diff --git a/fix.txt b/fix.txt index a82bf08..b1bd1d3 100644 --- a/fix.txt +++ b/fix.txt @@ -176,7 +176,7 @@ Priority 2: protocol and data correctness terminators, positional data, and volume adjustment. Test real UDP fallback behavior too. -22. Opus bitrate calculation assumes a 10 ms interval +[x] 22. Opus bitrate calculation assumes a 10 ms interval File: gumble/opus/opus.go bitrate is maxDataBytes * 8 * 100. For permitted 20/40/60 ms intervals it is 2x/4x/6x too high. Calculate bits per frame divided by the actual diff --git a/gumble/opus/opus.go b/gumble/opus/opus.go index a183e35..0eab4df 100644 --- a/gumble/opus/opus.go +++ b/gumble/opus/opus.go @@ -30,7 +30,8 @@ func (*generator) NewEncoder() gumble.AudioEncoder { // Force mono for voice transmission e, _ := opus.NewEncoder(gumble.AudioSampleRate, VoiceChannels, opus.AppVoIP) return &Encoder{ - Encoder: e, + Encoder: e, + channels: VoiceChannels, } } @@ -39,7 +40,8 @@ func NewStereoEncoder() gumble.AudioEncoder { // Create stereo encoder for file playback e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio) return &Encoder{ - Encoder: e, + Encoder: e, + channels: gumble.AudioChannels, } } @@ -56,18 +58,15 @@ func (*generator) NewDecoder() gumble.AudioDecoder { // encoder type Encoder struct { *opus.Encoder + channels int } func (*Encoder) ID() int { return ID } -func (e *Encoder) Encode(pcm []int16, _, maxDataBytes int) ([]byte, error) { - // Set the encoder bitrate to match the available bandwidth per frame. - // The bitrate is: maxDataBytes * 8 (bits per byte) * 100 (10ms frames per second). - // Opus encodes best when the bitrate target is set properly rather than - // relying on truncation, which can produce incomplete or corrupt frames. - bitrate := maxDataBytes * 8 * 100 +func (e *Encoder) Encode(pcm []int16, frameSamples, maxDataBytes int) ([]byte, error) { + bitrate := encoderBitrate(maxDataBytes, frameSamples, e.channels) if bitrate < 8000 { bitrate = 8000 // Opus minimum viable bitrate for voice } @@ -81,6 +80,14 @@ func (e *Encoder) Encode(pcm []int16, _, maxDataBytes int) ([]byte, error) { return buf[:n], nil } +// encoderBitrate converts a per-frame packet budget to bits per second. +func encoderBitrate(maxDataBytes, frameSamples, channels int) int { + if frameSamples <= 0 || channels <= 0 { + return 8000 + } + return maxDataBytes * 8 * gumble.AudioSampleRate * channels / frameSamples +} + func (e *Encoder) Reset() { _ = e.Encoder.Reset() } diff --git a/gumble/opus/opus_test.go b/gumble/opus/opus_test.go new file mode 100644 index 0000000..0d3531f --- /dev/null +++ b/gumble/opus/opus_test.go @@ -0,0 +1,16 @@ +package opus + +import "testing" + +// Regression: the encoder always assumed ten millisecond frames, causing the +// bitrate for 20/40/60 ms packets to be 2/4/6 times their actual budget. +func TestEncoderBitrateUsesFrameDuration(t *testing.T) { + const budget = 100 + for _, frameSamples := range []int{480, 960, 1920, 2880} { + got := encoderBitrate(budget, frameSamples, 1) + want := budget * 8 * 48000 / frameSamples + if got != want { + t.Fatalf("%d samples: got %d, want %d", frameSamples, got, want) + } + } +}