Skip to content

perf(core): polyphase half-band FIR decimation - #5

Merged
YueyuHoshizora merged 3 commits into
mainfrom
claude/project-thread-fgj3d9
Oct 21, 2026
Merged

YueyuHoshizora merged 3 commits into
mainfrom
claude/project-thread-fgj3d9

Conversation

@YueyuHoshizora

Copy link
Copy Markdown
Collaborator

Requested by 小語 · project thread

Summary

Replaces the per-voice Butterworth IIR decimator (src/core/decimator.ts, cutoff 0.30 Fs) with cascaded polyphase linear-phase half-band FIR stages:

Profile Stages (taps) Design rejection Group delay
eco (2×) 35 63 dB 8.5 frames
standard (4×) 15 → 47 81 dB 13.25 frames
high (8×) 11 → 15 → 47 81 dB 13.875 frames
  • Equiripple (Parks–McClellan) half-band taps; one multiply per unique coefficient pair.
  • decimateSample(input, state, coefficients) keeps the same signature. Each substep call only buffers its input. The last substep of a frame runs one hand-unrolled kernel for the whole cascade, so outputs are computed only where needed. The first unrolled version was 10–25% slower because V8 would not inline helpers inside renderVoice. The tiny per-substep path fixed that.
  • synth.ts changes are limited to 3 lines: the import, filters sized by DECIMATOR_STATE_SIZE (158 scalars), and the tail check via the new decimatorPeak(), which skips counter/position slots.

Quality (standard, 48 kHz, test/decimator-quality.ts, measured = analytic within 2e-9)

Freq / Fs Butterworth (before) Half-band FIR (after)
.20 -0.006 dB +0.001 dB
.35 -11.51 dB +0.001 dB
.40 -21.06 dB -0.001 dB
.50 -37.90 dB -6.05 dB
.60 (folds to .40) -52.29 dB -81.39 dB
.625 (folds to .375) -55.62 dB -81.56 dB
1.125 -112.88 dB -92.75 dB
1.6 -177.26 dB -89.35 dB

Passband ripple to .40 Fs is 0.0011 dB. The worst stopband across folds landing below .40 Fs is -81.4 dB. npm run sound-quality passes for all three profiles. At .35 Fs, measured loss is ≤0.006 dB on every profile; it was 7.3–11.5 dB before. The THD is unchanged (0.0208%).

Tradeoffs:

  1. Delay: a constant 13.25-frame linear-phase delay (~0.28 ms @ 48 kHz), up from the IIR's ~2.7–3.3 frames.
  2. Transition band: content at 0.50–0.60 Fs folds into 0.40–0.50 Fs with only partial attenuation. That is above 19.2 kHz at 48 kHz, but starts at 17.6 kHz at 44.1 kHz.
  3. Far stopband: well above Nyquist (≥1.125 Fs), the old bilinear zero rejected more deeply. The FIR stays at a flat 81–93 dB.

CPU (npm run benchmark, median of 4 interleaved base/new runs, cloud Xeon, Node 22)

Case Before ms After ms Δ
standard 8 held + LFO (matrix) 0.702 0.669 -4.7%
standard quality comparison, 8 voices 0.746 0.664 -11.0%
standard 32 held 2.861 2.786 -2.6%
eight voices, no LFO 0.592 0.547 -7.5%
high 8 held 1.204 1.116 -7.3%
high 32 held 4.976 4.656 -6.4%
eco 8 held 0.434 0.435 +0.2%
eco 32 held 1.787 1.803 +0.9%

Eco is flat within noise; this container's run-to-run spread is about ±10%. Arithmetic per output frame is 20 multiplies for standard, down from 80 (16 biquads).

Tests

  • test/decimator-reference.ts now evaluates the half-band DTFT directly from the published taps; it does not use the production rings.
  • decimator-quality, engine-v6, spectral and sound-quality assert a flat passband to .40 Fs and ≥80 dB folded rejection. The Butterworth formula is kept as butterworthFilterMagnitude for comparison rows.
  • Delay-aware adjustments:
    • The operator-ramp demodulation expectation is shifted by the group delay.
    • The legato energy comparison skips the first 32 frames, which still carry the released note's filter delay.
    • The feedback-DC check multiplies by the cascade's DC gain (1 ± 1e-4).
  • Corruption tests now use filters.fill(Infinity), because slot 0 is the frame counter.
  • npm test passes (440/440). npm run typecheck, npm run build (dist + docs) and npm run sound-quality all pass.

Security review (SECURITY.md §C, src/core/ change)

  • Non-finite output still snaps to silence. A corrupted state is either detected (NaN/Inf samples reach the output) or harmless:
    • Positions are read with | 0.
    • Out-of-range typed-array writes are ignored.
    • Out-of-range reads give NaN, which is caught.
  • Fixed work per frame, no allocations, state reset on admission. All history drains to exact zero.
  • Deterministic and chunk-independent (whole vs 127-frame chunk tests pass). No new dependencies (npm ls --omit=dev is empty).

Note

Another thread is moving decimation post-mix in synth.ts. This PR keeps the decimateSample interface, so that change can call it on the mixed signal.


Generated by Claude Code

Replace the per-voice Butterworth IIR decimator with cascaded linear-phase
half-band FIR stages (eco 35; standard 15 -> 47; high 11 -> 15 -> 47 taps).
Substep calls only buffer their input; one hand-unrolled kernel per output
frame runs the cascade, so the voice loop inlines the per-substep path.

Passband is flat to 0.40 output Fs and products folding below it are
rejected by >= 81 dB (63 dB eco). Output gains a constant 13.25-frame
linear-phase delay for standard. Test references now evaluate the
half-band DTFT directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT2ixgjbZRrhGw7uzgGPpz
@YueyuHoshizora YueyuHoshizora self-assigned this Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

windows-arm is red, but not because of this PR. All 5 failures are in test/docs-build.test.ts:

  • 3 tests: Missing declaration for public export opm.js:compile from the docs fixture.
  • 2 tests: ENOENT when writing fixture filenames that contain ? / #, which Windows rejects.

The same windows-arm job fails the same way on main at 17bd3a4, the commit that added the job (run 37132511410). This PR does not touch the docs build or its tests.

No fix exists yet in any open PR, so nothing is ported here. I can't re-run jobs from this session. The other jobs (Linux node / browsers) are the relevant signal for this change.


Generated by Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T18:59:58.952443Z 528ec24 New commits
🔒 Security Review ✅ Completed 2026-10-21T16:50:47.427763Z 913e30f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 913e30fde9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread README.md Resolved
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT2ixgjbZRrhGw7uzgGPpz
…-fgj3d9

# Conflicts:
#	CHANGELOG.md
#	dist/core/synth.js
#	dist/core/synth.js.map
#	doc/changelog.html
#	doc/search-data.js
@YueyuHoshizora
YueyuHoshizora merged commit 549714a into main Oct 21, 2026
14 checks passed
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.

2 participants