CH 05

— Bulletin

◀ Return to log

Bulletin 08 · 2024-12-05

Rust on the audio path

Rewriting a loudness meter's DSP in Rust and running it in the browser — same waveform in, same numbers out, and a garbage collector no longer invited to the mix.

Author
Alex Mercer
Published
2024-12-05
Reading time
2 min
Filed under
Systems
FIG.01 — R128 momentary loudness, JS and Rust builds superimposedConformance run

// Bulletin

The loudness meter started as TypeScript, and to be fair to it, the TypeScript was correct. It implemented R128 momentary and short-term loudness, passed the EBU conformance material, and drew a needle you could trust. It was also unusable, because correctness on the audio path is a latency budget, not a truth table — and roughly once a minute, the garbage collector took a bite out of a 2.6 millisecond deadline that does not tolerate bites.

You can fight that in JavaScript, and the ingest work I wrote up last summer fights it well: preallocate everything, reuse buffers, treat allocation as a bug. But audio has a property telemetry does not — the deadline is hard, the failure is audible, and the K-weighting filter chain is exactly the kind of tight numeric kernel that compilers love and JIT warmup ruins. This was the corner of the codebase where reaching for Rust stopped being fashion and started being engineering.

The shape that worked: the DSP is a Rust crate compiled to WASM, loaded inside an AudioWorklet. The worklet owns a pair of ring buffers in linear memory; the audio thread writes 128-sample blocks in, reads gated loudness values out, and neither operation allocates, locks, or crosses into JavaScript mid-block. The crate has no idea the browser exists — it is pure no_std arithmetic over slices — which means the same crate runs in the native ingest tool, and the browser meter and the rack meter can no longer disagree. One implementation, two deployments, zero drift.

The real design work was the boundary. Every gram of pain in a WASM integration lives at the FFI line, and the rule that kept it small: values cross, structures do not. The JS side never holds a Rust object; it holds an index into a buffer it was told about once. Serialization appears nowhere. If you find yourself encoding JSON to talk to your own WASM, the boundary is in the wrong place.

Was it worth it? The meter’s worst frame went from eleven milliseconds to under one, and the needle stopped flinching when the tab compiled a chart. But the honest summary is narrower than the headline: Rust did not make the product better, it made one hot loop boring. That is the job. Everything around the loop — the UI, the routing, the config — is still TypeScript, and should be.

// In this bulletin

  • — Why the JS meter was correct and unusable
  • — WASM in an AudioWorklet
  • — The FFI boundary is the design
Alex MercerSolid State · Colour Television
LIGHT
  • FB
  • X
  • LI
  • GH