Scale Opus bitrate to audio frame duration

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-09 14:36:00 -04:00
committed by Brandon McGinty
parent 2bdf9a8193
commit aa38c99aab
3 changed files with 32 additions and 9 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ Priority 2: protocol and data correctness
terminators, positional data, and volume adjustment. Test real UDP terminators, positional data, and volume adjustment. Test real UDP
fallback behavior too. 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 File: gumble/opus/opus.go
bitrate is maxDataBytes * 8 * 100. For permitted 20/40/60 ms intervals it 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 is 2x/4x/6x too high. Calculate bits per frame divided by the actual
+15 -8
View File
@@ -30,7 +30,8 @@ func (*generator) NewEncoder() gumble.AudioEncoder {
// Force mono for voice transmission // Force mono for voice transmission
e, _ := opus.NewEncoder(gumble.AudioSampleRate, VoiceChannels, opus.AppVoIP) e, _ := opus.NewEncoder(gumble.AudioSampleRate, VoiceChannels, opus.AppVoIP)
return &Encoder{ return &Encoder{
Encoder: e, Encoder: e,
channels: VoiceChannels,
} }
} }
@@ -39,7 +40,8 @@ func NewStereoEncoder() gumble.AudioEncoder {
// Create stereo encoder for file playback // Create stereo encoder for file playback
e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio) e, _ := opus.NewEncoder(gumble.AudioSampleRate, gumble.AudioChannels, opus.AppAudio)
return &Encoder{ return &Encoder{
Encoder: e, Encoder: e,
channels: gumble.AudioChannels,
} }
} }
@@ -56,18 +58,15 @@ func (*generator) NewDecoder() gumble.AudioDecoder {
// encoder // encoder
type Encoder struct { type Encoder struct {
*opus.Encoder *opus.Encoder
channels int
} }
func (*Encoder) ID() int { func (*Encoder) ID() int {
return ID return ID
} }
func (e *Encoder) Encode(pcm []int16, _, maxDataBytes int) ([]byte, error) { func (e *Encoder) Encode(pcm []int16, frameSamples, maxDataBytes int) ([]byte, error) {
// Set the encoder bitrate to match the available bandwidth per frame. bitrate := encoderBitrate(maxDataBytes, frameSamples, e.channels)
// 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
if bitrate < 8000 { if bitrate < 8000 {
bitrate = 8000 // Opus minimum viable bitrate for voice 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 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() { func (e *Encoder) Reset() {
_ = e.Encoder.Reset() _ = e.Encoder.Reset()
} }
+16
View File
@@ -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)
}
}
}