Fix UDP audio frame timestamp handling

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-10 15:18:04 -04:00
committed by Brandon McGinty
parent 9c487c2586
commit 5b0879ddaa
6 changed files with 84 additions and 23 deletions
+2 -2
View File
@@ -86,8 +86,8 @@ type AudioPacket struct {
Sender *User Sender *User
Target *VoiceTarget Target *VoiceTarget
// Sequence is the UDP audio packet sequence number, used by the // Sequence is the UDP audio frame timestamp, used by the jitter buffer to
// jitter buffer to reorder packets. // reorder packets.
Sequence int64 Sequence int64
AudioBuffer AudioBuffer
+5 -1
View File
@@ -232,11 +232,15 @@ func (c *Client) AudioOutgoing() chan<- AudioBuffer {
ch := make(chan AudioBuffer) ch := make(chan AudioBuffer)
go func() { go func() {
var seq int64 var seq int64
frameStep := int64(c.Config.AudioFrameSize() / AudioDefaultFrameSize)
if frameStep < 1 {
frameStep = 1
}
previous := <-ch previous := <-ch
for p := range ch { for p := range ch {
previous.writeAudio(c, seq, false) previous.writeAudio(c, seq, false)
previous = p previous = p
seq = (seq + 1) % math.MaxInt32 seq = (seq + frameStep) % math.MaxInt32
} }
if previous != nil { if previous != nil {
previous.writeAudio(c, seq, true) previous.writeAudio(c, seq, true)
+3 -2
View File
@@ -106,10 +106,11 @@ func testLocalMumbleAudioRoundTrip(t *testing.T, disableUDP bool, interval time.
// Discard the first few decoder warm-up frames before measuring pitch. // Discard the first few decoder warm-up frames before measuring pitch.
var packet *gumble.AudioPacket var packet *gumble.AudioPacket
var previousSequence int64 var previousSequence int64
frameStep := int64(sendConfig.AudioFrameSize() / gumble.AudioDefaultFrameSize)
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
select { select {
case packet = <-listener.packets: case packet = <-listener.packets:
if i > 0 && packet.Sequence != previousSequence+1 { if i > 0 && packet.Sequence != previousSequence+frameStep {
t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence) t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence)
} }
previousSequence = packet.Sequence previousSequence = packet.Sequence
@@ -119,7 +120,7 @@ func testLocalMumbleAudioRoundTrip(t *testing.T, disableUDP bool, interval time.
} }
select { select {
case packet = <-listener.packets: case packet = <-listener.packets:
if packet.Sequence != previousSequence+1 { if packet.Sequence != previousSequence+frameStep {
t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence) t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence)
} }
frequency, purity, peak := audioQuality(packet.AudioBuffer) frequency, purity, peak := audioQuality(packet.AudioBuffer)
+42 -12
View File
@@ -733,6 +733,8 @@ func (c *Client) dispatchOpus15(pktNum uint64, session uint32, frameNum int64, o
if terminator && len(opusData) == 0 { if terminator && len(opusData) == 0 {
decoder.Reset() decoder.Reset()
user.audioSequenceValid = false
user.audioFrameStep = 0
log.Info("UDP15 #%d: terminator for %s, decoder reset", pktNum, user.Name) log.Info("UDP15 #%d: terminator for %s, decoder reset", pktNum, user.Name)
return return
} }
@@ -791,14 +793,23 @@ func (c *Client) handleLegacyUDPVoice(pktNum uint64, data []byte) {
// decodeAndDispatch decodes an Opus frame and dispatches PCM to audio listeners. // decodeAndDispatch decodes an Opus frame and dispatches PCM to audio listeners.
func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecoder, frameNum int64, opusData []byte, context uint32, position *[3]float32, volumeAdjustment float32) { func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecoder, frameNum int64, opusData []byte, context uint32, position *[3]float32, volumeAdjustment float32) {
// Detect sequence gaps. // Frame numbers are timestamps in 10 ms units, not packet counters. For
// example, a standard 20 ms Opus packet advances its frame number by two.
// Only generate PLC for complete missing packets; treating every timestamp
// unit as a packet doubles playout and eventually exhausts OpenAL buffers.
if user.audioSequenceValid { if user.audioSequenceValid {
gap := frameNum - user.audioSequence gap := frameNum - user.audioSequence
if gap > 1 && gap < 100 { frameStep := user.audioFrameStep
log.Info("UDP15 #%d: seq gap for %s: %d -> %d (loss=%d), generating PLC", if frameStep < 1 {
pktNum, user.Name, user.audioSequence, frameNum, gap-1) frameStep = 1
for i := int64(1); i < gap; i++ { }
c.dispatchPLC15(user, decoder) if gap > frameStep && gap < 100 {
if missing := missingAudioPackets(gap, frameStep); missing > 0 {
log.Info("UDP15 #%d: audio gap for %s: %d -> %d (loss=%d), generating PLC",
pktNum, user.Name, user.audioSequence, frameNum, missing)
for i := int64(1); i <= missing; i++ {
c.dispatchPLC15(user, decoder, user.audioSequence+i*frameStep)
}
} }
} else if gap < 0 && gap > -100 { } else if gap < 0 && gap > -100 {
log.Info("UDP15 #%d: seq reorder for %s: %d -> %d, resetting decoder", log.Info("UDP15 #%d: seq reorder for %s: %d -> %d, resetting decoder",
@@ -809,8 +820,6 @@ func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecod
return return
} }
} }
user.audioSequence = frameNum
user.audioSequenceValid = true
pcm, err := decoder.Decode(opusData, AudioMaximumFrameSize) pcm, err := decoder.Decode(opusData, AudioMaximumFrameSize)
if err != nil { if err != nil {
@@ -820,6 +829,9 @@ func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecod
} }
log.Debug("UDP15 #%d: Opus OK for %s, pcm_samples=%d", pktNum, user.Name, len(pcm)) log.Debug("UDP15 #%d: Opus OK for %s, pcm_samples=%d", pktNum, user.Name, len(pcm))
user.audioSequence = frameNum
user.audioSequenceValid = true
user.audioFrameStep = audioFrameStep(len(pcm))
event := AudioPacket{ event := AudioPacket{
Client: c, Client: c,
@@ -836,20 +848,38 @@ func (c *Client) decodeAndDispatch(pktNum uint64, user *User, decoder AudioDecod
c.dispatchAudio(user, &event) c.dispatchAudio(user, &event)
} }
// missingAudioPackets returns the number of whole packets absent from a
// timestamp gap. A non-integral gap cannot reliably identify a missing packet.
func missingAudioPackets(gap, frameStep int64) int64 {
if frameStep < 1 || gap <= frameStep || gap%frameStep != 0 {
return 0
}
return gap/frameStep - 1
}
// audioFrameStep converts interleaved stereo PCM length to Mumble's 10 ms
// frame-number units.
func audioFrameStep(samples int) int64 {
frames := samples / AudioChannels
step := int64(frames / AudioDefaultFrameSize)
if step < 1 {
return 1
}
return step
}
// dispatchPLC15 generates a Packet Loss Concealment frame for 1.5 UDP. // dispatchPLC15 generates a Packet Loss Concealment frame for 1.5 UDP.
func (c *Client) dispatchPLC15(user *User, decoder AudioDecoder) { func (c *Client) dispatchPLC15(user *User, decoder AudioDecoder, sequence int64) {
pcm, err := decoder.Decode(nil, AudioMaximumFrameSize) pcm, err := decoder.Decode(nil, AudioMaximumFrameSize)
if err != nil { if err != nil {
decoder.Reset() decoder.Reset()
return return
} }
seq := user.audioSequence + 1
user.audioSequence = seq
event := AudioPacket{ event := AudioPacket{
Client: c, Client: c,
Sender: user, Sender: user,
Target: &VoiceTarget{ID: 0}, Target: &VoiceTarget{ID: 0},
Sequence: seq, Sequence: sequence,
AudioBuffer: AudioBuffer(pcm), AudioBuffer: AudioBuffer(pcm),
} }
c.dispatchAudio(user, &event) c.dispatchAudio(user, &event)
+25
View File
@@ -223,6 +223,31 @@ func TestUDPAudioProtobufReferenceVector(t *testing.T) {
} }
} }
func TestAudioFrameTimestampGaps(t *testing.T) {
tests := []struct {
name string
gap, step int64
want int64
}{
{"consecutive 10 ms packets", 1, 1, 0},
{"consecutive 20 ms packets", 2, 2, 0},
{"one missing 20 ms packet", 4, 2, 1},
{"two missing 20 ms packets", 6, 2, 2},
{"non-integral timestamp gap", 3, 2, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := missingAudioPackets(tt.gap, tt.step); got != tt.want {
t.Fatalf("missingAudioPackets(%d, %d) = %d, want %d", tt.gap, tt.step, got, tt.want)
}
})
}
if got := audioFrameStep(1920); got != 2 {
t.Fatalf("audioFrameStep(1920) = %d, want 2", got)
}
}
func TestUDPAudioProtobufIncomingFields(t *testing.T) { func TestUDPAudioProtobufIncomingFields(t *testing.T) {
var packet bytes.Buffer var packet bytes.Buffer
writeVarint := func(v uint64) { writeVarint := func(v uint64) {
+3 -2
View File
@@ -52,10 +52,11 @@ type User struct {
client *Client client *Client
decoder AudioDecoder decoder AudioDecoder
// audioSequence tracks the last UDP audio packet sequence number for // audioSequence tracks the last UDP audio frame timestamp for this user,
// this user, used to detect packet loss and reset the Opus decoder. // used to detect packet loss and reset the Opus decoder.
audioSequence int64 audioSequence int64
audioSequenceValid bool audioSequenceValid bool
audioFrameStep int64
// audioMu protects audio-related fields accessed from both the // audioMu protects audio-related fields accessed from both the
// audio processing goroutine (OnAudioStream) and the UI goroutine. // audio processing goroutine (OnAudioStream) and the UI goroutine.