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.
This commit is contained in:
Brandon McGinty (deepseek)
2026-08-08 19:38:35 -04:00
committed by Brandon McGinty
parent 181b325c2d
commit 80580d2a3c
+50 -13
View File
@@ -103,7 +103,6 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
decoder := user.decoder decoder := user.decoder
if decoder == nil { if decoder == nil {
// TODO: decoder pool // TODO: decoder pool
// TODO: de-reference after stream is done
codec := c.audioCodec codec := c.audioCodec
if codec == nil { if codec == nil {
return errNoCodec return errNoCodec
@@ -119,18 +118,19 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
} }
buffer = buffer[n:] buffer = buffer[n:]
// Detect sequence gaps (packet loss) and reset the decoder to prevent // Detect sequence gaps (packet loss). Use Opus Packet Loss
// permanent audio corruption from state desync. // Concealment to fill gaps rather than resetting the decoder,
// which would cause audible glitches.
// Mumble uses a monotonically increasing sequence that wraps at MaxInt32. // Mumble uses a monotonically increasing sequence that wraps at MaxInt32.
if user.audioSequenceValid { 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 gap := seq - user.audioSequence
if gap > 1 && gap < 100 { 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 { } else if gap < 0 && gap > -100 {
// Reordered packet — reset to be safe, since the decoder // Reordered packet — reset decoder to prevent corruption.
// state depends on correct frame ordering.
decoder.Reset() decoder.Reset()
} }
} }
@@ -175,6 +175,33 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
event.HasPosition = true 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() c.volatile.Lock()
for item := c.Config.AudioListeners.head; item != nil; item = item.next { for item := c.Config.AudioListeners.head; item != nil; item = item.next {
c.volatile.Unlock() c.volatile.Unlock()
@@ -182,19 +209,17 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
if ch == nil { if ch == nil {
ch = make(chan *AudioPacket) ch = make(chan *AudioPacket)
item.streams[user] = ch item.streams[user] = ch
event := AudioStreamEvent{ streamEvent := AudioStreamEvent{
Client: c, Client: c,
User: user, User: user,
C: ch, C: ch,
} }
item.listener.OnAudioStream(&event) item.listener.OnAudioStream(&streamEvent)
} }
ch <- &event ch <- packet
c.volatile.Lock() c.volatile.Lock()
} }
c.volatile.Unlock() c.volatile.Unlock()
return nil
} }
func (c *Client) handleAuthenticate(buffer []byte) error { 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(event.User.Channel.Users, session)
} }
delete(c.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 { if packet.Reason != nil {
event.String = *packet.Reason event.String = *packet.Reason
} }