Skip to content

Add pure-Go SILK encoder#147

Open
fallais wants to merge 7 commits into
pion:mainfrom
gojargo:feat/silk-encoder
Open

Add pure-Go SILK encoder#147
fallais wants to merge 7 commits into
pion:mainfrom
gojargo:feat/silk-encoder

Conversation

@fallais

@fallais fallais commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This adds a pure-Go SILK encoder as the counterpart to the existing SILK decoder, exposed as opus.Encoder.EncodeSILK(pcm, bandwidth, out). It produces decodable SILK-only Opus packets that round-trip through the existing decoder.

Scope

Mono, 20 ms frames, narrowband / mediumband / wideband. The full analysis pipeline is ported from the libopus float SILK encoder:

  • VAD + adaptive high-pass
  • pitch analysis (3-stage lag search) and LTP analysis/quantization
  • Burg LPC → NLSF quantization with interpolation
  • noise-shape analysis (spectral tilt, low-frequency and harmonic shaping), gains, and target-SNR control
  • prediction coefficients (find_pred_coefs: LTP residual for voiced, gain-normalized input for unvoiced)
  • noise-shaping quantizer (silk_NSQ_c) and range coding of every field in decode order

Verification

  • Encode → decode round-trips through the existing decoder at NB/MB/WB (internal/silk unit tests + an end-to-end fidelity test, ~22 dB reconstruction SNR on a voiced signal).
  • A/B against the libopus reference: the 1–4 kHz noise-shaping profile matches within ~1 dB at 8–25 kbit/s NB; target bitrate tracks within ~4% from 8→32 kbit/s.
  • go test ./... passes; lint clean under the repo config.

Not included yet (follow-ups)

Delayed-decision NSQ (silk_NSQ_del_dec), NLSF trellis MSVQ (this uses a full stage-1 search with a greedy stage-2 quantizer), the rate-control loop, stereo, hybrid, and 10/40/60 ms frame sizes.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.65241% with 110 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.81%. Comparing base (4d863e5) to head (77e753c).

Files with missing lines Patch % Lines
internal/silk/a2nlsf.go 70.27% 31 Missing and 2 partials ⚠️
internal/silk/nlsf_encode.go 91.19% 14 Missing ⚠️
internal/silk/nsq.go 94.44% 10 Missing and 1 partial ⚠️
encoder.go 61.90% 7 Missing and 1 partial ⚠️
internal/silk/pitch_analysis.go 96.88% 8 Missing ⚠️
internal/silk/math_fix.go 91.46% 5 Missing and 2 partials ⚠️
internal/silk/control_snr.go 75.00% 2 Missing and 2 partials ⚠️
internal/silk/enc_frame.go 97.79% 4 Missing ⚠️
internal/silk/ltp_scale.go 60.00% 2 Missing and 2 partials ⚠️
internal/silk/noise_shape_analysis.go 97.38% 3 Missing and 1 partial ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #147      +/-   ##
==========================================
+ Coverage   87.72%   90.81%   +3.09%     
==========================================
  Files          32       55      +23     
  Lines        7566     9573    +2007     
==========================================
+ Hits         6637     8694    +2057     
+ Misses        705      641      -64     
- Partials      224      238      +14     
Flag Coverage Δ
go 90.81% <94.65%> (+3.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fallais
fallais force-pushed the feat/silk-encoder branch from fa2c322 to 97d2298 Compare July 10, 2026 13:33

@JoTurk JoTurk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please break this into smaller prs so it's easier to reason / review?

@fallais
fallais force-pushed the feat/silk-encoder branch from 97d2298 to 77e753c Compare July 10, 2026 13:36
Comment thread internal/silk/nlsf_encode.go Outdated
Comment on lines +103 to +107
predQ10 := int32(0)
if k+1 < order {
predQ10 = (int32(predTable[predSelect[index1][k]][k]) * prevOut) >> 8 //nolint:gosec // G115
}
ind := clamp((invQstepQ6*(target-predQ10))>>16, -nlsfQuantMaxAmplitudeExt, nlsfQuantMaxAmplitudeExt-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spent a bit of time diffing some of the riskier files against libopus (a2nlsf, nsq, gains, ltp_scale, control_snr, nlsf_encode — mostly picked based on coverage and bitstream impact). Everything else looked good, but I think I found a bug here

clamp is func clamp(low, in, high int32) int32, but the arguments are swapped:

ind := clamp(raw, -nlsfQuantMaxAmplitudeExt, nlsfQuantMaxAmplitudeExt-1)

that means -10 ends up as the value being clamped, not raw, so the upper bound is never applied. I added some logging and saw raw reaching +31 on valid (just tightly clustered) NLSF vectors, with stage-2 indices ending up as high as +15

the nasty part is that it doesn't fail loudly. EncodeSymbolWithICDF just ignores out-of-range symbols, so the extension marker gets written but the extension symbol doesn't. After that the decoder starts reading the following bits as the extension symbol and the rest of the frame is out of sync

swapping the first two arguments fixes it:

ind := clamp(
    -nlsfQuantMaxAmplitudeExt,
    (invQstepQ6*(target-predQ10))>>16,
    nlsfQuantMaxAmplitudeExt-1,
)

might be worth adding a regression test too. A tightly clustered NLSF vector (strong formants / tonal content) seems to reproduce it pretty reliably

@fallais

fallais commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @thomas-vilte, good catch! You're right, the clamp arguments were inverted — the raw index was going into the lower-bound slot, so the stage-2 indices were never actually bounded and could run past the decodable [-10, 10] range and silently desync the decoder.

I've tried to fix it in 06256a2, and added a regression test over tightly clustered NLSF vectors (the case that triggers it most reliably).

@JoTurk I'll also split this into smaller stacked PRs to make it easier to review — I'll open them and link them back here shortly.

fallais added 7 commits July 17, 2026 09:33
Encoder scaffold and range-coding of the per-field SILK bitstream: subframe
gains, shell/pulse excitation, NLSF stage-1/2 indices, and pitch/LTP indices.
Voice activity detection with adaptive high-pass, float LPC and signal-analysis
primitives, the 3-stage pitch lag search, Burg LPC analysis, A2NLSF conversion,
the find_LPC wrapper, and LTP correlation analysis (find_LTP).
Warped-autocorrelation noise-shape primitives, fixed-point helpers, and the
noise-shaping quantizer that range-codes the excitation.
Wire the analysis and quantization stages into a full frame encoder, expose it
via opus.Encoder.EncodeSILK, and add the voiced/LTP path.
…tion, LTP scaling

Faithful noise-shaping analysis and prediction coefficients, NLSF interpolation
across subframes, and LTP state scaling control.
End-to-end encode/decode fidelity tests, encoder documentation, and golangci-lint
directives for the Q-format conversions and short DSP variable names.
The raw quantization index was passed as the clamp lower bound instead
of the value being clamped, so stage-2 indices were never bounded and
could exceed the decodable [-10, 10] range, silently desyncing the range
decoder. Add a regression test over tightly clustered NLSF vectors.
@fallais
fallais force-pushed the feat/silk-encoder branch from 06256a2 to 6bd3d82 Compare July 17, 2026 07:40
@JoTurk

JoTurk commented Jul 17, 2026

Copy link
Copy Markdown
Member

Sounds good thank you

@fallais

fallais commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@JoTurk @thomas-vilte I reorganized the work so it's easier to review. Instead of the one big squashed commit, it's now a series of coherent commits grouped by subsystem: scaffold + bitstream coding, then signal analysis, noise shaping + NSQ, frame assembly + EncodeSILK, the fidelity refinements, and finally tests/docs/lint. The clamp fix sits on top. So you can go through it commit by commit.

I kept everything in a single PR for now instead of several stacked ones. The encoder was built bottom-up and the lint fixes only really apply to the finished code, so if I split it into separate PRs the intermediate ones would fail golangci-lint. This way CI stays green and the history still tells the story.

That said, if you'd rather have multiple PRs I'm happy to do that, just let me know.

@JoTurk

JoTurk commented Jul 17, 2026

Copy link
Copy Markdown
Member

IMO it's still super hard for human review, and the direction is still more vibe coding than ai assisted work, I have many comments on the code itself from the design to small stuff like all the unnecessary nolints.
Personally i think it should be broken into smaller prs but also smaller and scoped commits, that does a single thing.

@fallais

fallais commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

@thomas-vilte : would you be able to manage that split into multiple PRs ? I am not sure I can do it efficiently

@JoTurk

JoTurk commented Jul 17, 2026

Copy link
Copy Markdown
Member

I'll read the outlines of this pr and the related specs and try to send you a plan to break it by today

@JoTurk

JoTurk commented Jul 17, 2026

Copy link
Copy Markdown
Member

Sorry I thought the request was for me if @thomas-vilte can break it, it would be great

@thomas-vilte

Copy link
Copy Markdown
Member

yeah, i'm happy to take care of the split

i was planning to work on the SILK encoder anyway, so when i saw this PR i spent some time yesterday and today reading through it to avoid duplicating work. i also mapped the call graph while i was at it, which made it a lot easier to see where the natural split points are

i think it breaks down pretty cleanly into: math/bit primitives → shared float DSP → VAD → LPC→NLSF → pitch analysis → Burg/LPC → NLSF quantization → LTP → gains → noise shaping → NSQ → pulses → pitch/LTP bitstream → find_pred_coefs → frame assembly + the public EncodeSILK API

the nice part is that NSQ, pulses, and the pitch/LTP bitstream only depend on the math primitives, so those can land independently while the analysis side is still being reviewed

there are only two small moves i'd make first so everything stands on its own. nlsfToLPCQ12 probably belongs with the NLSF code instead of enc_frame.go, and silkLog2 probably belongs with the shared math helpers instead of pitch_analysis.go. i'd just fold those into the relevant PRs

my plan would be to cherry-pick each piece from this branch, keep François as the author (i'm just repackaging the work), keep each PR small and testable, and review them against the corresponding libopus code as they go

@JoTurk i think this will also make the nolint cleanup a lot easier. if you already have code comments queued up, feel free to drop them here and i'll fold each one into whichever sub-PR touches that code

if it helps, i can also open a tracking issue with the detailed breakdown so we can tick pieces off as they land

if everyone's happy with that, I'd mark this PR as draft and use it as the reference while the smaller PRs land. i can start opening the first couple today

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants