diff --git a/encoder.go b/encoder.go index ed55811..c44cedd 100644 --- a/encoder.go +++ b/encoder.go @@ -6,8 +6,10 @@ package opus import ( "encoding/binary" "fmt" + "math" "github.com/pion/opus/internal/celt" + "github.com/pion/opus/internal/silk" ) // Application selects the encoder's tuning profile, mirroring libopus's @@ -52,9 +54,31 @@ const ( // is separate (bit 2 of the TOC) and not part of this constant. const celtOnlyFullband20msConfig = 31 +// SILK-only, 20 ms TOC config numbers per RFC 6716 Table 2, one per bandwidth +// EncodeSILK supports. +const ( + silkOnlyNarrowband20msConfig = 1 + silkOnlyMediumband20msConfig = 5 + silkOnlyWideband20msConfig = 9 +) + +// silkComplexityInterpolationThreshold is the encoder complexity at or above +// which SILK's NLSF interpolation search is enabled, mirroring libopus's +// silk_setup_complexity (control_codec.c): useInterpolatedNLSFs is 0 below +// complexity 4, 1 from 4 up. +const silkComplexityInterpolationThreshold = 4 + +// silkDCBlockCutoffHz is the high-pass cutoff EncodeSILK applies to its input +// before handing it to internal/silk. libopus applies this filter (dc_reject, +// src/opus_encoder.c:479-507) to the shared PCM ahead of both the SILK and +// CELT encoders, not inside silk/ itself — so this mirrors celt's +// dcBlockCutoffHz rather than living in internal/silk. +const silkDCBlockCutoffHz = 3.0 + // Encoder encodes PCM into Opus packets. type Encoder struct { celtEncoder celt.Encoder + silkEncoder silk.Encoder sampleRate int channels int bitrate int @@ -65,6 +89,7 @@ type Encoder struct { lossRate int bandwidth Bandwidth maxBandwidth Bandwidth + silkDCBlockMem float32 } // EncoderOption configures an Encoder during construction. @@ -208,11 +233,12 @@ func WithMaxBandwidth(bw Bandwidth) EncoderOption { // // 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. +// 20 ms CELT-only packets, plus SILK-only encoding via EncodeSILK. Transient +// detection is a follow-up. func NewEncoder(opts ...EncoderOption) (*Encoder, error) { encoder := &Encoder{ celtEncoder: celt.NewEncoder(), + silkEncoder: silk.NewEncoder(), sampleRate: celtSampleRate, channels: 1, bitrate: defaultBitrate, @@ -235,6 +261,7 @@ func NewEncoder(opts ...EncoderOption) (*Encoder, error) { encoder.celtEncoder.SetConstrainedVBR(encoder.constrainedVBR) encoder.celtEncoder.SetLossRate(encoder.lossRate) encoder.celtEncoder.SetComplexity(encoder.complexity) + encoder.silkEncoder.SetUseInterpolatedNLSFs(encoder.complexity >= silkComplexityInterpolationThreshold) return encoder, nil } @@ -251,6 +278,7 @@ func (e *Encoder) SetComplexity(complexity int) error { return err } e.celtEncoder.SetComplexity(complexity) + e.silkEncoder.SetUseInterpolatedNLSFs(complexity >= silkComplexityInterpolationThreshold) return nil } @@ -378,6 +406,74 @@ func (e *Encoder) EncodeFloat32(in []float32, out []byte) (int, error) { return 1 + n, nil } +// EncodeSILK encodes one 20 ms mono SILK frame into a SILK-only Opus packet. +// pcm must hold exactly one 20 ms frame of mono s16 samples at the +// bandwidth's internal rate: 160 (Narrowband/8 kHz), 240 (Mediumband/12 kHz), +// or 320 (Wideband/16 kHz) samples. This is a separate entry point from +// Encode/EncodeFloat32 — bitrate-based auto-selection always picks CELT +// bandwidths (Wideband and up); SILK is for callers who specifically want a +// SILK-only voice packet (VoIP/narrowband use cases), not an automatic +// CELT/SILK/hybrid switch. Superwideband and Fullband aren't SILK +// bandwidths and are rejected. Applies a fixed DC-removal high-pass before +// encoding (libopus's dc_reject applied to the shared PCM path); the +// pitch-adaptive VoIP cutoff (hp_cutoff) is not implemented. Covers +// voiced/LTP prediction, noise shaping and NLSF interpolation (see +// internal/silk); the delayed-decision NSQ, stereo, hybrid mode, and the +// bitrate-control loop are not yet implemented. +func (e *Encoder) EncodeSILK(pcm []int16, bandwidth Bandwidth, out []byte) (int, error) { + var config int + switch bandwidth { + case BandwidthNarrowband: + config = silkOnlyNarrowband20msConfig + case BandwidthMediumband: + config = silkOnlyMediumband20msConfig + case BandwidthWideband: + config = silkOnlyWideband20msConfig + default: + return 0, fmt.Errorf("%w: %d", errInvalidBandwidth, bandwidth) + } + + want := bandwidth.SampleRate() / 50 // 20 ms + if len(pcm) != want { + return 0, fmt.Errorf("%w: got %d samples, want %d", errInvalidFrameSize, len(pcm), want) + } + + filtered := applySILKDCBlock(pcm, bandwidth.SampleRate(), &e.silkDCBlockMem) + payload := e.silkEncoder.Encode(filtered, silk.Bandwidth(bandwidth), e.bitrate) + if len(out) < len(payload)+1 { + return 0, errOutBufferTooSmall + } + + out[0] = byte(config<<3) | byte(frameCodeOneFrame) // mono, one frame + n := copy(out[1:], payload) + + return n + 1, nil +} + +// applySILKDCBlock removes DC bias from pcm with a first-order IIR high-pass +// at silkDCBlockCutoffHz, returning a new slice (pcm is left untouched). mem +// must persist across calls for the same stream. +func applySILKDCBlock(pcm []int16, sampleRate int, mem *float32) []int16 { + coef := float32(6.3) * silkDCBlockCutoffHz / float32(sampleRate) + coef2 := 1 - coef + out := make([]int16, len(pcm)) + for i, sample := range pcm { + x := float32(sample) + y := x - *mem + *mem = coef*x + coef2**mem + switch { + case y > 32767: + out[i] = 32767 + case y < -32768: + out[i] = -32768 + default: + out[i] = int16(math.Round(float64(y))) + } + } + + return out +} + func (e *Encoder) tocHeader() tableOfContentsHeader { bw := e.autoSelectBandwidth() var config int diff --git a/internal/silk/enc_frame.go b/internal/silk/enc_frame.go new file mode 100644 index 0000000..4aca677 --- /dev/null +++ b/internal/silk/enc_frame.go @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 The Pion community +// SPDX-License-Identifier: MIT + +package silk + +// This file assembles a full SILK frame: analysis, quantization, the NSQ, and +// range coding of every field in decode order. It encodes mono, 20 ms, +// SILK-only frames with voiced/LTP prediction, faithful noise shaping and NLSF +// interpolation. The delayed-decision NSQ, stereo, and the rate-control loop +// are follow-up refinements. + +const ( + silkVADThreshold = 100 // speech_activity_Q8 above which a frame is treated as active + silkLTPScaleQ14 = 15565 +) + +// silkInternalRate returns the SILK internal sample rate in kHz. +func silkInternalRate(bandwidth Bandwidth) int { + switch bandwidth { + case BandwidthNarrowband: + return 8 + case BandwidthMediumband: + return 12 + default: + return 16 + } +} + +// silkLPCOrder returns the prediction LPC order for the bandwidth. +func silkLPCOrder(bandwidth Bandwidth) int { + if bandwidth == BandwidthWideband { + return 16 + } + + return 10 +} + +// Encode encodes one 20 ms mono SILK frame from internal-rate PCM and returns +// the range-coded SILK payload (the SILK header plus frame, without the Opus +// TOC byte). +func (e *Encoder) Encode(input []int16, bandwidth Bandwidth, targetBitrate int) []byte { + if targetBitrate > 0 { + e.targetBitrate = targetBitrate + } + e.rangeEncoder.Init() + e.encodeSILKFrame(input, bandwidth) + + return e.rangeEncoder.Done() +} + +// encodeSILKFrame encodes one 20 ms mono SILK frame to the range encoder. +// input is PCM at the internal rate for the bandwidth. +// +//nolint:gocyclo,cyclop // the frame encoder threads many stages in decode order. +func (e *Encoder) encodeSILKFrame(input []int16, bandwidth Bandwidth) { + fsKHz := silkInternalRate(bandwidth) + order := silkLPCOrder(bandwidth) + subfrCount := subframeCount(nanoseconds20Ms) + subfrLength := 5 * fsKHz + frameLength := subfrCount * subfrLength + ltpMemLength := 20 * fsKHz + + // Voice activity. + saQ8, tiltQ15, quality := e.vad.getSpeechActivityQ8(input, frameLength, fsKHz) + active := saQ8 > silkVADThreshold + + // Pitch analysis on the whitening residual (with LTP-memory history). + if len(e.xBuf) != ltpMemLength { + e.xBuf = make([]float32, ltpMemLength) + } + analysis := make([]float32, ltpMemLength+frameLength+ltpOrder) + copy(analysis, e.xBuf) + for i := range frameLength { + analysis[ltpMemLength+i] = float32(input[i]) + } + voiced, pitchL, lagIndex, contourIndex, res, predGain := e.findPitchLags( + analysis[:ltpMemLength+frameLength], fsKHz, subfrCount, saQ8, tiltQ15) + // Keep a few zero samples of headroom after the residual for find_LTP. + res = append(res, make([]float32, ltpOrder)...) + + signalType := frameSignalTypeInactive + switch { + case voiced: + signalType = frameSignalTypeVoiced + active = true + case active: + signalType = frameSignalTypeUnvoiced + } + + // Noise-shaping analysis: AR shaping filters, initial gains, spectral tilt, + // low-frequency and harmonic shaping. + snrDBQ7 := controlSNR(fsKHz, subfrCount, e.targetBitrate) + laShape := laShapeMSLowComplex * fsKHz + shapeBuf := make([]float32, laShape+frameLength+laShape) + copy(shapeBuf, e.xBuf[ltpMemLength-laShape:ltpMemLength]) + for i := range frameLength { + shapeBuf[laShape+i] = float32(input[i]) + } + // pitchRes is the current frame's portion of findPitchLags's whitened + // residual (res_pitch_frame in libopus) — noiseShapeAnalysis's unvoiced + // sparseness measure needs this, not the raw signal in shapeBuf. + pitchRes := res[ltpMemLength : ltpMemLength+frameLength] + sr := e.noiseShapeAnalysis( + shapeBuf, pitchRes, signalType, pitchL, predGain, snrDBQ7, saQ8, quality, fsKHz, subfrCount, subfrLength) + + // Prediction coefficients (find_pred_coefs). Build LPC_in_pre: the LTP + // residual for voiced, or the gain-normalized input for unvoiced. Both drive + // the short-term LPC and the residual energy. + invGains := make([]float32, subfrCount) + for k := range subfrCount { + invGains[k] = 1.0 / sr.gains[k] + } + ltpCoefQ14 := make([]int16, ltpOrder*subfrCount) + nsqPitchL := make([]int, subfrCount) + lpcInPre := make([]float32, subfrCount*(order+subfrLength)) + xBase := ltpMemLength - order + var periodicityIndex int + var filterIndices []int8 + var predGainDB float32 + ltpScaleIndex := 0 + ltpScaleQ14 := int32(silkLTPScaleQ14) + if voiced { + xxLTP := make([]float32, subfrCount*ltpMatrixSize) + xXLTP := make([]float32, subfrCount*ltpOrder) + findLTPFLP(xxLTP, xXLTP, res, ltpMemLength, pitchL, subfrLength, subfrCount) + ltpCoefQ14, filterIndices, periodicityIndex, predGainDB = e.quantLTPGains(xxLTP, xXLTP, subfrLength, subfrCount) + copy(nsqPitchL, pitchL) + ltpScaleIndex, ltpScaleQ14 = ltpScaleControl(predGainDB, snrDBQ7, e.packetLossPerc, 1, false) + + ltpCoefFloat := make([]float32, ltpOrder*subfrCount) + for i := range ltpCoefFloat { + ltpCoefFloat[i] = float32(ltpCoefQ14[i]) * (1.0 / 16384.0) + } + ltpAnalysisFilterFLP(lpcInPre, analysis, xBase, ltpCoefFloat, pitchL, invGains, subfrLength, subfrCount, order) + } else { + e.sumLogGainQ7 = 0 + for k := range subfrCount { + dst := k * (order + subfrLength) + src := xBase + k*subfrLength + for i := range order + subfrLength { + lpcInPre[dst+i] = analysis[src+i] * invGains[k] + } + } + } + + // Short-term prediction: Burg over LPC_in_pre, search the NLSF interpolation + // factor, then quantize and build both frame-half LPC sets. + minInvGain := predCoefsMinInvGain(e.firstFrameAfterReset, predGainDB, sr.codingQuality) + nlsfInterpQ2, nlsf := e.findLPCNLSF(lpcInPre, minInvGain, bandwidth, order, subfrCount, subfrLength) + stabilizeNLSF(nlsf, order, bandwidth) + index1, indices2, quantNLSF := quantizeNLSF(nlsf, bandwidth) + predCoefQ12 := nlsfToLPCQ12(quantNLSF, bandwidth) // second frame half + predCoefQ12Half0 := predCoefQ12 + if nlsfInterpQ2 < 4 { + nlsf0 := make([]int16, order) + interpolateNLSF(nlsf0, e.prevNLSFq, quantNLSF, nlsfInterpQ2, order) + predCoefQ12Half0 = nlsfToLPCQ12(nlsf0, bandwidth) // interpolated first half + } + predCoef2 := make([]int16, 2*maxLPCOrder) + copy(predCoef2, predCoefQ12Half0) + copy(predCoef2[maxLPCOrder:], predCoefQ12) + copy(e.prevNLSFq, quantNLSF) + + // Residual energy per subframe from the quantized LPC (gain soft-limit). + predCoefFloat0 := make([]float32, order) + predCoefFloat1 := make([]float32, order) + for j := range order { + predCoefFloat0[j] = float32(predCoefQ12Half0[j]) * (1.0 / 4096.0) + predCoefFloat1[j] = float32(predCoefQ12[j]) * (1.0 / 4096.0) + } + resNrg := make([]float32, subfrCount) + residualEnergyFLP(resNrg, lpcInPre, predCoefFloat0, predCoefFloat1, sr.gains, subfrLength, subfrCount, order) + + // Process gains: reduce for high LTP gain, soft-limit, quantize; Lambda + offset. + gainsQ16Int, gainIndices, lambdaQ10, quantOffsetType := e.processGains( + sr, resNrg, signalType, predGainDB, snrDBQ7, saQ8, tiltQ15, subfrLength, subfrCount, false) + + // Noise-shaping quantization. + pulses := make([]int8, frameLength) + seed := uint32(e.frameCounter & 3) //nolint:gosec // G115 + e.nsq.quantize(input, pulses, &nsqParams{ + predCoefQ12: predCoef2, + ltpCoefQ14: ltpCoefQ14, + arQ13: sr.arQ13, + harmShapeGainQ14: sr.harmShapeQ14, + tiltQ14: sr.tiltQ14, + lfShpQ14: sr.lfShpQ14, + gainsQ16: gainsQ16Int, + pitchL: nsqPitchL, + lambdaQ10: lambdaQ10, + ltpScaleQ14: ltpScaleQ14, + seed: int32(seed), //nolint:gosec // G115 + signalType: signalType, + quantOffsetType: quantOffsetType, + nlsfInterpCoefQ2: nlsfInterpQ2, + ltpMemLength: ltpMemLength, + frameLength: frameLength, + subfrLength: subfrLength, + nbSubfr: subfrCount, + predictLPCOrder: order, + shapingLPCOrder: shapeLPCOrderLowComplex, + }) + e.frameCounter++ + + // Emit every field in the order the decoder reads it. + vadBit := uint32(0) + if active { + vadBit = 1 + } + e.rangeEncoder.EncodeSymbolLogP(1, vadBit) + e.rangeEncoder.EncodeSymbolLogP(1, 0) // LBRR flag (no redundancy) + + e.emitFrameType(signalType, quantOffsetType, active) + e.emitGainIndices(gainIndices, signalType, false) + e.emitNLSFIndices(index1, indices2, bandwidth, voiced) + e.rangeEncoder.EncodeSymbolWithICDF(icdfNormalizedLSFInterpolationIndex, uint32(nlsfInterpQ2)) //nolint:gosec // G115 + if voiced { + primaryLag := int(lagIndex) + peMinLagMS*fsKHz + contour := uint32(contourIndex) //nolint:gosec // G115: contour index is non-negative. + period := uint32(periodicityIndex) //nolint:gosec // G115: periodicity index is non-negative. + scale := uint32(ltpScaleIndex) //nolint:gosec // G115: scale index is 0..2. + e.encodePitchLags(primaryLag, contour, bandwidth, nanoseconds20Ms, true) + e.encodeLTPFilter(period, toUint32(filterIndices)) + e.encodeLTPScaling(scale) + } + e.rangeEncoder.EncodeSymbolWithICDF(icdfLinearCongruentialGeneratorSeed, seed) + e.encodePulses(signalType, quantOffsetType, pulses, frameLength) + + // Carry state to the next frame. + copy(e.xBuf, analysis[frameLength:frameLength+ltpMemLength]) + e.isPreviousFrameVoiced = voiced + e.firstFrameAfterReset = false +} + +// toUint32 converts codebook indices to the type the emitters expect. +func toUint32(indices []int8) []uint32 { + out := make([]uint32, len(indices)) + for i, v := range indices { + out[i] = uint32(v) //nolint:gosec // G115: indices are small non-negative. + } + + return out +} + +// emitFrameType codes the signal type and quantization offset (RFC 6716 +// Section 4.2.7.3). +func (e *Encoder) emitFrameType(signalType frameSignalType, quantOffsetType frameQuantizationOffsetType, vad bool) { + high := quantOffsetType == frameQuantizationOffsetTypeHigh + if !vad { + sym := uint32(0) + if high { + sym = 1 + } + e.rangeEncoder.EncodeSymbolWithICDF(icdfFrameTypeVADInactive, sym) + + return + } + + var sym uint32 + switch { + case signalType == frameSignalTypeUnvoiced && high: + sym = 1 + case signalType == frameSignalTypeVoiced && !high: + sym = 2 + case signalType == frameSignalTypeVoiced && high: + sym = 3 + } + e.rangeEncoder.EncodeSymbolWithICDF(icdfFrameTypeVADActive, sym) +} diff --git a/internal/silk/enc_frame_test.go b/internal/silk/enc_frame_test.go new file mode 100644 index 0000000..a3a79df --- /dev/null +++ b/internal/silk/enc_frame_test.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 The Pion community +// SPDX-License-Identifier: MIT + +package silk + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEncodeSILKFrameDecodable encodes a SILK frame and decodes it with the +// real decoder, proving the bitstream is well-formed and self-consistent. This +// is the end-to-end gate short of opus_compare against libopus. +func TestEncodeSILKFrameDecodable(t *testing.T) { + for _, bandwidth := range []Bandwidth{BandwidthNarrowband, BandwidthMediumband, BandwidthWideband} { + fsKHz := silkInternalRate(bandwidth) + frameLength := 20 * fsKHz + + input := make([]int16, frameLength) + for i := range input { + input[i] = int16(4000*math.Sin(2*math.Pi*float64(i)/50) + 1500*math.Sin(2*math.Pi*float64(i)/13)) + } + + enc := NewEncoder() + enc.rangeEncoder.Init() + enc.encodeSILKFrame(input, bandwidth) + data := enc.rangeEncoder.Done() + require.NotEmpty(t, data) + + dec := NewDecoder() + out := make([]float32, frameLength) + err := dec.Decode(data, out, false, nanoseconds20Ms, bandwidth) + require.NoErrorf(t, err, "bandwidth %d", bandwidth) + + var energy float64 + for _, v := range out { + energy += float64(v) * float64(v) + } + assert.Positivef(t, energy, "bandwidth %d: decoded output is silent", bandwidth) + } +} + +// TestEncodeSILKFrameSilence checks a silent input encodes and decodes cleanly. +func TestEncodeSILKFrameSilence(t *testing.T) { + bandwidth := BandwidthWideband + frameLength := 20 * silkInternalRate(bandwidth) + + enc := NewEncoder() + enc.rangeEncoder.Init() + enc.encodeSILKFrame(make([]int16, frameLength), bandwidth) + data := enc.rangeEncoder.Done() + + dec := NewDecoder() + out := make([]float32, frameLength) + require.NoError(t, dec.Decode(data, out, false, nanoseconds20Ms, bandwidth)) +} + +// TestEncodeSILKFrameInterpolatedNLSF exercises the NLSF-interpolation branch +// (nlsfInterpQ2<4): needs SetUseInterpolatedNLSFs(true) and a second frame +// (firstFrameAfterReset only clears after the first encodeSILKFrame call). +// Decodes with the real decoder to confirm the interpolated first-half LPC +// coefficients still produce a valid bitstream. +func TestEncodeSILKFrameInterpolatedNLSF(t *testing.T) { + bandwidth := BandwidthWideband + fsKHz := silkInternalRate(bandwidth) + frameLength := 20 * fsKHz + + gen := func(offset int) []int16 { + input := make([]int16, frameLength) + for i := range input { + input[i] = int16(4000*math.Sin(2*math.Pi*float64(i+offset)/50) + 1500*math.Sin(2*math.Pi*float64(i+offset)/13)) + } + + return input + } + + enc := NewEncoder() + enc.SetUseInterpolatedNLSFs(true) + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(0), bandwidth) + require.False(t, enc.firstFrameAfterReset, "first call should clear firstFrameAfterReset") + + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(frameLength), bandwidth) + data := enc.rangeEncoder.Done() + require.NotEmpty(t, data) + + dec := NewDecoder() + out := make([]float32, frameLength) + require.NoError(t, dec.Decode(data, out, false, nanoseconds20Ms, bandwidth)) +} + +// TestEncodeSILKFrameUnvoicedHighOffset exercises the Unvoiced+High branch of +// emitFrameType (a quiet, high-frequency oscillation classified unvoiced with +// a sparse quantizer offset on the second frame). +func TestEncodeSILKFrameUnvoicedHighOffset(t *testing.T) { + bandwidth := BandwidthWideband + fsKHz := silkInternalRate(bandwidth) + frameLength := 20 * fsKHz + + gen := func(offset int) []int16 { + input := make([]int16, frameLength) + for i := range input { + input[i] = int16(100 * math.Sin(2*math.Pi*float64(i+offset)/5)) + } + + return input + } + + enc := NewEncoder() + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(0), bandwidth) + + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(frameLength), bandwidth) + data := enc.rangeEncoder.Done() + require.NotEmpty(t, data) + + dec := NewDecoder() + out := make([]float32, frameLength) + require.NoError(t, dec.Decode(data, out, false, nanoseconds20Ms, bandwidth)) +} + +// TestEncodeSILKFrameVoicedHighOffset exercises the Voiced+High branch of +// emitFrameType (processGains picks quantOffsetType=High when the quantized +// LTP prediction gain plus spectral tilt doesn't clear 1.0 — see +// process_gains_FLP.c). A fast upward pitch chirp (period sweeping 35->420 +// samples within one frame) stays periodic enough to pass the voicing +// threshold but defeats the fixed-lag LTP filter, driving the quantized LTP +// gain low; found via a sweep over period/amplitude combinations. +func TestEncodeSILKFrameVoicedHighOffset(t *testing.T) { + bandwidth := BandwidthWideband + fsKHz := silkInternalRate(bandwidth) + frameLength := 20 * fsKHz + + gen := func(offset int) []int16 { + input := make([]int16, frameLength) + for i := range input { + frac := float64(i) / float64(frameLength) + period := 35 + frac*(420-35) + input[i] = int16(1500 * math.Sin(2*math.Pi*float64(i+offset)/period)) + } + + return input + } + + enc := NewEncoder() + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(0), bandwidth) + + enc.rangeEncoder.Init() + enc.encodeSILKFrame(gen(frameLength), bandwidth) + data := enc.rangeEncoder.Done() + require.NotEmpty(t, data) + + dec := NewDecoder() + out := make([]float32, frameLength) + require.NoError(t, dec.Decode(data, out, false, nanoseconds20Ms, bandwidth)) +} + +// TestEncode checks the public Encode wrapper: it must Init the range coder, +// apply an explicit target bitrate, and return a non-empty payload. +func TestEncode(t *testing.T) { + bandwidth := BandwidthWideband + frameLength := 20 * silkInternalRate(bandwidth) + input := make([]int16, frameLength) + for i := range input { + input[i] = int16(3000 * math.Sin(2*math.Pi*float64(i)/50)) + } + + enc := NewEncoder() + data := enc.Encode(input, bandwidth, 20000) + + require.NotEmpty(t, data) + assert.Equal(t, 20000, enc.targetBitrate) + + dec := NewDecoder() + out := make([]float32, frameLength) + require.NoError(t, dec.Decode(data, out, false, nanoseconds20Ms, bandwidth)) +} diff --git a/internal/silk/encoder.go b/internal/silk/encoder.go index acd6f62..49cfad5 100644 --- a/internal/silk/encoder.go +++ b/internal/silk/encoder.go @@ -37,11 +37,21 @@ type Encoder struct { // Analysis state for the frame encoder. vad vadState nsq *nsqState - targetBitrate int // target bitrate in bps (drives control_SNR) - sumLogGainQ7 int32 // cumulative LTP gain limit (quant_LTP_gains) - ltpCorr float32 // normalized correlation carried across frames - tiltSmth float32 // smoothed spectral tilt (shape state) - harmShapeGainSmth float32 // smoothed harmonic shaping gain (shape state) + frameCounter int + targetBitrate int // target bitrate in bps (drives control_SNR) + packetLossPerc int // expected packet loss %, drives LTP state scaling + sumLogGainQ7 int32 // cumulative LTP gain limit (quant_LTP_gains) + xBuf []float32 // previous frame, as LTP-memory history for pitch analysis + ltpCorr float32 // normalized correlation carried across frames + tiltSmth float32 // smoothed spectral tilt (shape state) + harmShapeGainSmth float32 // smoothed harmonic shaping gain (shape state) + + // useInterpolatedNLSFs mirrors libopus's psEncC->useInterpolatedNLSFs + // (silk_setup_complexity, control_codec.c): NLSF interpolation search + // only runs at encoder complexity >= 4. Set via SetUseInterpolatedNLSFs; + // defaults to false (matching the low-complexity tiers) until the caller + // configures it. + useInterpolatedNLSFs bool } // NewEncoder creates a SILK Encoder with its prediction state reset. @@ -52,6 +62,13 @@ func NewEncoder() Encoder { return e } +// SetUseInterpolatedNLSFs enables or disables the NLSF interpolation search +// in findLPCNLSF, mirroring libopus's complexity-tier setting +// (silk_setup_complexity: enabled for encoder complexity >= 4). +func (e *Encoder) SetUseInterpolatedNLSFs(enabled bool) { + e.useInterpolatedNLSFs = enabled +} + // resetPredictionState resets the encoder prediction state. The values must // match Decoder.resetPredictionState. func (e *Encoder) resetPredictionState() { diff --git a/internal/silk/find_pred_coefs.go b/internal/silk/find_pred_coefs.go index 7f74ff7..d032979 100644 --- a/internal/silk/find_pred_coefs.go +++ b/internal/silk/find_pred_coefs.go @@ -62,8 +62,9 @@ func interpolateNLSF(xi, x0, x1 []int16, ifactQ2, d int) { } } -// findLPCNLSF runs Burg over LPC_in_pre and, for 20 ms frames, searches the -// NLSF interpolation factor with the lowest first-half residual energy +// findLPCNLSF runs Burg over LPC_in_pre and, when e.useInterpolatedNLSFs is +// set and the frame has the full 4 subframes, searches the NLSF +// interpolation factor with the lowest first-half residual energy // (silk_find_LPC_FLP). It returns the interpolation index (4 = no // interpolation) and the NLSFs to quantize. func (e *Encoder) findLPCNLSF( @@ -75,7 +76,7 @@ func (e *Encoder) findLPCNLSF( resNrg := burgModifiedFLP(aFull, lpcInPre, minInvGain, blockLen, nbSubfr, order) nlsf := make([]int16, order) - if !e.firstFrameAfterReset && nbSubfr == maxSubframeCount { + if e.useInterpolatedNLSFs && !e.firstFrameAfterReset && nbSubfr == maxSubframeCount { half := maxSubframeCount / 2 aTmp := make([]float32, order) resNrg -= burgModifiedFLP(aTmp, lpcInPre[half*blockLen:], minInvGain, blockLen, half, order) diff --git a/internal/silk/find_pred_coefs_test.go b/internal/silk/find_pred_coefs_test.go index 05b226b..76fc5f0 100644 --- a/internal/silk/find_pred_coefs_test.go +++ b/internal/silk/find_pred_coefs_test.go @@ -102,10 +102,10 @@ func TestPredCoefsMinInvGain(t *testing.T) { } // TestFindLPCNLSF exercises both the interpolation-search path (4 subframes, -// not the first frame after reset — matching find_pitch_lags_FLP's caller -// convention) and the no-interpolation path (2 subframes, always skips the -// search since it requires MAX_NB_SUBFR), including nlsfToLPCQ12 (only -// reachable from the interpolation search). +// useInterpolatedNLSFs set, not the first frame after reset — matching +// find_pitch_lags_FLP's caller convention) and the no-interpolation path (2 +// subframes, always skips the search since it requires MAX_NB_SUBFR), +// including nlsfToLPCQ12 (only reachable from the interpolation search). func TestFindLPCNLSF(t *testing.T) { const ( order = 16 @@ -119,6 +119,7 @@ func TestFindLPCNLSF(t *testing.T) { } enc := NewEncoder() + enc.SetUseInterpolatedNLSFs(true) enc.firstFrameAfterReset = false enc.prevNLSFq = genNLSF(order, 42) @@ -140,4 +141,14 @@ func TestFindLPCNLSF(t *testing.T) { interpQ2NB, nlsfNB := enc.findLPCNLSF(lpcInPre[:2*blockLen], 1e-4, BandwidthNarrowband, order, 2, subfrLength) assert.Equal(t, 4, interpQ2NB) require.Len(t, nlsfNB, order) + + // useInterpolatedNLSFs unset (the zero value, matching encoder complexity + // < 4): skips the search even with 4 subframes and firstFrameAfterReset + // already false. + encLowComplexity := NewEncoder() + encLowComplexity.firstFrameAfterReset = false + encLowComplexity.prevNLSFq = genNLSF(order, 42) + interpQ2Low, nlsfLow := encLowComplexity.findLPCNLSF(lpcInPre, 1e-4, BandwidthWideband, order, nbSubfr, subfrLength) + assert.Equal(t, 4, interpQ2Low, "interpolation search should be gated off") + require.Len(t, nlsfLow, order) } diff --git a/silk_encode_test.go b/silk_encode_test.go new file mode 100644 index 0000000..2276c0a --- /dev/null +++ b/silk_encode_test.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 The Pion community +// SPDX-License-Identifier: MIT + +package opus + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEncodeSILKRoundTrip encodes a SILK frame with the public encoder and +// decodes the resulting Opus packet with the public decoder, for every +// SILK-supported bandwidth, checking the packet is well-formed and decodes to +// non-silent audio. +func TestEncodeSILKRoundTrip(t *testing.T) { + for _, bandwidth := range []Bandwidth{BandwidthNarrowband, BandwidthMediumband, BandwidthWideband} { + enc, err := NewEncoder() + require.NoErrorf(t, err, "bandwidth %d", bandwidth) + + sampleCount := bandwidth.SampleRate() / 50 // 20 ms + pcm := make([]int16, sampleCount) + for i := range pcm { + pcm[i] = int16(5000*math.Sin(2*math.Pi*float64(i)/48) + 1200*math.Sin(2*math.Pi*float64(i)/11)) + } + + packet := make([]byte, maxOpusFrameSize) + n, err := enc.EncodeSILK(pcm, bandwidth, packet) + require.NoErrorf(t, err, "bandwidth %d", bandwidth) + require.Greaterf(t, n, 1, "bandwidth %d", bandwidth) + packet = packet[:n] + + dec, err := NewDecoderWithOutput(bandwidth.SampleRate(), 1) + require.NoErrorf(t, err, "bandwidth %d", bandwidth) + + out := make([]float32, sampleCount) + got, err := dec.DecodeToFloat32(packet, out) + require.NoErrorf(t, err, "bandwidth %d", bandwidth) + require.Positivef(t, got, "bandwidth %d", bandwidth) + + var energy float64 + for _, v := range out[:got] { + energy += float64(v) * float64(v) + } + assert.Positivef(t, energy, "bandwidth %d: decoded output is silent", bandwidth) + } +} + +// TestEncodeSILKComplexityGatesInterpolation checks that raising complexity +// above silkComplexityInterpolationThreshold reaches SetUseInterpolatedNLSFs +// on the underlying SILK encoder (end to end: encode two frames and confirm +// both still decode cleanly, exercising the interpolation branch inside +// internal/silk once firstFrameAfterReset clears on the second frame). +func TestEncodeSILKComplexityGatesInterpolation(t *testing.T) { + enc, err := NewEncoder(WithComplexity(8)) + require.NoError(t, err) + + pcm := make([]int16, BandwidthWideband.SampleRate()/50) // 20 ms + for i := range pcm { + pcm[i] = int16(5000*math.Sin(2*math.Pi*float64(i)/48) + 1200*math.Sin(2*math.Pi*float64(i)/11)) + } + + packet := make([]byte, maxOpusFrameSize) + for range 2 { + n, encErr := enc.EncodeSILK(pcm, BandwidthWideband, packet) + require.NoError(t, encErr) + + dec, decErr := NewDecoderWithOutput(BandwidthWideband.SampleRate(), 1) + require.NoError(t, decErr) + out := make([]float32, len(pcm)) + _, decErr = dec.DecodeToFloat32(packet[:n], out) + require.NoError(t, decErr) + } +} + +func TestEncodeSILKInvalidLength(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + + _, err = enc.EncodeSILK(make([]int16, 100), BandwidthWideband, make([]byte, maxOpusFrameSize)) + require.Error(t, err) +} + +func TestEncodeSILKInvalidBandwidth(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + + for _, bandwidth := range []Bandwidth{BandwidthSuperwideband, BandwidthFullband, BandwidthAuto} { + _, err = enc.EncodeSILK(make([]int16, 320), bandwidth, make([]byte, maxOpusFrameSize)) + assert.Errorf(t, err, "bandwidth %d", bandwidth) + } +} + +func TestEncodeSILKOutBufferTooSmall(t *testing.T) { + enc, err := NewEncoder() + require.NoError(t, err) + + pcm := make([]int16, BandwidthWideband.SampleRate()/50) + _, err = enc.EncodeSILK(pcm, BandwidthWideband, make([]byte, 1)) + require.Error(t, err) +} + +// TestSILKDCBlockRemovesConstantOffset mirrors celt's TestDCBlockRemovesConstantOffset: +// after 1 s of constant input the filter should settle to near zero. +func TestSILKDCBlockRemovesConstantOffset(t *testing.T) { + const sampleRate = 16000 + pcm := make([]int16, sampleRate) + for i := range pcm { + pcm[i] = 10000 + } + var mem float32 + out := applySILKDCBlock(pcm, sampleRate, &mem) + + var sum float64 + for i := len(out) - 1600; i < len(out); i++ { + sum += float64(out[i]) + } + assert.InDelta(t, 0.0, sum/1600, 100.0, + "DC filter should attenuate constant offset (mean=%f)", sum/1600) +} + +// TestSILKDCBlockPreservesInput checks the original pcm slice is untouched +// (applySILKDCBlock returns a new slice, unlike celt's in-place applyDCBlock). +func TestSILKDCBlockPreservesInput(t *testing.T) { + pcm := []int16{5000, 5000, 5000, 5000} + original := append([]int16(nil), pcm...) + var mem float32 + applySILKDCBlock(pcm, 16000, &mem) + assert.Equal(t, original, pcm) +} + +// TestSILKDCBlockSaturates checks the clamp branches: a step between the +// int16 extremes while mem sits at the opposite extreme pushes y outside +// [-32768, 32767], which must saturate instead of wrapping. +func TestSILKDCBlockSaturates(t *testing.T) { + memHigh := float32(-32768) + outHigh := applySILKDCBlock([]int16{32767}, 16000, &memHigh) + assert.Equal(t, int16(32767), outHigh[0]) + + memLow := float32(32767) + outLow := applySILKDCBlock([]int16{-32768}, 16000, &memLow) + assert.Equal(t, int16(-32768), outLow[0]) +} + +// TestSILKDCBlockMultiFrameState checks the filter state persists correctly +// across calls: two half-frame runs must match one full run. +func TestSILKDCBlockMultiFrameState(t *testing.T) { + const sampleRate = 16000 + pcm := make([]int16, 320) + for i := range pcm { + pcm[i] = int16(3000 * math.Sin(2*math.Pi*440*float64(i)/sampleRate)) + } + + var memFull float32 + fullOut := applySILKDCBlock(pcm, sampleRate, &memFull) + + var memSplit float32 + out1 := applySILKDCBlock(pcm[:160], sampleRate, &memSplit) + out2 := applySILKDCBlock(pcm[160:], sampleRate, &memSplit) + splitOut := append(append([]int16(nil), out1...), out2...) + + assert.Equal(t, fullOut, splitOut) +}