The web runtime
Five thread kinds, one wasm binary, and a set of constraints that shaped the architecture more than any design preference did.
The threads
| Thread | Holds | Runs |
|---|---|---|
| Main | UI, Web Audio graph | main.js, never in the audio or render data path |
| Engine worker | WasmEngine (all of auracle-session) | worker.js: pool fill, fits, refinement, workbench |
| Render workers ×N | A wasm instance, nothing else | farm.js: stateless (term, phrase) → φ at boot, (context, job) → child for a generation’s walks and ⚡, task → posterior for a refit |
| Face painter | An OffscreenCanvas for each face of a sound being played, nothing else | live-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 |
| AudioWorklet | LivePoly | The 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 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 -th deal draws only from the
first 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
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 is the prior sampled under
StdRng::seed_from_u64(splitmix64(fill_seed, i)), so a term is a pure
function of . Not of arrival order, not of which
worker got it.
Results are absorbed in index order. The pool at index depends only on indices .
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.