diff --git a/decoder.go b/decoder.go index 71c03f4..ec51622 100644 --- a/decoder.go +++ b/decoder.go @@ -270,6 +270,8 @@ func (c Configuration) silkFrameSampleCount() int { return 16 * c.frameDuration().nanoseconds() / 1000000 case BandwidthSuperwideband, BandwidthFullband: return 0 + case BandwidthAuto: + return 0 } return 0 diff --git a/encoder.go b/encoder.go index 1fa5fa0..ed55811 100644 --- a/encoder.go +++ b/encoder.go @@ -63,6 +63,8 @@ type Encoder struct { vbr bool constrainedVBR bool lossRate int + bandwidth Bandwidth + maxBandwidth Bandwidth } // EncoderOption configures an Encoder during construction. @@ -163,9 +165,48 @@ func WithConstrainedVBR(cvbr bool) EncoderOption { } } +// WithBandwidth sets the encoder bandwidth explicitly (Narrowband through +// Fullband; Mediumband is SILK-only and not supported here). Use +// WithMaxBandwidth instead to cap auto-selection rather than fixing it. +func WithBandwidth(bw Bandwidth) EncoderOption { + return func(e *Encoder) error { + if bw == BandwidthAuto { + return fmt.Errorf("%w: use WithMaxBandwidth for auto selection", errInvalidBandwidth) + } + if bw < BandwidthNarrowband || bw > BandwidthFullband { + return fmt.Errorf("%w: %d", errInvalidBandwidth, bw) + } + if bw == BandwidthMediumband { + return fmt.Errorf("%w: mediumband not supported in CELT-only mode", errInvalidBandwidth) + } + e.bandwidth = bw + + return nil + } +} + +// WithMaxBandwidth sets the maximum bandwidth the auto-select algorithm may +// choose. Has no effect when an explicit bandwidth is set via WithBandwidth. +func WithMaxBandwidth(bw Bandwidth) EncoderOption { + return func(e *Encoder) error { + if bw == BandwidthAuto { + return fmt.Errorf("%w: max bandwidth must be explicit", errInvalidBandwidth) + } + if bw < BandwidthNarrowband || bw > BandwidthFullband { + return fmt.Errorf("%w: %d", errInvalidBandwidth, bw) + } + if bw == BandwidthMediumband { + return fmt.Errorf("%w: mediumband not supported in CELT-only mode", errInvalidBandwidth) + } + e.maxBandwidth = bw + + return nil + } +} + // NewEncoder creates a new Opus encoder with the supplied options. // -// Defaults: 48 kHz, mono, 24 kbit/s, complexity 0. Pass options to override +// Defaults: 48 kHz, mono, 24 kbit/s, complexity 5. Pass options to override // any of these. The current implementation supports 48 kHz, 1 or 2 channels, // 20 ms CELT-only packets. Transient detection and SILK encoding will land // in follow-up PRs. @@ -175,11 +216,13 @@ func NewEncoder(opts ...EncoderOption) (*Encoder, error) { sampleRate: celtSampleRate, channels: 1, bitrate: defaultBitrate, - complexity: 0, + complexity: 5, application: ApplicationAudio, vbr: false, constrainedVBR: true, lossRate: 0, + bandwidth: BandwidthAuto, + maxBandwidth: BandwidthFullband, } for _, opt := range opts { @@ -244,6 +287,18 @@ func (e *Encoder) SetLossRate(rate int) error { return nil } +// SetBandwidth sets the encoder bandwidth, overriding auto-selection. +func (e *Encoder) SetBandwidth(bw Bandwidth) error { + return WithBandwidth(bw)(e) +} + +// SetMaxBandwidth sets the maximum bandwidth the auto-select algorithm may +// choose. Only affects encoding when bandwidth is set to BandwidthAuto (the +// default). +func (e *Encoder) SetMaxBandwidth(bw Bandwidth) error { + return WithMaxBandwidth(bw)(e) +} + // Application returns the current encoder application mode. func (e *Encoder) Application() Application { return e.application } @@ -258,6 +313,13 @@ func (e *Encoder) ConstrainedVBR() bool { return e.constrainedVBR } // LossRate returns the expected packet loss rate (0-100 percent). func (e *Encoder) LossRate() int { return e.lossRate } +// Bandwidth returns the configured bandwidth (BandwidthAuto by default). +func (e *Encoder) Bandwidth() Bandwidth { return e.bandwidth } + +// MaxBandwidth returns the maximum bandwidth the auto-select algorithm may +// choose. +func (e *Encoder) MaxBandwidth() Bandwidth { return e.maxBandwidth } + // Encode encodes S16LE PCM into a single Opus packet. // // The input must contain exactly one 20 ms mono 48 kHz frame. @@ -303,7 +365,12 @@ func (e *Encoder) EncodeFloat32(in []float32, out []byte) (int, error) { return 0, errOutBufferTooSmall } out[0] = byte(e.tocHeader()) - n, err := e.celtEncoder.EncodeFrame(channels, out[1:frameBytes+1], frameBytes, 0, e.celtEncoder.Mode().BandCount()) + bw := e.autoSelectBandwidth() + startBand, endBand, err := e.celtEncoder.Mode().BandRangeForSampleRate(bw.SampleRate()) + if err != nil { + return 0, err + } + n, err := e.celtEncoder.EncodeFrame(channels, out[1:frameBytes+1], frameBytes, startBand, endBand) if err != nil { return 0, err } @@ -312,8 +379,19 @@ func (e *Encoder) EncodeFloat32(in []float32, out []byte) (int, error) { } func (e *Encoder) tocHeader() tableOfContentsHeader { - header := byte(celtOnlyFullband20msConfig << 3) - header |= byte(frameCodeOneFrame) + bw := e.autoSelectBandwidth() + var config int + switch bw { + case BandwidthNarrowband: + config = 19 // CELT-only, NB, 20 ms + case BandwidthWideband: + config = 23 // CELT-only, WB, 20 ms + case BandwidthSuperwideband: + config = 27 // CELT-only, SWB, 20 ms + default: // BandwidthFullband + config = 31 // CELT-only, FB, 20 ms + } + header := byte(config<<3) | byte(frameCodeOneFrame) if e.channels == 2 { header |= 1 << 2 } @@ -321,6 +399,48 @@ func (e *Encoder) tocHeader() tableOfContentsHeader { return tableOfContentsHeader(header) } +// equivRate estimates the effective bitrate actually available for coding, +// mirroring libopus's compute_equiv_rate (opus_encoder.c). The CELT-only +// branch there also docks ~10% for complexity<5 lacking the pitch filter; +// omitted here since this encoder's pitch pre-filter always runs regardless +// of complexity. The frame-rate-overhead term is also omitted: it only +// applies above 50 frames/sec, and this encoder is fixed at 20 ms (50/sec). +func (e *Encoder) equivRate() int { + equiv := e.bitrate + if !e.vbr { + equiv -= equiv / 12 // CBR costs about 8%. + } + + return equiv * (90 + e.complexity) / 100 // complexity spans about 10%. +} + +// autoSelectBandwidth selects the best bandwidth for the current bitrate, +// clamped to maxBandwidth. Returns the effective bandwidth to use for encoding. +func (e *Encoder) autoSelectBandwidth() Bandwidth { + if e.bandwidth != BandwidthAuto { + return e.bandwidth + } + // Thresholds based on libopus voice defaults. + // NB↔WB: 9000 bps, WB↔SWB: 13500 bps, SWB↔FB: 14000 bps. + target := e.equivRate() + var bw Bandwidth + switch { + case target < 9000: + bw = BandwidthNarrowband + case target < 13500: + bw = BandwidthWideband + case target < 14000: + bw = BandwidthSuperwideband + default: + bw = BandwidthFullband + } + if bw > e.maxBandwidth { + bw = e.maxBandwidth + } + + return bw +} + // splitChannels splits interleaved PCM into per-channel slices. // For mono, it returns the input directly without allocation. func splitChannels(in []float32, numChannels, frameSamples int) [][]float32 { diff --git a/encoder_test.go b/encoder_test.go index 254d9dc..4768a09 100644 --- a/encoder_test.go +++ b/encoder_test.go @@ -344,6 +344,225 @@ func TestSetConstrainedVBR(t *testing.T) { assert.True(t, encoder.ConstrainedVBR()) } +func TestWithBandwidth(t *testing.T) { + enc, err := NewEncoder(WithBandwidth(BandwidthWideband)) + require.NoError(t, err) + assert.Equal(t, BandwidthWideband, enc.Bandwidth()) + + _, err = NewEncoder(WithBandwidth(BandwidthAuto)) + assert.ErrorIs(t, err, errInvalidBandwidth) + + _, err = NewEncoder(WithBandwidth(BandwidthMediumband)) + assert.ErrorIs(t, err, errInvalidBandwidth) + + _, err = NewEncoder(WithBandwidth(Bandwidth(255))) + assert.ErrorIs(t, err, errInvalidBandwidth) +} + +func TestSetBandwidth(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + assert.Equal(t, BandwidthAuto, enc.Bandwidth()) + + require.NoError(t, enc.SetBandwidth(BandwidthWideband)) + assert.Equal(t, BandwidthWideband, enc.Bandwidth()) + + assert.ErrorIs(t, enc.SetBandwidth(BandwidthMediumband), errInvalidBandwidth) +} + +func TestBandwidthRoundTrip(t *testing.T) { + for _, bw := range []Bandwidth{ + BandwidthNarrowband, BandwidthWideband, + BandwidthSuperwideband, BandwidthFullband, + } { + enc, err := NewEncoder( + WithBandwidth(bw), + WithBitrate(24000), + ) + require.NoError(t, err) + + dec, err := NewDecoderWithOutput(48000, 1) + require.NoError(t, err) + + pcm := testEncoderSineFloat32() + packet := make([]byte, 256) + n, err := enc.EncodeFloat32(pcm, packet) + require.NoError(t, err) + require.Greater(t, n, 0) + + out := make([]float32, encoderTestFrameSampleCount) + decBandwidth, _, err := dec.DecodeFloat32(packet[:n], out) + require.NoError(t, err) + assert.Equal(t, bw, decBandwidth, "decoded bandwidth should match encoded bandwidth") + } +} + +func TestBandwidthChangesTOC(t *testing.T) { + for _, tc := range []struct { + bw Bandwidth + config byte + }{ + {BandwidthNarrowband, 19}, + {BandwidthWideband, 23}, + {BandwidthSuperwideband, 27}, + {BandwidthFullband, 31}, + } { + enc, err := NewEncoder(WithBandwidth(tc.bw)) + require.NoError(t, err) + + pcm := testEncoderSineFloat32() + packet := make([]byte, 256) + n, err := enc.EncodeFloat32(pcm, packet) + require.NoError(t, err) + require.Greater(t, n, 0) + + expectedTOC := tc.config<<3 | byte(frameCodeOneFrame) + assert.Equal(t, expectedTOC, packet[0], "TOC byte for bandwidth %v", tc.bw) + } +} + +func TestWithMaxBandwidth(t *testing.T) { + enc, err := NewEncoder(WithMaxBandwidth(BandwidthWideband)) + require.NoError(t, err) + assert.Equal(t, BandwidthWideband, enc.MaxBandwidth()) + + _, err = NewEncoder(WithMaxBandwidth(BandwidthAuto)) + assert.ErrorIs(t, err, errInvalidBandwidth) + + _, err = NewEncoder(WithMaxBandwidth(BandwidthMediumband)) + assert.ErrorIs(t, err, errInvalidBandwidth) + + _, err = NewEncoder(WithMaxBandwidth(Bandwidth(255))) + assert.ErrorIs(t, err, errInvalidBandwidth) +} + +func TestAutoSelectBandwidth(t *testing.T) { + for _, tc := range []struct { + name string + bitrate int + maxBW Bandwidth + expected Bandwidth + }{ + // Boundaries are in equivRate() space (CBR + default complexity 5 + // dock the raw bitrate by ~13%), not raw bitrate — see equivRate. + {"NB at low bitrate", 6000, BandwidthFullband, BandwidthNarrowband}, + {"NB at threshold", 10334, BandwidthFullband, BandwidthNarrowband}, // equiv=8999 + {"WB just above threshold", 10335, BandwidthFullband, BandwidthWideband}, // equiv=9000 + {"WB at 12kbps", 12000, BandwidthFullband, BandwidthWideband}, + {"WB at threshold", 15501, BandwidthFullband, BandwidthWideband}, // equiv=13499 + {"SWB just above threshold", 15502, BandwidthFullband, BandwidthSuperwideband}, // equiv=13500 + {"SWB at threshold", 16075, BandwidthFullband, BandwidthSuperwideband}, // equiv=13999 + {"FB just above threshold", 16076, BandwidthFullband, BandwidthFullband}, // equiv=14000 + {"FB at 24kbps", 24000, BandwidthFullband, BandwidthFullband}, + {"clamped by maxBandwidth", 24000, BandwidthWideband, BandwidthWideband}, + {"clamped to NB", 24000, BandwidthNarrowband, BandwidthNarrowband}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := NewEncoder( + WithBitrate(tc.bitrate), + WithMaxBandwidth(tc.maxBW), + ) + require.NoError(t, err) + assert.Equal(t, tc.expected, enc.autoSelectBandwidth()) + }) + } +} + +func TestEquivRate(t *testing.T) { + enc, err := NewEncoder(WithBitrate(13500)) + require.NoError(t, err) + // CBR (default) docks ~8%, complexity 5 (default) docks another ~5%. + assert.Equal(t, 11756, enc.equivRate()) + + enc.SetVBR(true) + assert.Equal(t, 12825, enc.equivRate(), "VBR should skip the CBR penalty") + + enc.SetVBR(false) + require.NoError(t, enc.SetComplexity(0)) + assert.Equal(t, 11137, enc.equivRate(), "complexity 0 should dock closer to 10%") +} + +func TestAutoBandwidthDefault(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + assert.Equal(t, BandwidthAuto, enc.Bandwidth()) + assert.Equal(t, BandwidthFullband, enc.MaxBandwidth()) + // At default 24 kbps, auto should select fullband. + assert.Equal(t, BandwidthFullband, enc.autoSelectBandwidth()) +} + +func TestAutoBandwidthExplicitOverrides(t *testing.T) { + enc, err := NewEncoder( + WithBandwidth(BandwidthWideband), + WithMaxBandwidth(BandwidthFullband), + ) + require.NoError(t, err) + // Explicit bandwidth should be returned regardless of maxBandwidth. + assert.Equal(t, BandwidthWideband, enc.autoSelectBandwidth()) +} + +func TestAutoBandwidthTOC(t *testing.T) { + // At 6000 bps, auto should select NB → config 19. + enc, err := NewEncoder(WithBitrate(6000)) + require.NoError(t, err) + + pcm := testEncoderSineFloat32() + packet := make([]byte, 256) + n, err := enc.EncodeFloat32(pcm, packet) + require.NoError(t, err) + require.Greater(t, n, 0) + + expectedTOC := byte(19<<3) | byte(frameCodeOneFrame) + assert.Equal(t, expectedTOC, packet[0], "auto NB TOC") +} + +func TestAutoBandwidthRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + bitrate int + maxBW Bandwidth + wantBW Bandwidth + }{ + {"NB round-trip", 6000, BandwidthFullband, BandwidthNarrowband}, + {"WB round-trip", 12000, BandwidthFullband, BandwidthWideband}, + {"FB round-trip", 24000, BandwidthFullband, BandwidthFullband}, + } { + t.Run(tc.name, func(t *testing.T) { + enc, err := NewEncoder( + WithBitrate(tc.bitrate), + WithMaxBandwidth(tc.maxBW), + ) + require.NoError(t, err) + + dec, err := NewDecoderWithOutput(48000, 1) + require.NoError(t, err) + + pcm := testEncoderSineFloat32() + packet := make([]byte, 256) + n, err := enc.EncodeFloat32(pcm, packet) + require.NoError(t, err) + require.Greater(t, n, 0) + + out := make([]float32, encoderTestFrameSampleCount) + decBandwidth, _, err := dec.DecodeFloat32(packet[:n], out) + require.NoError(t, err) + assert.Equal(t, tc.wantBW, decBandwidth, "decoded bandwidth should match auto-selected bandwidth") + }) + } +} + +func TestSetMaxBandwidth(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + assert.Equal(t, BandwidthFullband, enc.MaxBandwidth()) + + require.NoError(t, enc.SetMaxBandwidth(BandwidthWideband)) + assert.Equal(t, BandwidthWideband, enc.MaxBandwidth()) + + assert.ErrorIs(t, enc.SetMaxBandwidth(BandwidthAuto), errInvalidBandwidth) + assert.ErrorIs(t, enc.SetMaxBandwidth(BandwidthMediumband), errInvalidBandwidth) +} + func TestSetLossRate(t *testing.T) { encoder, err := NewEncoder() require.NoError(t, err) diff --git a/errors.go b/errors.go index 4fd698b..7d8ec13 100644 --- a/errors.go +++ b/errors.go @@ -35,4 +35,6 @@ var ( errInvalidApplication = errors.New("invalid application") errInvalidLossRate = errors.New("loss rate must be 0-100") + + errInvalidBandwidth = errors.New("invalid bandwidth") ) diff --git a/table_of_contents_header.go b/table_of_contents_header.go index dcdf9b8..a68c250 100644 --- a/table_of_contents_header.go +++ b/table_of_contents_header.go @@ -235,13 +235,16 @@ func (c Configuration) frameDuration() frameDuration { return 0 } -// Bandwidth constants. +// Bandwidth constants. Numbered explicitly, not with iota, because +// BandwidthAuto's 0 value doesn't belong to the Narrowband..Fullband +// sequence it's leading. const ( - BandwidthNarrowband Bandwidth = iota + 1 - BandwidthMediumband - BandwidthWideband - BandwidthSuperwideband - BandwidthFullband + BandwidthAuto Bandwidth = 0 // let the encoder select based on bitrate + BandwidthNarrowband Bandwidth = 1 + BandwidthMediumband Bandwidth = 2 + BandwidthWideband Bandwidth = 3 + BandwidthSuperwideband Bandwidth = 4 + BandwidthFullband Bandwidth = 5 ) // See Configuration for mapping of bandwidth to configuration numbers @@ -268,11 +271,13 @@ func (c Configuration) bandwidth() Bandwidth { return BandwidthFullband } - return 0 + return Bandwidth(255) } func (b Bandwidth) String() string { switch b { + case BandwidthAuto: + return "Auto" case BandwidthNarrowband: return "Narrowband" case BandwidthMediumband: @@ -291,6 +296,8 @@ func (b Bandwidth) String() string { // SampleRate returns the effective SampleRate for a given bandwidth. func (b Bandwidth) SampleRate() int { switch b { + case BandwidthAuto: + return 0 case BandwidthNarrowband: return 8000 case BandwidthMediumband: diff --git a/table_of_contents_header_test.go b/table_of_contents_header_test.go index d5834bc..668d82a 100644 --- a/table_of_contents_header_test.go +++ b/table_of_contents_header_test.go @@ -75,7 +75,7 @@ func TestBandwidth(t *testing.T) { assert.Equal(t, BandwidthWideband, Configuration(20).bandwidth()) assert.Equal(t, BandwidthSuperwideband, Configuration(24).bandwidth()) assert.Equal(t, BandwidthFullband, Configuration(28).bandwidth()) - assert.Equal(t, Bandwidth(0), Configuration(32).bandwidth()) + assert.Equal(t, Bandwidth(255), Configuration(32).bandwidth()) } func TestBandwidthString(t *testing.T) { @@ -86,7 +86,8 @@ func TestBandwidthString(t *testing.T) { assert.Equal(t, "Wideband", BandwidthWideband.String()) assert.Equal(t, "Superwideband", BandwidthSuperwideband.String()) assert.Equal(t, "Fullband", BandwidthFullband.String()) - assert.Equal(t, "Invalid Bandwidth", Bandwidth(0).String()) + assert.Equal(t, "Auto", BandwidthAuto.String()) + assert.Equal(t, "Invalid Bandwidth", Bandwidth(255).String()) } func TestBandwidthSampleRate(t *testing.T) {