fm-synthesizers.js
v0.1.0
Published
FM synthesizers for the browser. Computed on the fly, no samples, tens of kilobytes.
Maintainers
Readme
fm-synthesizers.js
A 26.8 KB gzipped browser FM synthesizer with 19 curated patches and 40 documented controls. Audio is synthesized at runtime in a WebAssembly AudioWorklet; the package contains no samples and needs no network access while playing.
npm package | Patch showcase | Playground | Changelog
Install
npm install fm-synthesizers.jsimport { createEngine } from "fm-synthesizers.js";
import { applyPreset } from "fm-synthesizers.js/presets";
const engine = await createEngine(); // resolves its own WASM and worklet
applyPreset(engine, "e-piano-fm");
engine.noteOn(60, 0.9);Call createEngine() from a user gesture because browsers control when audio may start. The engine resolves its packaged WASM and inlined worklet itself, and applyPreset() sends a complete patch so no state carries over from the previous sound.
What is included
- A voice of 4 operators (sine oscillators, each with its own ratio, level, and ADSR) routed through fixed, curated algorithms — carrier+modulator, carrier+2 modulators, stacked pairs, and a feedback topology.
- Modulation index as the headline control — FM's "drive" — with velocity mapped to index so harder playing brightens the tone.
- 4× oversampled rendering: FM's Bessel sideband series is infinite, so the alias problem is the whole game, and the shipped path is measured against the closed-form spectrum.
- Ensemble chorus, ping-pong delay with tone control, and stereo feedback-delay-network reverb.
- A curated patch bank with explicit intent provenance (roster landing in M3) rather than a sample download or external asset service.
| group | patches | |---|---:| | keys | 7 | | pluck | 4 | | bass | 3 | | pad | 2 | | lead | 2 | | brass | 1 | | total | 19 |
Compatibility and lifecycle
The ESM imports are SSR-safe: importing the package does not touch window or construct an AudioContext. Packed-package checks install the tarball into clean projects and render audio in Chromium and Playwright WebKit; separate fixtures build it without library-specific configuration in Vite, webpack 5, and Next.
createEngine({ context }) shares a caller-owned BaseAudioContext, while createEngine({ connect: false }) leaves engine.output unconnected for caller-controlled routing. engine.resume() recovers any non-running, non-closed context state, including WebKit's interrupted state. engine.dispose() is idempotent, frees the worklet's WASM engine, disconnects output, and closes only a context created by the library. Construction failures reject createEngine(); later worklet, processor, and message errors reach engine.onError and the console.
No CDN, sample, or network request is made while playing. The only runtime fetch is the WASM file installed with the package unless the caller supplies wasmUrl; the worklet is inlined into the JavaScript build.
API
createEngine(options?) → Promise<Engine>
| option | meaning |
|---|---|
| wasmUrl?: string \| URL | Override where the WASM is fetched from. Defaults to the packaged asset. |
| workletUrl?: string \| URL | Override the worklet module URL. Defaults to inlined source via a Blob URL. |
| context?: BaseAudioContext | Supply your own context — required for an OfflineAudioContext render. |
| connect?: boolean | Connect the output to context.destination. Defaults to true; pass false for caller-controlled routing. |
| initialEvents?: ScheduledEvent[] | Events applied at node construction. Required for offline rendering: an OfflineAudioContext can finish rendering without ever servicing the message port. |
Engine
| member | meaning |
|---|---|
| readonly context: BaseAudioContext | The context the engine was created on — yours, or one it made. |
| readonly node: AudioWorkletNode | The engine's output node. Connected to the destination unless connect: false was requested. |
| readonly output: AudioWorkletNode | Unambiguous output handle. This is the same AudioWorkletNode as node. |
| readonly voices: number | Voices currently sounding. Updated ~10 times a second. |
| onStats?: (stats: { voices: number }) => void | Called with engine stats as they arrive. |
| onError?: (error: Error) => void | Called when the worklet reports a runtime, message-deserialization, or processor error. |
| resume(): Promise<void> | Resume any non-running, non-closed context state, including WebKit's interrupted. Safe to call from a user gesture. |
| noteOn(note: number, vel?: number): void | Start a note now. note is MIDI (60 = middle C), vel is 0..1. |
| noteOff(note: number): void | Release a note now; its amp release still rings out. |
| allOff(): void | Release every sounding note, tails intact. |
| schedule(events: ScheduledEvent[]): void | Queue events at absolute context times; applied on the exact frame. |
| clear(): void | Drop everything pending and silence. |
| setParam(name: ParamName, value: number): void | Set one patch parameter, effective on the next block. |
| dispose(): Promise<void> | Free the WASM engine and disconnect its output. Idempotent; closes only a context the library created. |
PARAMETERS is the authoritative metadata for all 40 controls; PARAM, ALGORITHM, RATIOS, preset defaults, declarations, the playground, and the parameter table below derive from it.
For deterministic offline rendering, pass an OfflineAudioContext and initialEvents to createEngine(), then call context.startRendering(). A live engine instead accepts noteOn(), noteOff(), schedule(), and setParam() messages after construction.
Parameters
PARAMETERS is exported from the main entry point and is the source of every parameter id, preset-reset default, supported range, increment, unit, and enum value. DEFAULTS from fm-synthesizers.js/presets is generated from the same definitions. Presets merge their partial overrides over those defaults before applying all controls. Values outside the supported range are not part of the public contract. An optional editorMax is a preferred slider ceiling for fine control; the playground expands it when a loaded preset uses a larger supported value.
| parameter | id | preset default | supported range | step | unit / values |
|---|---:|---:|---:|---:|---|
| algorithm | 0 | 0 | 0 … 3 | 1 | mod1 = 0, mod2 = 1, stack2 = 2, feedback = 3 |
| index | 1 | 0.5 | 0 … 2 | 0.01 | ratio |
| feedback | 2 | 0 | 0 … 0.9 | 0.01 | ratio |
| velToIndex | 3 | 0.3 | 0 … 1 | 0.01 | ratio |
| gain | 4 | 0.32 | 0 … 0.85 | 0.01 | linear gain |
| op1Ratio | 5 | 1 | 0.5 … 8 | 0.1 | ratio |
| op1Level | 6 | 0.7 | 0 … 1 | 0.01 | linear gain |
| op1Attack | 7 | 0.005 | 0.001 … 2 | 0.001 | seconds |
| op1Decay | 8 | 0.3 | 0.005 … 2 | 0.005 | seconds |
| op1Sustain | 9 | 0.5 | 0 … 1 | 0.01 | ratio |
| op1Release | 10 | 0.2 | 0.005 … 3 | 0.005 | seconds |
| op2Ratio | 11 | 2 | 0.5 … 8 | 0.1 | ratio |
| op2Level | 12 | 0.6 | 0 … 1 | 0.01 | linear gain |
| op2Attack | 13 | 0.005 | 0.001 … 2 | 0.001 | seconds |
| op2Decay | 14 | 0.3 | 0.005 … 2 | 0.005 | seconds |
| op2Sustain | 15 | 0.5 | 0 … 1 | 0.01 | ratio |
| op2Release | 16 | 0.2 | 0.005 … 3 | 0.005 | seconds |
| op3Ratio | 17 | 3 | 0.5 … 8 | 0.1 | ratio |
| op3Level | 18 | 0.5 | 0 … 1 | 0.01 | linear gain |
| op3Attack | 19 | 0.005 | 0.001 … 2 | 0.001 | seconds |
| op3Decay | 20 | 0.3 | 0.005 … 2 | 0.005 | seconds |
| op3Sustain | 21 | 0.5 | 0 … 1 | 0.01 | ratio |
| op3Release | 22 | 0.2 | 0.005 … 3 | 0.005 | seconds |
| op4Ratio | 23 | 4 | 0.5 … 8 | 0.1 | ratio |
| op4Level | 24 | 0.5 | 0 … 1 | 0.01 | linear gain |
| op4Attack | 25 | 0.005 | 0.001 … 2 | 0.001 | seconds |
| op4Decay | 26 | 0.3 | 0.005 … 2 | 0.005 | seconds |
| op4Sustain | 27 | 0.5 | 0 … 1 | 0.01 | ratio |
| op4Release | 28 | 0.2 | 0.005 … 3 | 0.005 | seconds |
| chorusMix | 29 | 0 | 0 … 1 | 0.01 | ratio |
| chorusRate | 30 | 0.6 | 0.05 … 6 | 0.01 | Hz |
| chorusDepth | 31 | 3 | 0 … 12 | 0.1 | milliseconds |
| delayMix | 32 | 0 | 0 … 1 | 0.01 | ratio |
| delayTime | 33 | 0.25 | 0.02 … 1 | 0.005 | seconds |
| delayFeedback | 34 | 0.35 | 0 … 0.92 | 0.01 | ratio |
| delayTone | 35 | 3200 | 400 … 16000 | 100 | Hz |
| reverbMix | 36 | 0 | 0 … 1 | 0.01 | ratio |
| reverbSize | 37 | 0.6 | 0 … 1 | 0.01 | ratio |
| reverbDamp | 38 | 4200 | 800 … 14000 | 100 | Hz |
| reverbPredelay | 39 | 18 | 0 … 100 | 1 | milliseconds |
Known limits
- This is a browser AudioWorklet library, not a Node audio renderer, DAW, sequencer, arpeggiator, sampler, Web MIDI adapter, or plugin format.
- Chromium and Playwright WebKit are blocking release targets. Firefox and direct mobile-device performance tiers are not currently release gates.
- The voice pool steals the oldest voice when exhausted. Under load the engine degrades by shedding a voice rather than increasing its fixed allocation.
setParam()rejects unknown names, but callers are responsible for keeping values inside the exportedPARAMETERSranges.
Alias suppression is a hard CI gate with the shipped 4× path clearing -35 dB worst case across the ratio/index grid; the residual ceiling sits below -40 dB and is driven by the final-stage decimation filter (see scripts/verify/verify_spec.py and crates/dsp/src/filter.rs).
Size
| artifact | raw | gzipped |
|---|---:|---:|
| packages/core/wasm/fm_dsp.wasm | 45,836 B | 18,216 B |
| packages/core/src/index.js | 7,870 B | 2,815 B |
| packages/core/src/parameters.js | 4,554 B | 1,586 B |
| packages/core/src/presets.js | 11,235 B | 2,695 B |
| packages/core/worklet/processor.js | 5,766 B | 2,169 B |
| total | | 27,481 B (26.8 KB) |
Budget is 60 KB gzipped for the whole library — currently 44%.
Runtime cost
| | | |---|---| | voices in the reference arrangement | 16 (pad + bass + lead, chorus on) | | audio-thread budget used | 15.4 % of the 2.667 ms / 128-frame budget | | real-time factor | 6.5x |
The benchmark saturates the voice pool with the reference arrangement and enables the feedback algorithm and full index, which is the worst case this build can produce. The measurement describes the machine that regenerated the table; performance on other devices, including mobile devices, is not claimed.
Verification
An FM carrier/modulator pair has an exact closed-form spectrum — sidebands at f_c ± k·f_m with Bessel amplitudes J_k(I) — so the harness grades the shipped engine against the analytic prototype, checks alias energy, stability, headroom, tuning, patch-bank loudness and distinctness, audio-thread cost, artifact size, package installation, browser audio, lifecycle failure paths, and real consumer builds.
| candidate | alias dB | sideband err | verdict |
|---|---:|---:|---|
| honest_fm | -58.9 | 0.7 | PASS |
| wasm_fm | -41.3 | 0.7 | PASS |
| wasm_fm_1x | -17.9 | 0.7 | REJECT (passed visible) |
| naive_fm | -17.9 | 0.7 | REJECT (passed visible) |
| cheat_silence | inf | inf | REJECT |
| cheat_pure_sine | -57.9 | 75.3 | REJECT |
| cheat_brickwall | -57.8 | 75.3 | REJECT |
| cheat_special_cased | -17.9 | 0.7 | REJECT (passed visible) |
wasm_fm is the shipped path. The remaining candidates include deliberate cheats: silence, a pure sine (a carrier-only oscillator has no sidebands at index > 0), a brick-wall construction, and a candidate special-cased to the visible grid. Their rejection demonstrates that an alias metric cannot pass by deleting the intended sideband structure or overfitting published cases.
Measured alias suppression
| ratio | naive FM | honest Bessel | shipped WASM | |---:|---:|---:|---:| | 1.0 | -119.8 dB | -119.8 dB | -102.3 dB | | 2.0 | -119.9 dB | -119.9 dB | -103.3 dB | | 3.0 | -119.7 dB | -119.7 dB | -104.1 dB | | 7.0 | -90.0 dB | -120.2 dB | -98.2 dB |
Patch intent coverage
Every exported preset is bound to exactly one checked intent artifact. prior means Git history proves the intent predates implementation. The bank starts empty and presets land with a prior intent committed first; there is no retrospective migration because the checker exists from day one.
| intent coverage | count | |---|---:| | exported presets | 19 | | exactly mapped implemented intents | 19 | | written before implementation | 19 | | reconstructed after implementation | 0 | | proposed before implementation | 0 |
Harness
| | | |---|---| | harness audit assertions | 22 | | Python harness/spec tests | 20 | | public metadata/README tests | 9 | | deliberately-broken fixtures | 8 |
Rules are enforced as hooks, generated artifacts, or failing tests rather than prose alone. That includes deliberately broken fixtures proving the audit can fail for the defect it claims to catch.
PRINCIPLES.md— project constitution.AGENTS.md— operating rules and task routing.agentic-docs/design/— architecture, verification, release criteria, and harness evidence.agentic-docs/build-usage-log.md— token cost of the build, by session.
Development
rustup target add wasm32-unknown-unknown
npm install
git config core.hooksPath .githooks
cargo build -p fm-dsp --target wasm32-unknown-unknown --release
npm run audit:harness
npm run verify:spec
npm run audit:bundle
npm run check:install
npm run check:types
npm run check:bundlersA short build log
This project is the third sibling in the sets-of-instruments-js family, after
physical-instruments.js and
subtractive-synthesizers.js.
The plumbing and the harness were copied from the subtractive sibling byte-identical
where possible, with provenance recorded in the licensing ledger; what FM adds is the
operator model and a harder alias problem.
FM builds a tone by using one oscillator to modulate the phase of another. A carrier at f_c modulated by a modulator at f_m with index I produces sidebands at f_c ± k·f_m with amplitudes given by Bessel functions — an exact, closed-form spectrum. That is why FM is the cleanest specification project in the family: the verification harness can grade the engine against the mathematics itself, no recording required. It is also why the alias problem is harder than in subtractive: the sideband series is infinite, so there is always content above Nyquist folding back, and the shipped path runs 4× oversampled to keep it honest.
The technique has a well-documented history. John Chowning's original work at Stanford established the mathematics, and the first commercial instruments based on it — the Yamaha DX series, starting with the DX7 in 1983 — made FM the sound of a decade's pop and its electric-piano-ish and bell signatures the most recognisable synthesizer timbre after the analog classics. The DX7's popularity turned FM into a specific cultural memory while its patents were still live; both the patents and the marketing hype are long gone, and what remains is a genuinely distinct way to shape a spectrum.
Agentic build log
This library was built by a coding agent working with the owner in one continuous
session, and the cost of that is recorded here rather than remembered. Numbers are
generated from the session database by scripts/usage/usage-log.py; the table below is
a point-in-time snapshot at 0.1.0 and drifts as the project moves on. The column
exchanges is the number of messages the owner sent the agent — every intervention,
instruction, or correction — and agent turns is the agent's replies plus tool calls.
| session | model | opencode | cost USD | tokens total | owner exchanges | agent turns | tool calls |
|---|---:|---|---:|---:|---:|---:|---:|
| ses_03b61b243ffegicMyoGpn2wxxv | deepseek-v4-flash (opencode-go) | 1.18.11 | 1.0303 | 287,531,356 | 5 | 679 | 709 |
tokens total is input + output + reasoning + cache-read for the session — the rough
budget consumed, not a billable figure. cost USD is the number the provider actually
charges.
The exchanges, summarized
The owner talked to the agent four times over the whole build. What each turn asked for:
- Start the sibling, and instrument the build itself. Read the knowledge base and
the shipped siblings, then build
fm-synthesizers.js— and this time record the token cost, the number of times we talk, the model, and the opencode version, so the build has a number attached to it. - Ship it. Create the GitHub repo under the owner's account, wire up the remote, and push. (Also implicitly: enable the demo pages, which the owner later found were 404.)
- Drive to release. Keep going autonomously through the milestones until the package is ready to release.
- Identity and polish. Give FM its own logo (the
[FM]mark in violet instead of the subtractive[-]in blue), its own dark-purple theme for the demo pages, an agentic-build-log section in the README with a per-turn summary, and make sure the demo pages are actually live — and keep the patches and demos FM-shaped rather than reusing subtractive's. - Taste on the sound. Listening feedback: the patches had too much reverb on average, and the roster should grow with more of the classic, well-documented FM recipes (mallets, drawbar organ, steel pans, pure leads). Result: reverb pulled back to ~0.20 average with dry rhythm patches, and the bank grown from 10 to 19 genuinely distinct patches.
The point of the table is not to celebrate the number; it is to make the cost of this way of working visible and checkable. The session it describes is the entire build from empty directory to release-ready package.
License
Dual-licensed under MIT or Apache-2.0, at your option. Porting and trademark policy is recorded in the repository's licensing ledger.
