Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The web runtime

Five thread kinds, one wasm binary, and a set of constraints that shaped the architecture more than any design preference did.

For audio engineers: the patch graph, the modules and their types, compilation, the audition phrase, loudness, vetting, the features, and the live voices. 2:49 · chapters and transcript

The threads

ThreadHoldsRuns
MainUI, Web Audio graphmain.js, never in the audio or render data path
Engine workerWasmEngine (all of auracle-session)worker.js: pool fill, fits, refinement, workbench
Render workers ×NA wasm instance, nothing elsefarm.js: stateless (term, phrase) → φ at boot, (context, job) → child for a generation’s walks and ⚡, task → posterior for a refit
Face painterAn OffscreenCanvas for each face of a sound being played, nothing elselive-paint.js: each frame of what is drawn over those faces, answered with a picture of each, where main's own paint of them is over budget
AudioWorkletLivePolyThe instrument. Real-time

Main compiles the wasm binary once, spawns the render workers, and transfers one MessagePort per worker into the engine worker. After that main is out of the data path, and no audition buffer ever touches the UI thread.

No nested workers (Safari shipped those only in 16.4), no SharedArrayBuffer, no COOP/COEP headers, no build step, and no server change. Those constraints are why the topology is a star around the engine worker rather than a tree.

The face painter is main's own, outside that star, and holds no wasm, and it runs only where it pays. The face of a sound being played answers what is heard (the guide): each frame, main measures what is heard and decides what is drawn over each such face (each outline's points, color and glow, and the trail's fade). The glow is a blur, and a browser that draws a canvas as it is called makes it on the thread that asks: on the main thread, in Firefox at a Retina screen's scale on a fast laptop (an M3 Max), that was 7 ms of every frame, and more on a slower machine. Chromium only records the calls, and draws them after the frame's script. So main times its own canvas calls against the live faces' 2 ms a frame, first while the page is idle, before any sound: one frame of the largest face, painted on a canvas that is never shown. Over budget (Firefox), main starts the painter then, and from the first frame, for the session, the painter makes those calls and answers with a picture of each face, which main shows as it is, so a frame is shown one round trip after it is read. Inside it (Chromium), main makes them itself and a frame shows in the frame that read it, for as long as they measure inside as the faces are played (the median of the last ten frames); once they measure over, main starts the painter and hands it each face as it stands. One frame is with the painter at a time. What main times is the calls: where Chromium draws a canvas in software (on a machine whose GPU it doesn't use), that drawing after the script lands on the main thread too, and neither measurement sees it yet. Main paints the faces itself, with the same calls, where the painter cannot run.

The AudioWorklet’s hostile environment

An AudioWorklet has no fetch, no TextDecoder, no TextEncoder. wasm-bindgen’s glue needs all three.

So the worklet is assembled as a blob with the glue inlined behind a polyfill, and raw wasm bytes are transferred into it for a synchronous in-worklet compile.

The bytes specifically, not the module: a transferred WebAssembly.Module arrives as a silent messageerror in some engines. That is the kind of failure that costs a day: no exception, no log, just a worklet that never initializes.

Also, no wall clock on the audio thread. LivePoly uses a deterministic xorshift for the random arpeggiator pattern; anything Date.now()-shaped belongs on the main thread.

LivePoly holds NN compiled copies of the patch, via the same compile() path evolution uses and with the limiter included, plus oldest-note stealing and silent-tail voice parking. Every workbench edit re-patches the live instrument.

The stack size

wasm32’s default stack is 1 MB, and the patch compiler is recursive: every level of Compiler::build constructs quiver modules by value before moving them into the patch, and some carry large inline buffers. A PitchShifter holds [f64; 4800] (38 KB), a Granular more.

A dozen-module patch overflows it, and it does so as memory access out of bounds, nowhere near the flag that caused it. It then poisons the engine: the panic unwinds out of a &mut self binding, and every later call fails with wasm-bindgen’s “recursive use of an object” instead of the real fault.

The fix is 8 MB, the same order as the native main-thread stack the test suite runs on, which is why make check never saw this:

WASM_STACK := 8388608
WASM_RUSTFLAGS := RUSTFLAGS="-C link-arg=-zstack-size=$(WASM_STACK)"

It lives in the Makefile, and every build path goes through it: CI, releases, the site build. Invoking wasm-pack directly ships a 1 MB stack and reintroduces the bug, which is why the CI workflows build wasm via make wasm rather than calling the tool.

Progressive boot

Boot costs ~40 renders. The bank is standardized and posted as playable at 8 patches (PLAYABLE_AT, in worker.js), which is when the first pair is dealt. The remaining ~32 fill in chunks that yield to the message queue between batches, so playing during the fill is real rather than cosmetic.

Generated names are kept from the moment the bank is posted, and they don't depend on when that was. The farm folds results in batches, so playable can catch 8 sounds on one run and more on the next; each sound is named from the bank as it stood when it arrived, and the first eight from each other (NAME_FLOOR, in naming.rs, which must not exceed PLAYABLE_AT). A seed names its pool the same at any handover and any farm width (a_seed_names_its_pool_however_the_bank_was_handed_over).

Pairs are dealt at once, from however many sounds have arrived, so a pick never waits for more to arrive. That depends on the machine's speed, so a seed in the address (?seed=) deals them the way it names its pool. In a seeded session whose pool fills at boot, the kk-th deal draws only from the first 8(k+1)8(k+1) sounds of the pool (8, 16, 24, 32), in the order the seed's fill folds them in, whether or not the fill has finished by then, and every later deal from the whole pool (DealSchedule, in auracle-session). A deal whose sounds have not all arrived waits for them in the worker (dealsWaiting), and every other request is served meanwhile. A deal used to draw over however many sounds had arrived, so a seed dealt other pairs on a faster machine; now it deals the same pairs at any speed and farm width. An ordinary session has no schedule: the wait would cost a fast picker seconds on a slow machine, and its pairs are shared with no one. A session restored with its pool full has no fill and no schedule: every deal draws from the whole pool. In every session a uniform pair is drawn over every sound its schedule names (or every sound that has arrived), cut ones included, and one holding a cut sound is drawn again as the next deal, so a cut moves no pair but one holding the sound cut.

filled still fires, and everything downstream of it still runs. fill_progress carries stage/stages, so a restore and a top-up fill each own a labeled share of one bar.

The render farm

N=clamp(hardwareConcurrency−2,  0,  6)N = \mathrm{clamp}(\text{hardwareConcurrency} - 2,; 0,; 6)

capped at 2 when deviceMemory ≤ 4, and a width of 1 is taken as 0: below two workers the serial path is as fast (farmWidth, in main.js). Override with ?farm=k or localStorage["auracle-renderers"], from 0 to 8; 0 is the serial path, which builds the same pool.

The pool is identical at every width, including 0

This is a structural guarantee, and two properties carry it:

Draws are indexed. Draw ii is the prior sampled under StdRng::seed_from_u64(splitmix64(fill_seed, i)), so a term is a pure function of (fill_seed,i)(\text{fill_seed}, i). Not of arrival order, not of which worker got it.

Results are absorbed in index order. The pool at index ii depends only on indices <i< i.

Together those mean a lost or timed-out job is re-issued by index with no retained state, and speculative work past the stop point is simply discarded.

Gated natively by farm_width_does_not_change_the_pool and farm_absorption_reproduces_the_serial_pool, on (id, tree, raw φ).

Every degradation path falls back to the serial fill of the same draw stream: a worker that never initializes, one killed mid-boot, a build-stamp mismatch, and a browser that cannot structured-clone a WebAssembly.Module. So parallelism costs time and never content.

Boot waits FARM_HANDSHAKE_MS (5 s, in worker.js) for a farm worker to report ready. When none has, a crew with a worker still starting is kept: the fill (or a restore) begins in the engine worker, asks before each batch (each entry) whether a worker has reported ready since, and hands it the rest from there. A slow machine instantiating the engine in several workers at once can miss the window, and before this its crew sat out the whole boot. The two paths fold the same stream from the same cursor, so the pool is the same whichever took which part. A farm run that ended because every ready worker died leaves the draws it was handed and did not fold in issued (the engine's issue cursor past its fill cursor); the engine worker's batches fold some, and the next farm run hands the rest out again first, by index (draw_json), before it draws new ones.

A draw retired after two attempts (MAX_TRIES, in worker.js) is recorded, not hidden, but in the app’s own log (window.__aurLog) rather than as a console warning. It is a designed degradation, and the console gate holds a clean boot to zero warnings.

Walks on the farm

A generation is refine_seeds walks, and each is a pure function of the generation’s shared context and its own job (refinement). So after boot the farm comes back for them: the engine worker asks main for a crew when a generation or ⚡ evolve from this starts, main spawns it, and the crew is reaped after a minute with nothing to walk. Main compiles the wasm module once and keeps it, so a crew is an instantiation per worker, not a compile: where boot had a farm (four cores or more) the module was compiled then, and on a two- or three-core machine, where boot fills serially, the first crew compiles it. A browser that cannot hand a compiled module to a worker has each worker compile its own. A crew’s width is boot’s rule with a floor of one worker wherever there are two cores (walkWidth, in main.js), because even one worker takes the walk off the engine worker, which then answers everything else.

The context (the tilted prior, the posterior’s draws, the standardizer, the phrase: about 2.2 MB of JSON) goes to each worker once per generation, as one string, and farm_walk keeps its parse keyed by that exact text. Results are absorbed in job order, one per turn, whatever order they finished in, so the pool is the serial path’s at every width; natively a_generation_absorbed_in_any_completion_order_is_the_serial_one, and across the farm’s wire farm_walks_are_the_engines_walks_absorbed_in_job_order. A walk a worker cannot run, or a crew that never comes up, is walked in the engine worker from the engine’s own copy of the same job.

⚡ evolve from this is one job over the same path. It draws its job from the refine stream before it waits for a crew, so a generation asked for during a cold crew’s handshake cannot draw first and change the child: a seeded session breeds the same ⚡ child however warm the crew was. With no crew the engine walks that very job (refine_from_walk). A generation and ⚡ take turns in the engine worker, and a refit waits for both, so a ⚡ child is never absorbed into a generation at whatever job count its walk finished on. The engine keeps a ⚡ seed out of every eviction until its walk is absorbed or stopped.

The refit on the farm

A refit is one MCMC call, about a second on a fast laptop and about four on the reference machine for a mature log. The engine worker does only its two ends (fit_export, then fit_install), and a render worker the MCMC (farm_fit), handed out as a walk is. The export is the evidence as the fit reads it, Engine::fit_job: the log standardized on the scale it is fitted on (the standardizer refitted over the pool and the log, not yet adopted), the model's configuration, the chain's length, the last fit's lenses, and the seed of the generator the engine's own fit would use (the session seed and the log's length). The render worker aligns the new lenses to those (a search over every permutation, for every draw) and sends the draws back as bytes. The install adopts the scale and folds in the observations recorded since the export, as a pick between fits is. With nothing between them the three are the engine's fit, draw for draw: fit_posterior is the three in a row, and a_fit_on_the_farm_installs_the_draws_fit_makes holds it across the wire. Until the fit lands the engine answers with the posterior it has, so a deal asked for meanwhile is dealt under it. One fit is out at a time, a generation and ⚡ wait for it, and with no crew the engine fits the same export itself, after the player's requests.

A saved session keeps the posterior's draws and weights (SessionState::fit), so a restore installs them and fits nothing, when the log and the standardizer come back exactly as saved.

Replacement waits for the end: children join the pool as they are absorbed, and refine_finish retires the weakest members not kept (saved, or kept as new and not yet in a pick) once, when the last job lands or the player stops the generation. Weakest is judged under the posterior the generation opened with, as admission is, so picks made while it breeds (they reweight the posterior for the next pair) do not change which children are kept.

Worker replies are load-bearing

Every workbench edit message must get a reply (bench or edit_rejected), or the main thread’s in-flight queue deadlocks.

bench_missing is the sharpest case. The worker has always sent it when edit_begin fails, and because nothing handled it, the optimistic “it’s on the workbench” toast stayed on screen while the bench showed the previous patch. A protocol whose failure message has no listener is a protocol with a silent failure mode.

The general rule: a control that cannot act says so. The recurring bug is silence: a ▶ with no handler, an if (x == null) return, a worker failure nothing listened for. Prefer a disabled control with a reason in its title, or a note; never a handler that returns.

Caching, in development

The dev server sends Cache-Control: no-store and the app version-stamps its worker and wasm URLs. Both are needed: a browser’s heuristic cache ignores late no-store on an already-cached module worker.

Getting this wrong gives a rebuild that appears to change nothing, or an engine and a UI from two different commits.

Verification beyond make check

UI changes are verified live in a browser (Playwright) with numeric audio assertions (an AnalyserNode RMS, boundary-sample checks around patch swaps) plus a zero-console-error requirement.

The engine worker's half of the messages has a level of its own: the worker-protocol tests (make worker-test, tests/worker/) run worker.js as it is, in Node over the built engine with no page, and hold what it answers and in what order (its lanes, a long job giving way, what reaches the farm).

Debug hooks: window.__aur and window.__aurLog. (window.__ric is kept as an alias for notes written before the rename.)

That combination is the only thing that can catch a class of bug make check cannot see: the engine is correct, the UI is correct, and the message between them is wrong.