Skip to content
Merged
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ For installation and executable examples, see the [README](./README.md#getting-s

- Replace the per-voice Butterworth IIR decimator (cutoff 0.30 output Fs) with cascaded polyphase linear-phase half-band FIR stages: eco 35 taps at 2×; standard 15 → 47 taps; high 11 → 15 → 47 taps. Substep calls only buffer their input; the cascade runs once per output frame.
- Passband is flat to 0.40 output Fs (ripple below 0.001 dB for standard/high; formerly −11.5 dB at 0.35 Fs). Products folding below 0.40 Fs are rejected by at least 81 dB for standard/high and 63 dB for eco (standard formerly −52.3/−55.6 dB at 0.60/0.625 Fs). Between 0.50 and 0.60 Fs the transition band folds into 0.40–0.50 Fs, and far above Nyquist the former filter's bilinear zero was deeper; see [acoustic quality](./doc/acoustic-quality.md).
- Behavior change: output is delayed by a constant linear-phase 13.25 output frames for standard (8.5 eco, 13.875 high; about 0.28 ms at 48 kHz) instead of the IIR's frequency-dependent 2.7–3.3 frames. Each voice preallocates 158 state scalars instead of 8.
- Behavior change: output is delayed by a constant linear-phase 13.25 output frames for standard (8.5 eco, 13.875 high; about 0.28 ms at 48 kHz) instead of the IIR's frequency-dependent 2.7–3.3 frames. The decimator keeps 158 state scalars instead of 8 (per output channel since post-mix decimation, below).
- Measured on this checkout's cloud container with `npm run benchmark` (median of four interleaved runs per side): standard 8 held voices 0.702 → 0.669 ms per 128-frame block, high 8 held voices 1.204 → 1.116 ms, eco unchanged within noise (0.434 → 0.435 ms).

### Band-limited square, saw and half-wave operators

Square and saw operators now add two-sample polyBLEP residuals at their jumps, and half-wave operators add polyBLAMP residuals at their corners, sized from each operator's unmodulated phase step at the oversampled rate. In unit tests the folded-alias power of periodic frames drops by about 8–17 dB at the internal rate, before the decimator. Phase modulation makes the nominal step an approximation, so these shapes remain not alias-free. All non-sine periodic shapes now use the deterministic polynomial `fastSin` instead of `Math.sin`, and the shape helpers are split so the dispatcher stays inlinable. Measured on Node 22 with 16 held voices at 48 kHz (`standard`): square 1.74 → 0.98 ms/block, half 1.89 → 1.40 ms, saw 1.04 → 1.13 ms. Sine output and `periodicWaveform(theta, code)` without a step are unchanged apart from `fastSin` replacing `Math.sin` (~1e-10). Square/saw/half voices render differently near their edges. These timings are one Node process on one machine, not a browser AudioWorklet or device measurement.

### Post-mix decimation

- Voices are now panned and summed to left/right at the oversampled rate, and one shared decimator per output channel runs after the mix, instead of one decimator per voice. A centered (dual-mono) mix filters once until the channel inputs differ. Measured against per-voice half-band FIR decimation with a 128-frame `Synth.render` loop at 48 kHz (median of three interleaved runs, one Node 22 process on one cloud x64 host): `standard` 8 voices 0.677 → 0.611 ms/block (−10%), 32 voices 2.772 → 2.477 ms (−11%); `high` 8 voices 1.115 → 0.996 ms (−11%), 32 voices 4.626 → 4.154 ms (−10%); `eco` 32 voices unchanged; a single voice costs about 0.01 ms/block more (mix-buffer overhead). Against the earlier per-voice Butterworth decimator the gains were 9–19%.
- Behavior change: note gain, expression, pan and steal fades now apply before the decimator, so a hard gain step is band-limited by it and rings through the half-band FIR (at most 26 output frames) instead of cutting instantly at the output rate. Ramps are within s·p·Σk|h[k]|/4 of the former output-rate product.
- Voices retire on their envelope end (all operator releases finished) and report `ended` without waiting for filter history; the shared decimator drains the tail; once nothing is sounding, history below the −96 dB floor snaps to exact zero.

## v1.11.1

Package version 1.11.1, a GitHub release (not on npm). Performance patch over v1.11; no API, voice-format (still v7) or protocol changes. Output differs from v1.11 by about 1e-10 for sine operators. Security: scoped static review of the `src/core` change found no evidence-backed issue.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ The v1.10 checkout-only benchmark also reports scoped capacity candidates across

`sound-quality` covers all three profiles and passed 2,505 lifecycle/headroom/determinism matrix cases plus 24 live-control cases in the recorded v1.8 verification. It also checks independent passband, THD, folded aliases and streaming references; these are controlled numerical gates, not arbitrary-FM or listening certification. `voice-quality` evaluates the curated preset bank and original synthetic DX7 recipes over register/velocity cells. Peak/RMS are raw dBFS, not LUFS or perceived loudness; host trims do not rewrite synthesis levels. See [acoustic-quality evidence](./doc/acoustic-quality.md#coverage-and-evidence) and [voice-quality notes](./doc/voice-quality.md).

Independent references cover Bessel-series high-index PM, contractive delayed feedback, conservative full-level feedback energy, and nested four-operator chain/branched/multicarrier synthesis. The per-voice 4× decimator is a polyphase linear-phase half-band FIR cascade, flat to 0.40 output Fs with at least 81 dB rejection of products that fold below that edge. Two 120-second streaming cases exercise held AM/PM and glide retargets. See [acoustic reference mathematics and limits](./doc/acoustic-quality.md); none certify arbitrary alias-free FM, perceptual quality or hardware fidelity.
Independent references cover Bessel-series high-index PM, contractive delayed feedback, conservative full-level feedback energy, and nested four-operator chain/branched/multicarrier synthesis. The shared post-mix 4× decimator is a polyphase linear-phase half-band FIR cascade, flat to 0.40 output Fs with at least 81 dB rejection of products that fold below that edge. Two 120-second streaming cases exercise held AM/PM and glide retargets. See [acoustic reference mathematics and limits](./doc/acoustic-quality.md); none certify arbitrary alias-free FM, perceptual quality or hardware fidelity.

`browser-stress` exercises real AudioWorklet output, dense admissions/steals, controls, late rejection and queue cleanup under bounded main-thread contention. Timer gaps and diagnostic round trips are observations, **not** worklet CPU/GC/underrun/glitch measurements. Periodic analyser samples cannot prove uninterrupted audio. The Vite smoke additionally verifies an installed current tarball, non-root production deployment, retained license, CSP and negative asset/MIME cases. Run these separately from deadline benchmarks.

Expand Down
2 changes: 1 addition & 1 deletion dist/core/synth.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core/synth.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions doc/acoustic-quality.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Acoustic references and decimation — OPM.js</title><link rel="stylesheet" href="site.css"><script src="search-data.js" defer></script><script src="site.js" defer></script></head><body><a class="skip" href="#content">Skip to content</a><header><a href="index.html">OPM.js documentation</a><a href="https://opm.js-package.xyz/">Online demos</a></header><div class="layout"><aside><form role="search"><label for="docs-search">Search guides and API exports</label><input id="docs-search" type="search" autocomplete="off" aria-controls="search-results"><p id="search-status" role="status" aria-live="polite"></p><ul id="search-results"></ul></form><nav aria-label="Documentation"><ul><li><a href="./index.html">OPM.js documentation</a></li><li><a href="./readme.html">OPM.js</a></li><li><a href="./security.html">SECURITY.md — OPM.js Security Policy &amp; Review Mechanism</a></li><li><a href="./changelog.html">Changelog</a></li><li><a href="./acoustic-quality.html" aria-current="page">Acoustic references and decimation</a></li><li><a href="./adaptive-music.html">Adaptive music, tempo curves and the seek contract</a></li><li><a href="./audio-buses.html">Polyphony budgets, voice priority and independent buses</a></li><li><a href="./compatibility.html">Compatibility policy</a></li><li><a href="./expressive-voices.html">Voice v7 and expressive controls</a></li><li><a href="./host-integration.html">Host integration</a></li><li><a href="./midi-files.html">Standard MIDI files</a></li><li><a href="./midi-performance.html">Expressive performance and optional Web MIDI</a></li><li><a href="./mobile-acceptance.html">Physical-device audio recovery acceptance</a></li><li><a href="./publishing.html">Publication and package verification</a></li><li><a href="./score-projects.html">Replayable score projects</a></li><li><a href="./sound-design.html">Sound design workflow</a></li><li><a href="./streaming-sequences.html">Bounded sequences</a></li><li><a href="./usage.en.html">OPM.js usage guide (English)</a></li><li><a href="./usage.zh-TW.html">OPM.js 使用說明(繁體中文)</a></li><li><a href="./voice-quality.html">Preset and conversion acceptance</a></li><li><a href="./worker-diagnostics.html">Worker render diagnostics, deadlines and rollback</a></li><li><a href="./api.html">Generated API reference</a></li></ul></nav><nav aria-label="On this page"><h2>On this page</h2><ul><li class="depth-1"><a href="#acoustic-references-and-decimation">Acoustic references and decimation</a></li><li class="depth-2"><a href="#response-and-choice">Response and choice</a></li><li class="depth-2"><a href="#phase-tails-and-cpu-tradeoffs">Phase, tails and CPU tradeoffs</a></li><li class="depth-2"><a href="#coverage-and-evidence">Coverage and evidence</a></li><li class="depth-2"><a href="#conservative-host-capacity-candidates">Conservative host capacity candidates</a></li><li class="depth-3"><a href="#observed-checkout-measurement">Observed checkout measurement</a></li></ul></nav></aside><main id="content" tabindex="-1"><p class="source">Generated from <a href="./acoustic-quality.md">Markdown source</a>. Edit the source, not this HTML.</p><h1 id="acoustic-references-and-decimation">Acoustic references and decimation <a class="anchor" href="#acoustic-references-and-decimation" aria-label="Link to Acoustic references and decimation">#</a></h1>
<p>Every profile renders operators at an internal multiple of the output rate and decimates with a cascade of original <strong>linear-phase half-band FIR</strong> filters. Each 2:1 stage has a centre tap of 1/2 and every other tap zero, so it costs one multiply per unique coefficient pair, and it is evaluated in polyphase form: oversampled inputs are only buffered, and the whole cascade runs once per output frame on the last internal substep. The default <strong>standard</strong> profile uses four-times internal sampling: a 15-tap stage at 4× followed by a 47-tap stage at 2×. Passband is flat to <strong>0.40 times output sample rate</strong>; products that would fold below that edge (from 0.60 Fs upward) are rejected by at least 81 dB. Each voice has 158 preallocated state scalars (counter/ring positions, the frame&#39;s substep inputs and doubled delay rings); there are no allocations. State resets on admission and all buffered history must drain to zero on release.</p>
<p>Every profile renders operators at an internal multiple of the output rate and decimates with a cascade of original <strong>linear-phase half-band FIR</strong> filters. Each 2:1 stage has a centre tap of 1/2 and every other tap zero, so it costs one multiply per unique coefficient pair, and it is evaluated in polyphase form: oversampled inputs are only buffered, and the whole cascade runs once per output frame on the last internal substep. The default <strong>standard</strong> profile uses four-times internal sampling: a 15-tap stage at 4× followed by a 47-tap stage at 2×. Passband is flat to <strong>0.40 times output sample rate</strong>; products that would fold below that edge (from 0.60 Fs upward) are rejected by at least 81 dB. Voices are panned and summed to left/right at the internal rate, and one shared decimator per output channel runs after the mix, so filter work no longer scales with polyphony. Each channel has 158 preallocated state scalars (counter/ring positions, the frame&#39;s substep inputs and doubled delay rings); there are no allocations. A centered (dual-mono) mix filters once until the channel inputs first differ. Because note gain, expression, pan and steal fades apply before the decimator, a hard gain step is band-limited by it and rings for the FIR&#39;s length (at most 26 output frames, standard/high) plus its linear-phase delay instead of cutting at the output rate.</p>
<p>Before this change (v1.11.1 and earlier) the decimator was an eighth-order bilinear Butterworth IIR with cutoff 0.30 output Fs (fourth order for eco). Its tables are kept below for comparison.</p>
<p>Construction accepts <code>quality: &#39;eco&#39; | &#39;standard&#39; | &#39;high&#39;</code> in <code>SynthOptions</code>, offline render options and <code>OPM</code> options. Quality cannot change on an active synth; OPM retains it across restart. <code>maxVoices</code> accepts 1–32, with default 8; larger budgets are opt-in; the profile tables and the stealing-fade ceiling (eight) are unchanged.</p>
<div class="table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table><thead><tr><th scope="col">Profile</th><th scope="col" class="align-right">Internal rate</th><th scope="col">Half-band stages (taps)</th><th scope="col" class="align-right">Design rejection</th><th scope="col" class="align-right">Group delay</th><th scope="col">Tradeoff</th></tr></thead><tbody><tr><td>eco</td><td class="align-right">2×</td><td>35</td><td class="align-right">63 dB</td><td class="align-right">8.5 frames</td><td>Fewer oscillator substeps, earlier internal Nyquist and weaker rejection</td></tr><tr><td>standard (default)</td><td class="align-right">4×</td><td>15 → 47</td><td class="align-right">81 dB</td><td class="align-right">13.25 frames</td><td>Acceptance baseline</td></tr><tr><td>high</td><td class="align-right">8×</td><td>11 → 15 → 47</td><td class="align-right">81 dB</td><td class="align-right">13.875 frames</td><td>Higher internal Nyquist, twice standard oscillator substeps; not an arbitrary-patch alias-free guarantee</td></tr></tbody></table></div>
Expand All @@ -26,7 +26,7 @@ <h2 id="coverage-and-evidence">Coverage and evidence <a class="anchor" href="#co
<p>Historical <strong>1.8.0</strong> checkout measurement (not the new capacity matrix): Apple M5, darwin arm64, Node.js 26.7.0, 48 kHz, 128-frame blocks (2.667 ms deadline), 300 excluded warmup blocks and 2,000 measured blocks per row. No explicit acceptance budgets were configured.</p>
<div class="table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table><thead><tr><th scope="col">Profile</th><th scope="col" class="align-right">Median ms</th><th scope="col" class="align-right">p95 ms</th><th scope="col" class="align-right">p99 ms</th><th scope="col" class="align-right">Worst ms</th><th scope="col" class="align-right">p99 / deadline</th><th scope="col" class="align-right">Misses</th></tr></thead><tbody><tr><td>Eco</td><td class="align-right">0.212375</td><td class="align-right">0.231625</td><td class="align-right">0.279708</td><td class="align-right">0.453166</td><td class="align-right">0.105</td><td class="align-right">0 / 2,000</td></tr><tr><td>Standard</td><td class="align-right">0.380333</td><td class="align-right">0.423750</td><td class="align-right">0.509292</td><td class="align-right">0.607917</td><td class="align-right">0.191</td><td class="align-right">0 / 2,000</td></tr><tr><td>High</td><td class="align-right">0.680709</td><td class="align-right">0.747333</td><td class="align-right">0.819917</td><td class="align-right">0.981125</td><td class="align-right">0.307</td><td class="align-right">0 / 2,000</td></tr></tbody></table></div>
<p>All rows reported zero DSP errors and admission rejections. These are offline host wall-clock measurements, not AudioWorklet underrun counters, allocation measurements, listening judgments or physical-device acceptance. They do not replace the different historical v1.6 host results recorded in CHANGELOG.</p>
<p>Envelope-transition references compare the waveform through the actual voice-ended frame, then require exact retired silence. The implementation retires a voice once every buffered FIR input is below the state floor rather than waiting for exact zeros. A state floor is not a bound on each future output sample: the independent bound multiplies the floor by each stage&#39;s absolute tap sum.</p>
<p>Voices retire on their envelope: once every operator release has ended, the voice is freed and its <code>ended</code> notification fires without waiting for filter history. The shared decimator keeps draining that history; envelope-transition references compare the waveform through the frame where the channel&#39;s buffered FIR inputs all fall below the state floor, then require exact silence. The implementation clears that sub-floor history (only on a channel whose input is silent) rather than waiting for exact zeros. A state floor is not a bound on each future output sample: the independent bound multiplies the floor by each stage&#39;s absolute tap sum.</p>
<h2 id="conservative-host-capacity-candidates">Conservative host capacity candidates <a class="anchor" href="#conservative-host-capacity-candidates" aria-label="Link to Conservative host capacity candidates">#</a></h2>
<p><strong>Scope note (v1.11):</strong> the benchmark patches use sine operators only. Non-sine operator waveforms (<code>half</code>, <code>abs</code>, <code>quarter</code>, <code>alternating</code>, <code>camel</code>, <code>square</code>, <code>saw</code>) add a per-sample waveform evaluation and <code>noise</code> adds a bounded LFSR step, so patches that use them cost more per voice than the measured candidates indicate; the work stays bounded by the voice limit, but the capacity candidates below were not re-measured for those voices.</p>
<p>The v1.10 checkout adds this matrix/candidate output through the <strong>checkout-only</strong> maintenance command; it is not a packaged API or command. From the matching 1.10.0 source checkout, run:</p>
Expand Down
Loading
Loading