From 80580d2a3c6f2555579b87cc3bcadae40d4d19d8 Mon Sep 17 00:00:00 2001 From: "Brandon McGinty (deepseek)" Date: Sat, 8 Aug 2026 19:38:35 -0400 Subject: [PATCH] fix: close audio stream channels on disconnect and add Opus PLC Close audio stream channels in handleUserRemove to prevent goroutine leaks. Each remote user that sends audio spawns a goroutine in OnAudioStream that blocks on an unbuffered channel; without explicit closure, these goroutines leaked on every user disconnect. Add Opus Packet Loss Concealment for detected sequence gaps. When a sequence number discontinuity indicates lost packets, feed empty data to the Opus decoder to produce PLC frames that bridge the gap. This replaces the previous approach of resetting the decoder, which caused audible glitches/silence on packet loss. Extracted dispatchAudio helper to avoid code duplication between real and PLC frame delivery. --- gumble/gumble/handlers.go | 63 +++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/gumble/gumble/handlers.go b/gumble/gumble/handlers.go index be02013..e9ab28f 100644 --- a/gumble/gumble/handlers.go +++ b/gumble/gumble/handlers.go @@ -103,7 +103,6 @@ func (c *Client) handleUDPTunnel(buffer []byte) error { decoder := user.decoder if decoder == nil { // TODO: decoder pool - // TODO: de-reference after stream is done codec := c.audioCodec if codec == nil { return errNoCodec @@ -119,18 +118,19 @@ func (c *Client) handleUDPTunnel(buffer []byte) error { } buffer = buffer[n:] - // Detect sequence gaps (packet loss) and reset the decoder to prevent - // permanent audio corruption from state desync. + // Detect sequence gaps (packet loss). Use Opus Packet Loss + // Concealment to fill gaps rather than resetting the decoder, + // which would cause audible glitches. // Mumble uses a monotonically increasing sequence that wraps at MaxInt32. if user.audioSequenceValid { - // Only treat as discontinuity if the gap is small enough to be loss - // rather than a legitimate wrap-around or restart. gap := seq - user.audioSequence if gap > 1 && gap < 100 { - decoder.Reset() + // Lost packets detected; generate PLC frames for each. + for i := int64(1); i < gap; i++ { + c.dispatchPLC(user, audioTarget, decoder) + } } else if gap < 0 && gap > -100 { - // Reordered packet — reset to be safe, since the decoder - // state depends on correct frame ordering. + // Reordered packet — reset decoder to prevent corruption. decoder.Reset() } } @@ -175,6 +175,33 @@ func (c *Client) handleUDPTunnel(buffer []byte) error { event.HasPosition = true } + c.dispatchAudio(user, &event) + return nil +} + +// dispatchPLC generates a Packet Loss Concealment frame from the decoder +// and dispatches it to all audio listeners for the given user. +func (c *Client) dispatchPLC(user *User, audioTarget byte, decoder AudioDecoder) { + // Feed empty data to the decoder to trigger Opus PLC, which + // produces a concealed frame bridging the gap. + pcm, err := decoder.Decode(nil, AudioMaximumFrameSize) + if err != nil { + // If PLC fails, reset the decoder so the next real packet + // starts from a clean state. + decoder.Reset() + return + } + event := AudioPacket{ + Client: c, + Sender: user, + Target: &VoiceTarget{ID: uint32(audioTarget)}, + AudioBuffer: AudioBuffer(pcm), + } + c.dispatchAudio(user, &event) +} + +// dispatchAudio sends an audio packet to all registered audio listeners. +func (c *Client) dispatchAudio(user *User, packet *AudioPacket) { c.volatile.Lock() for item := c.Config.AudioListeners.head; item != nil; item = item.next { c.volatile.Unlock() @@ -182,19 +209,17 @@ func (c *Client) handleUDPTunnel(buffer []byte) error { if ch == nil { ch = make(chan *AudioPacket) item.streams[user] = ch - event := AudioStreamEvent{ + streamEvent := AudioStreamEvent{ Client: c, User: user, C: ch, } - item.listener.OnAudioStream(&event) + item.listener.OnAudioStream(&streamEvent) } - ch <- &event + ch <- packet c.volatile.Lock() } c.volatile.Unlock() - - return nil } func (c *Client) handleAuthenticate(buffer []byte) error { @@ -479,6 +504,18 @@ func (c *Client) handleUserRemove(buffer []byte) error { delete(event.User.Channel.Users, session) } delete(c.Users, session) + + // Close audio stream channels for the disconnected user. + // This is safe because handleUserRemove and handleUDPTunnel + // both run in the serialized readRoutine; no concurrent send + // on these channels is possible once the user is removed. + for item := c.Config.AudioListeners.head; item != nil; item = item.next { + if ch, ok := item.streams[event.User]; ok { + close(ch) + delete(item.streams, event.User) + } + } + if packet.Reason != nil { event.String = *packet.Reason }