Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/silk/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package silk

import "github.com/pion/opus/internal/rangecoding"

// Encoder quantizes and range-encodes a single SILK channel. It is the
// counterpart to Decoder and is built up one stage at a time.
//
// State fields match the corresponding Decoder fields and use the same reset
// values so the encoder and decoder stay in sync across an uncoded frame.
type Encoder struct {
rangeEncoder rangecoding.Encoder

// haveEncoded reports whether a frame has been encoded yet; it selects
// independent gain coding for the first subframe of the first frame.
haveEncoded bool

// previousLogGain is the running quantized log-gain index carried across
// subframes and frames.
previousLogGain int32

// previousLag and isPreviousFrameVoiced carry pitch state across frames,
// selecting relative vs absolute primary-lag coding.
previousLag int
isPreviousFrameVoiced bool

// firstFrameAfterReset caps the predictor more aggressively on the first
// frame after a reset (find_pred_coefs).
firstFrameAfterReset bool

// prevNLSFq holds the previous frame's quantized NLSFs (Q15) for LSF
// interpolation.
prevNLSFq []int16

// 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)
}

// NewEncoder creates a SILK Encoder with its prediction state reset.
func NewEncoder() Encoder {
e := Encoder{vad: newVADState(), nsq: newNSQState()}
e.resetPredictionState()

return e
}

// resetPredictionState resets the encoder prediction state. The values must
// match Decoder.resetPredictionState.
func (e *Encoder) resetPredictionState() {
e.haveEncoded = false
e.previousLogGain = 10
e.previousLag = 100
e.isPreviousFrameVoiced = false
e.firstFrameAfterReset = true
e.sumLogGainQ7 = 0
e.prevNLSFq = make([]int16, maxLPCOrder)
if e.targetBitrate == 0 {
e.targetBitrate = 24000
}
}
42 changes: 42 additions & 0 deletions internal/silk/encoder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package silk

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewEncoderDefaults(t *testing.T) {
enc := NewEncoder()

assert.False(t, enc.haveEncoded)
assert.Equal(t, int32(10), enc.previousLogGain)
assert.Equal(t, 100, enc.previousLag)
assert.False(t, enc.isPreviousFrameVoiced)
assert.True(t, enc.firstFrameAfterReset)
assert.Equal(t, int32(0), enc.sumLogGainQ7)
assert.Len(t, enc.prevNLSFq, maxLPCOrder)
assert.Equal(t, 24000, enc.targetBitrate)
require.NotNil(t, enc.nsq)
}

func TestResetPredictionStateKeepsExplicitBitrate(t *testing.T) {
enc := NewEncoder()
enc.targetBitrate = 32000
enc.haveEncoded = true
enc.previousLogGain = 40
enc.firstFrameAfterReset = false

enc.resetPredictionState()

assert.False(t, enc.haveEncoded)
assert.Equal(t, int32(10), enc.previousLogGain)
assert.True(t, enc.firstFrameAfterReset)
// A non-zero bitrate the caller already set is left alone — only the
// zero-value default (uninitialized Encoder) gets the 24000 fallback.
assert.Equal(t, 32000, enc.targetBitrate)
}
70 changes: 70 additions & 0 deletions internal/silk/find_pitch_lags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package silk

const (
findPitchWhiteNoiseFraction = 1e-3
findPitchBandwidthExpansion = 0.99
laPitchMS = 2
findPitchLPCWinMS = 20 + laPitchMS<<1 // FIND_PITCH_LPC_WIN_MS
pitchEstLPCOrder = 16
pitchEstComplexity = 2 // 0..2, highest = best
pitchSearchThreshold = 0.3 // first-stage candidate threshold
)

// findPitchLags whitens the signal and runs the pitch estimator (a port of
// silk_find_pitch_lags_FLP). analysisBuf holds the LTP-memory history followed
// by the current frame (length ltp_mem_length + frame_length). It returns
// whether the frame is voiced, the per-subframe pitch lags, the lag/contour
// indices, and the whitening residual (reused by the LTP analysis).
func (e *Encoder) findPitchLags(
analysisBuf []float32, fsKHz, nbSubfr, speechActivityQ8 int, inputTiltQ15 int32,
) (voiced bool, pitchL []int, lagIndex int16, contourIndex int8, res []float32, predGain float32) {
bufLen := len(analysisBuf)
laPitch := laPitchMS * fsKHz
winLength := findPitchLPCWinMS * fsKHz

// Windowed signal: rising edge, flat middle, falling edge.
wsig := make([]float32, winLength)
start := bufLen - winLength
applySineWindowFLP(wsig, analysisBuf[start:], 1, laPitch)
copy(wsig[laPitch:winLength-laPitch], analysisBuf[start+laPitch:start+winLength-laPitch])
applySineWindowFLP(wsig[winLength-laPitch:], analysisBuf[start+winLength-laPitch:], 2, laPitch)

// Whitening LPC via autocorrelation + Schur.
autoCorr := make([]float32, pitchEstLPCOrder+1)
autocorrelationFLP(autoCorr, wsig, winLength, pitchEstLPCOrder+1)
autoCorr[0] += autoCorr[0]*findPitchWhiteNoiseFraction + 1

refl := make([]float32, pitchEstLPCOrder)
resNrg := schurFLP(refl, autoCorr, pitchEstLPCOrder)
predGain = autoCorr[0] / max(resNrg, 1.0)
a := make([]float32, pitchEstLPCOrder)
k2aFLP(a, refl, pitchEstLPCOrder)
bwexpanderFLP(a, pitchEstLPCOrder, findPitchBandwidthExpansion)

res = make([]float32, bufLen)
lpcAnalysisFilterFLP(res, a, analysisBuf, bufLen, pitchEstLPCOrder)

// Voicing threshold (search_thres2).
prevVoiced := float32(0)
if e.isPreviousFrameVoiced {
prevVoiced = 1
}
thrhld := float32(0.6) -
0.004*pitchEstLPCOrder -
0.1*float32(speechActivityQ8)*(1.0/256.0) -
0.15*prevVoiced -
0.1*float32(inputTiltQ15)*(1.0/32768.0)

pitchL = make([]int, nbSubfr)
prevLag := 0
if e.isPreviousFrameVoiced {
prevLag = e.previousLag
}
lagIndex, contourIndex, voiced = pitchAnalysisCore(
res, pitchL, &e.ltpCorr, prevLag, pitchSearchThreshold, thrhld, fsKHz, pitchEstComplexity, nbSubfr)

return voiced, pitchL, lagIndex, contourIndex, res, predGain
}
72 changes: 72 additions & 0 deletions internal/silk/find_pitch_lags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package silk

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestFindPitchLagsVoiced checks that a clearly periodic signal is detected as
// voiced with a pitch lag near its true period.
func TestFindPitchLagsVoiced(t *testing.T) {
const (
fsKHz = 16
nbSubfr = 4
ltpMemLength = 20 * fsKHz
frameLength = 20 * fsKHz
period = 96 // 16 kHz / 96 ~ 167 Hz, inside the pitch range
)

// Impulse train: a strong pulse every `period` samples.
buf := make([]float32, ltpMemLength+frameLength)
for i := range buf {
if i%period == 0 {
buf[i] = 8000
}
}

enc := NewEncoder()
voiced, pitchL, lagIndex, contourIndex, res, predGain := enc.findPitchLags(buf, fsKHz, nbSubfr, 200, 0)

require.True(t, voiced, "periodic signal should be voiced")
require.Len(t, res, ltpMemLength+frameLength)
primaryLag := int(lagIndex) + peMinLagMS*fsKHz
assert.InDeltaf(t, period, primaryLag, 8, "primary lag")
for _, p := range pitchL {
assert.InDeltaf(t, period, p, 12, "subframe lag")
}
// contourIndex selects a row of the stage-3 lag codebook (peNBCbksStage3Max
// entries at complexity 2, the level findPitchLags always requests).
assert.GreaterOrEqualf(t, contourIndex, int8(0), "contour index")
assert.Lessf(t, contourIndex, int8(peNBCbksStage3Max), "contour index")
// predGain is the whitening filter's prediction gain (autoCorr[0]/resNrg).
// This is a short-term (16-tap) LPC gain, not the long-term pitch gain —
// an impulse train with a 96-sample period has no structure a 16-tap
// predictor can see, so the meaningful invariant here is just positivity
// (autoCorr[0] and the clamped resNrg are both always > 0).
assert.Greaterf(t, predGain, float32(0), "prediction gain")
}

// TestFindPitchLagsUnvoiced checks noise is not classified as strongly voiced.
func TestFindPitchLagsUnvoiced(t *testing.T) {
const (
fsKHz = 16
ltpMemLength = 20 * fsKHz
frameLength = 20 * fsKHz
)
buf := make([]float32, ltpMemLength+frameLength)
state := uint32(1)
for i := range buf {
state = 1664525*state + 1013904223
buf[i] = float32(int32(state>>16)%1000 - 500)
}

enc := NewEncoder()
// Not asserting hard (noise can occasionally correlate) — just that it runs.
_, _, _, _, res, _ := enc.findPitchLags(buf, fsKHz, 4, 50, 0) //nolint:dogsled // only the residual is under test
require.Len(t, res, ltpMemLength+frameLength)
}
122 changes: 122 additions & 0 deletions internal/silk/find_pred_coefs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package silk

import "math"

const (
maxPredictionPowerGain = 1e4
maxPredictionPowerGainAfterReset = 1e2
)

// ltpAnalysisFilterFLP forms the LTP residual (silk_LTP_analysis_filter_FLP).
// x is the speech buffer and xBase the index of the first sample to process,
// which must have at least predictLPCOrder + max(pitchL) samples of history
// before it. The output holds nbSubfr blocks of preLength+subfrLength samples,
// each scaled by the subframe inverse gain.
func ltpAnalysisFilterFLP(
ltpRes, x []float32, xBase int, b []float32, pitchL []int, invGains []float32,
subfrLength, nbSubfr, preLength int,
) {
xPtr := xBase
resIdx := 0
for k := range nbSubfr {
lagPtr := xPtr - pitchL[k] //nolint:gosec // G602: pitchL has nbSubfr entries by caller contract.
invGain := invGains[k] //nolint:gosec // G602: invGains has nbSubfr entries by caller contract.
for i := range subfrLength + preLength {
v := x[xPtr+i]
for j := range ltpOrder {
v -= b[k*ltpOrder+j] * x[lagPtr+ltpOrder/2-j+i]
}
ltpRes[resIdx+i] = v * invGain
}
resIdx += subfrLength + preLength
xPtr += subfrLength
}
}

// residualEnergyFLP measures the per-subframe LPC residual energy of the
// gain-normalized signal, rescaled by the squared gains
// (silk_residual_energy_FLP). lpcInPre holds nbSubfr blocks of order+subfrLength
// samples; a0 and a1 are the quantized LPC for the first and second frame
// halves (equal when NLSF interpolation is inactive).
func residualEnergyFLP(nrgs, lpcInPre, a0, a1, gains []float32, subfrLength, nbSubfr, order int) {
shift := order + subfrLength
lpcRes := make([]float32, 2*shift)
halves := [][]float32{a0, a1}
for half := 0; half < nbSubfr; half += 2 {
lpcAnalysisFilterFLP(lpcRes, halves[half/2], lpcInPre[half*shift:], 2*shift, order)
nrgs[half] = gains[half] * gains[half] * float32(energyFLP(lpcRes[order:], subfrLength))
//nolint:gosec // G602: gains/nrgs have nbSubfr entries by caller contract.
nrgs[half+1] = gains[half+1] * gains[half+1] * float32(energyFLP(lpcRes[order+shift:], subfrLength))
}
}

// interpolateNLSF linearly interpolates two NLSF vectors (silk_interpolate).
// ifactQ2 (0..4) is the weight on x1.
func interpolateNLSF(xi, x0, x1 []int16, ifactQ2, d int) {
for i := range d {
//nolint:gosec // G115: NLSFs and their interpolation stay within int16.
xi[i] = int16(int32(x0[i]) + (smulbb(int32(x1[i])-int32(x0[i]), int32(ifactQ2)) >> 2))
}
}

// findLPCNLSF runs Burg over LPC_in_pre and, for 20 ms frames, 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(
lpcInPre []float32, minInvGain float32, bandwidth Bandwidth, order, nbSubfr, subfrLength int,
) (int, []int16) {
blockLen := subfrLength + order
interpQ2 := 4
aFull := make([]float32, order)
resNrg := burgModifiedFLP(aFull, lpcInPre, minInvGain, blockLen, nbSubfr, order)

nlsf := make([]int16, order)
if !e.firstFrameAfterReset && nbSubfr == maxSubframeCount {
half := maxSubframeCount / 2
aTmp := make([]float32, order)
resNrg -= burgModifiedFLP(aTmp, lpcInPre[half*blockLen:], minInvGain, blockLen, half, order)
a2nlsfFLP(nlsf, aTmp, order) // NLSFs of the last 10 ms

resNrg2nd := float32(math.MaxFloat32)
nlsf0 := make([]int16, order)
lpcRes := make([]float32, 2*blockLen)
aInterp := make([]float32, order)
for k := 3; k >= 0; k-- {
interpolateNLSF(nlsf0, e.prevNLSFq, nlsf, k, order)
aQ12 := nlsfToLPCQ12(nlsf0, bandwidth)
for i := range order {
aInterp[i] = float32(aQ12[i]) * (1.0 / 4096.0)
}
lpcAnalysisFilterFLP(lpcRes, aInterp, lpcInPre, 2*blockLen, order)
resInterp := float32(energyFLP(lpcRes[order:], subfrLength)) +
float32(energyFLP(lpcRes[order+blockLen:], subfrLength))
if resInterp < resNrg {
resNrg = resInterp
interpQ2 = k
} else if resInterp > resNrg2nd {
break
}
resNrg2nd = resInterp
}
}
if interpQ2 == 4 {
a2nlsfFLP(nlsf, aFull, order)
}

return interpQ2, nlsf
}

// predCoefsMinInvGain returns the minimum inverse prediction gain used to cap
// the short-term predictor (silk_find_pred_coefs_FLP).
func predCoefsMinInvGain(firstFrameAfterReset bool, ltpredCodGain, codingQuality float32) float32 {
if firstFrameAfterReset {
return 1.0 / maxPredictionPowerGainAfterReset
}
minInvGain := float32(math.Pow(2, float64(ltpredCodGain)/3)) / maxPredictionPowerGain

return minInvGain / (0.25 + 0.75*codingQuality)
}
Loading
Loading