# The engine in Rust
*Design, 2026-11-05. Written before the port; the "Status" section at the end records what shipped.*
## Why
1. **JDAW.** JDAW (`~/jdaw/VISION.md`) is a native Rust engine with a web UI. CHIMERA joins it as a
source module "once its DSP is ported to Rust" (principles 5, 9, 14). JDAW needs a headless,
deterministic, real-time-safe library with no browser in it. Offline renders must equal
real-time output (6), the engine must be able to use every core (7), and macOS and Linux must
both work, with WASM for the web (8).
2. **Audio-thread headroom.** The TS engine uses 30–45% of one fast core in the AudioWorklet
(male voice at 2× oversampling, the creatures at a similar cost). A slower laptop has little
headroom left.
The UI lag is a separate problem. Measured in Chromium, it comes from the anatomy view's canvas
`blur()` filter (applied to every fill, every frame), not from the engine. It gets its own small
fix.
## Scope
Everything under `src/engine/` moves to Rust, and the TS engine is deleted, so there is one
source of truth and no drift. That covers the airway FDTD, the frequency-domain oracle, the human
anatomy and articulators, the gestural score, the glottis/valves/syrinx sources, the creatures,
the phonetics (IPA, CMUdict G2P, syllables, durations, planner, singing, beatbox, demo songs) and
the analysis code (FFT, LPC, YIN). The worklet's state machine (`src/worklet/processor.ts`: body
switches, creature switches, beat player, ordered replay) moves too, because JDAW needs that
behaviour as well.
These stay in TS:
- **UI-only code**: React, the canvas renderers, the creature drawing data (silhouettes and tube
paths), the zustand stores, Web MIDI, and the live Sing controller (`ui/singing.ts`). The
controller calls the Rust planners.
- **The browser audio graph**: the analysers and the close-mic chain, which use WebAudio nodes.
## Layout
```
Cargo.toml workspace
crates/chimera/ the engine (lib). deps: libm, serde(+derive), serde_json. No I/O, no globals but OnceLock tables.
crates/chimera-wasm/ cdylib for wasm32-unknown-unknown: a C ABI over chimera, no wasm-bindgen
crates/chimera-cli/ `chimera render …`: headless renders to WAV (replaces tools/render-*.ts) + calibration bins
src/engine/ (TS) thin typed facade over the wasm module: same names/types the UI imports today
```
Module tree of `chimera` mirrors `src/engine` 1:1 (`airway::network`, `anatomy::human`, …), so the
port can be reviewed file against file. `chimera::engine::Engine` is the old processor.
## Real-time contract (what JDAW needs)
- `Engine`, `HumanVoice` and `CreatureVoice` are `Send` and share no mutable state, so each can
render on its own thread.
- **Steady-state rendering allocates nothing.** That covers `render(&mut [f32])`, control steps,
geometry frames, noise-source lists, beat-sequencer gesture builds and telemetry snapshots,
which all reuse buffers held in their structs.
- **Bounded state with defined overflow.** The gesture score holds at most
`GestureScore::CAPACITY` (16 384) gestures. A gesture added past that is dropped and counted
(the TS score had no bound). Each `schedule` command takes at most 4 000 gestures, as the TS
worklet did. Pruning compacts in place and keeps insertion order, which matters because
blends sum in that order.
- **Commands that allocate.** These are meant for when nothing is sounding, or for a host that
builds things off the audio thread:
- a body switch (it builds a new airway network);
- a creature switch;
- the first `Engine::new` (it also loads the phonetics tables, so nothing initialises lazily
on the audio thread later).
Scheduling, releases, artic/source/gate changes and beat edits don't allocate.
- **Planners allocate** (text → gestures) and belong on a non-real-time thread: the main thread
in the browser, an edit thread in JDAW. `plan_note_on` for live sung notes is small but
allocates too; making it allocation-free is left for the JDAW integration.
- **Block-size independence.** `Engine::render` advances in fixed 128-sample sub-blocks, and the
per-block work (beat-sequencer ticks) happens at those sub-block boundaries, which sit at
absolute sample positions. The same commands at the same absolute sample positions therefore
give the same samples whatever the host's block size. The TS worklet ticked once per host
block, so a 48 000-sample offline block would have lost beats. Commands apply at the start of
the next `render` call; a host that needs sample accuracy splits its block at the event.
- **Events out.** Responses go into a queue that the host drains after `handle`/`render`:
`Ready { body, creature, n_ph, n_or }` after a voice switch, and `Planned { id, t0, end }` for a
schedule that carries an id (the UI's timelines use `t0`).
- **Telemetry.** A snapshot is fixed-size and written into caller-owned buffers. Interval
measures (RMS levels, the glottal scope's cycle capture) reset on each read; the struct docs
mark which fields are which. Load measurement and message cadence stay in JS.
- **The human clock stops while a creature plays**, as before.
## Determinism
- **Repeatability (promised).** A given build renders the same samples every time, for any
block size.
- **Cross-target identity (tested where possible).** All transcendental math goes through
`chimera::math`, which wraps the pure-Rust `libm` crate at a pinned version. `sqrt` and the
basic operations are IEEE-exact, and Rust never contracts to FMA on its own. A test renders
fixed scenarios natively and through the wasm build and requires identical samples on
x86-64 Linux. aarch64/macOS is untested here, so identity there is a goal, not a claim.
- **JS semantics.** `Math.min`/`Math.max` become `js_min`/`js_max`, which propagate NaN from
either argument and order −0 below +0. Ternaries are ported as written. `Math.round` becomes
`js_round`, which rounds halves up. The PRNG uses explicit wrapping `u32` arithmetic.
- **Precision.** State and gesture values are `f64` throughout; only output and telemetry are
`f32`, as the TS `Float32Array`s were.
- **No FTZ/DAZ.** Hosts must not enable flush-to-zero or denormals-are-zero, because those
change the samples. The engine already flushes silent state itself.
## Port fidelity
The port is literal. It keeps the same equations, the same operation order, the same constants,
the same PRNG seeds, and the same container order where order decides random-number
consumption or sums (noise sources, gestures). It retunes nothing. The checks:
1. **Golden comparison against the TS engine, before the TS engine is deleted.** Each
`tools/golden/.ts` runs the TS engine on fixed scenarios and writes fixtures, and the
Rust tests compare against them.
- Numeric outputs (geometry, area functions, plans, G2P, syllables) must agree to 1e-9
relative, with an absolute floor near zero. Plans and G2P must agree exactly in structure.
- Audio is compared on scenarios with events throughout (artic changes, releases, cancels,
calls, body switches), not just at the start. Renders must agree sample for sample (≲1e-6
of peak) until a discrete decision flips on a last-ulp difference between V8's libm and
Rust's. After that, every 10 ms window's RMS must agree to 0.05 dB, and onset/release
timing, silence, pitch and closures are covered by the ported behavioural tests. Any
divergence before an event is investigated, not tolerated.
- With the TS engine gone, the fixtures record what it did (`crates/chimera/tests/golden/README.md`).
The human-voice audio can be re-blessed from the Rust engine (`CHIMERA_BLESS=1`).
2. **The existing bun tests** (resonances against analytic values, FDTD against the chain
matrix, stability, formants, closures, singing behaviour) are ported to `cargo test` with
the same thresholds.
3. **The app's e2e smoke test** (`tools/e2e.ts`) runs against the wasm build and stays green.
4. **Benchmarks** measure native and wasm real-time factors against the TS numbers taken
before the port.
## The browser boundary
- **One wasm module.** The main thread compiles it once and passes the `WebAssembly.Module` to
the worklet in `processorOptions` (structured clone). Each side instantiates it
synchronously. The import object is empty: no WASI and no JS glue. A build check asserts the
artifact has no imports.
- **Startup ordering.** The wasm module must be ready before any UI module that calls into it
runs, because `ui/singing.ts` builds syllables when it loads. `main.tsx` awaits the module,
then imports the app. A failure shows a retryable error screen.
- **Audio thread (worklet).** The exports are C ABI with numbers and pointers only, over one
engine handle.
- Engine-owned buffers have a stable address for the engine's lifetime: an output buffer of
up to 4 096 frames, a command scratch area, and the telemetry block. JS re-creates its
typed-array views whenever `memory.buffer` has changed, and copies telemetry before the
next call.
- Gestures cross as fixed 256-byte little-endian records, layout version 1, specified in
`crates/chimera-wasm/src/lib.rs` and mirrored by `src/engine/wire.ts`:
- eight `f64`s: t0, t1, rampIn, rampOut, strength, speed, releaseHold, tag;
- 22 `f64` targets in `ControlKey` order;
- a u8 label length and 24 bytes of UTF-8;
- padding.
NaN marks an absent optional or target. Labels are cut at a character boundary.
- The worklet scope has no `TextEncoder`/`TextDecoder`, so a tiny UTF-8 helper handles
labels and ids.
- **Main thread.** Rich, non-real-time calls are JSON in and out through `serde_json`:
- planning, G2P and syllables;
- the anatomy model: frame, transfer function and formants, the `P`/`unP` template maps,
scale, tube node positions, and branch → drawing position;
- the creature, beatbox and vowel catalogues.
The TS facade gives the UI the types it uses today. Creature drawing data stays in TS.
- **Faults.** The crate is built with `panic = "abort"`, so a panic in the worklet traps. The
processor then outputs silence for good and posts one error. The main thread may build a new
node, which restarts playback state: score, clocks and beat. There's no attempt at seamless
recovery.
## Build
- `bun run wasm` runs `cargo build -p chimera-wasm --release --target wasm32-unknown-unknown`
and copies the result to `src/wasm/chimera.wasm`, which Vite imports with `?url`. `dev`,
`build` and `test` run it first.
- `bun test` keeps the TS-side tests (the codec, the facade, and the e2e run against the built
app). `cargo test` holds the engine tests.
## What JDAW gets
`chimera` as a path or git dependency:
- **`Engine::new(sample_rate)`** with:
- commands as methods: `set_body`, `set_artic`, `set_source`, `set_gate`, `schedule`,
`release`, `stop_speech`, `set_beat_*`, `beat_hit`, `set_creature`, `set_creature_params`,
`play_call` and `stop_call`;
- `render(&mut [f32])`;
- `telemetry` / `creature_telemetry`.
- The voices directly: `HumanVoice`, `CreatureVoice`.
- The planners: `plan_phrases`, `plan_song`, `plan_note_on/off`, `lyrics_to_syllables`, and
`EnglishG2P` with a dictionary given as `&str`.
JDAW's eventual module trait (note-on/off with lyrics and expression read from the notes) is an
adapter over these, written when JDAW's module interface exists. Designing it now would be
speculative (JDAW principle: generality must earn its place).
## Tools
- `render-speech`, `render-text`, `render-song`, `render-beatbox`, `render-creature` and
`render-vowels` become `chimera render …`, which writes a WAV. The spectrogram PNG stays
a TS tool (`tools/spectrogram.ts in.wav out.png`, reusing `tools/lib`).
- `calibrate-vowels` and `calibrate-consonants` become `chimera calibrate vowels|consonants`,
because they regenerate committed data.
- `gen-syrinx-table` and `gen-ipa-nfc` stay TS, because neither drives the engine. The NFC data
comes from the JS engine's ICU.
- `speech-diagnostics` (nasal leakage, formant undershoot, bandwidths, pitch) becomes
`chimera diagnose`, because it's a measurement tool for future tuning.
- The one-off explorations are deleted: `proto-vowel`, `explore-syrinx`, `measure-closures`,
`measure-consonants` and `draw-anatomy`. Their findings are already in the code and the
commit history.
## Review
A design review (Codex, 2026-11-05) changed several points: block-size independence of the
beat sequencer, the bounded score and the list of allocating commands, latching faults instead
of rebuilding on the audio thread, determinism claimed only as tested, the event and telemetry
contract, the ABI layout, facade gaps and startup ordering.
It also suggested keeping G2P, planning and songs in TS for now. That suggestion was declined:
- the beat sequencer runs in the engine and needs most of the planner core (inventory, oral
gestures, vowel targets);
- JDAW's headless core (principle 4) has to sing lyrics on notes (principle 15);
- phonetics split across two languages would drift.
The worklet's per-block replay budget stays out of Rust, because commands apply at the next
render call and so it isn't needed.
## Status (what shipped)
**Crates**
- `crates/chimera`: the engine.
- `crates/chimera-wasm`: the browser ABI (about 1 MB of wasm, no imports).
- `crates/chimera-cli`: the `chimera` CLI.
**Browser**
- `src/wasm/` holds the facade and codec.
- `src/worklet/processor.ts` runs on the wasm engine.
- `src/engine/` (the TS engine) is gone.
**Verification**
- **Golden fixtures** from the TS engine (`crates/chimera/tests/golden/README.md`):
- human voice: within 1e-9 of peak over every render;
- creatures: within 2e-10, including the chaotic and self-oscillating modes;
- plans and beat sequences: bit-exact apart from 1-ulp `f0` values;
- G2P, IPA and syllables: exact on about 3 200 cases;
- geometry: within 8e-14 relative; analysis (transfer functions, formants, FFT, LPC): within
1e-10 relative (the UI's dB curve 9.7e-11, formants 1.1e-12).
- **The bun tests** that were about the engine are now `cargo test`s with the same thresholds.
The UI's live paths (Sing keys, worklet ordering, creature telemetry layout) run on the wasm
build.
- **Native and wasm** render bit-identical samples on x86-64.
- **Block size:** a full session (sequencer, body switch, held note) is bit-identical in blocks
of 1 to 48 000 samples.
- **Allocations:** counted through the audio-thread ABI, from idle into labelled speech, the
sequencer and a creature. There are none.
- **CLI renders** match the old TS tools to under 1e-6 of peak.
**Speed** (Ryzen 7 8745HS)
- Male voice at 2× oversampling, rendered offline: 46% of a core for TS in Chromium, 32% for
wasm in Chromium, 19–21% native.
- Engine load in the running app: human 40% → 29%, creatures roughly halved (seal 56% → 28%).
**Where it differs from the design above**
- **Commands are methods** applied at once, not a `Command` enum. The worklet's body/creature
pending queues and per-block replay budget are gone, since ordering is the call order.
- **Schedules return their clock offset**, and the worklet posts `ready`/`planned` itself, so
there is no event queue.
- **The FDTD step uses checked indexing.** `get_unchecked` gained nothing measurable, and it
relied on an invariant that safe callers could break.
- **Only the human-voice audio fixtures can be re-blessed.** The others record the TS engine.
- **Vowel calibration isn't bit-reproducible.** `chimera calibrate vowels` moves 7 posture
values by ±0.001–0.002: last-ulp math differences, amplified by Nelder–Mead on flat minima.
The committed table is kept, so the voice is unchanged.
**Open**
- **Determinism on aarch64/macOS** is untested.
- **Performance beyond TS parity.** Exactness with the TS engine ruled out reciprocal
multiplies and similar changes in the FDTD step. That's possible now, with a re-bless.
- **Real-time planning.** `plan_note_on` allocates; a JDAW live-input path will want a
non-allocating form.