Porting a Studio Limiter into an AudioWorklet: WASM DSP at the 128-Sample Quantum, with a Design for Counting Underruns
A studio limiter is written for a world where you choose the block size. You pick 512 frames, or 1024, or whatever the host hands you, and the lookahead window, the release curve, and the true-peak detector are all designed around that number. The Web Audio render quantum does not negotiate. It hands you 128 frames per channel, calls process() synchronously on the audio rendering thread, and expects you to be done before the next callback. That is the whole contract, and it is enough to break a naive port.
This is a design brief for moving a limiter’s DSP into an AudioWorklet backed by WebAssembly. It is not a report of a finished port with measured underrun counts. The numbers below come from published sources and from one traced case in the wild; the instrumentation design is a proposal you can implement and verify on your own rig. I am treating the port as a target, not a trophy.
What the platform actually guarantees
MDN’s documentation for AudioWorkletProcessor.process() is precise about the block size and equally precise about the fact that you should not depend on it:
Currently, audio data blocks are always 128 frames long—that is, they contain 128 32-bit floating-point samples for each of the inputs’ channels. However, plans are already in place to revise the specification to allow the size of the audio blocks to be changed depending on circumstances.
The practical instruction that follows is: read channel.length, never hardcode 128. A limiter that assumes a fixed quantum will still work today and will silently misbehave the day the platform changes. The same page notes that inputs and outputs are guaranteed to share the same block length, which is the one assumption you can lean on.
Parameters have their own trap. If you declare a parameter without an automationRate, it defaults to "a-rate", meaning sample-accurate updates. MDN spells out the consequence for the parameters object:
If the automation rate of the parameter is “a-rate”, the array will contain 128 values — one for each frame in the current audio block. If there’s no automation happening during the time represented by the current block, the array may contain a single value that is constant for the entire block, instead of 128 identical values. If the automation rate is “k-rate”, the array will contain a single value, which is to be used for each of 128 frames.
So an a-rate parameter is not just a number. It is a per-callback allocation and copy of up to 128 floats, and the browser has to validate and marshal it before your code runs. A limiter with a dozen a-rate controls is paying that cost on every callback, whether the user is touching a slider or not.
The lifetime contract, and why a limiter has a tail
The boolean returned from process() is not decorative. It tells the user agent whether to keep the node alive when its own logic would otherwise shut it down. MDN describes the tail-time case directly:
A processor implementing such a node should return true from the process method for the period of the tail-time, beginning as soon as inputs are found that contain zero-channels.
A limiter with a lookahead delay line is exactly this kind of node. When the upstream source stops, the delay line still holds audio that has not been output yet. If you return false the moment the input goes silent, you truncate the tail. The correct behavior is to keep returning true until the delay line has drained, then return false. The latency this introduces is real and should be stated in milliseconds at the target sample rate, not hidden behind a vague “low latency” claim.
There is a harder edge to the contract. MDN warns that if process() throws an uncaught error, the node emits a processorerror event and outputs silence for the rest of its lifetime. A limiter that divides by a zero envelope, or indexes past a WASM heap boundary, does not glitch for one block. It goes permanently silent. That is a strong argument for defensive bounds checks at the JS/WASM boundary and for keeping the hot path free of anything that can throw.
Why WASM, and what the boundary costs
MDN’s AudioWorklet guide is blunt about the motivation: audio processing can involve substantial computation, and a processor “may benefit greatly from being built using WebAssembly, which brings near-native or fully native performance to web apps.” A limiter is a good candidate. Gain computation, envelope following, and true-peak interpolation are arithmetic-heavy and branch-light, which is where WASM does well.
The boundary is where the design gets interesting. WebAssembly memory is a contiguous range of untyped bytes, and in JavaScript a Memory instance behaves like a resizable ArrayBuffer. Pages are fixed at 64KB. To process a block, you copy each input channel’s Float32Array into linear memory, call the exported processing function, and copy the output back. At 128 frames per channel that copy is small, but it happens on every callback, and it is not free. The Emscripten Wasm Audio Worklets runtime is explicitly designed to avoid generating temporary JavaScript-level VM garbage so that GC pauses do not land in the audio callback. If you hand-roll the glue, you own that property: pre-allocate your views, reuse them, and never allocate inside process().
Emscripten’s documentation also clarifies the threading model. The callback runs “on a dedicated separate audio processing thread with real-time processing priority,” and each Web Audio context uses a single audio thread shared by all its nodes. The callback “should execute as quickly as possible and be non-blocking. In other words, spinning a custom for(;;) loop is not possible.” A Web Audio maintainer put the same point more sharply in a spec discussion: you can never block the callback, because doing so can demote the real-time audio thread from real-time priority to regular priority, “causing all sorts of glitches and problems.”
The performance pitfall that is easy to miss
The most useful published data point for this kind of port comes from Casey Primozic’s traced investigation of an AudioWorklet performance problem. The headline finding is not about DSP math at all. It is about parameter count:
In my case, I reduced it from 544 to 96 which got rid of my performance issue entirely.
The trace showed the parent AudioWorkletHandler::Process call dropping from an average of 0.055ms to 0.025ms, and AudioWorkletProcessor::Process from 0.040ms to 0.013ms. Total AudioDestination::RequestRender fell from 5.936ms to 2.311ms. The user-defined process() body had been a small fraction of the cost; the overhead was in validating and marshalling a large number of a-rate parameters on every callback.
That case involved 16 voices, so the per-callback budget was already tight. A single limiter node has more headroom. But the lesson scales: fixed per-callback overhead grows with the number of parameters, and a-rate parameters are the expensive default. A limiter’s control surface should be designed with that in mind.
Designing the limiter’s control surface
Here is the recommendation, stated as a design choice rather than a measured result. Expose a small number of parameters to the audio thread. Use k-rate for anything that does not need sample accuracy: threshold, ratio, release time, output ceiling. These arrive as a single value per block, which is exactly the resolution a slow-moving control needs. Reserve a-rate for the rare control that genuinely must be sample-accurate, and be honest with yourself about whether that is true.
For configuration that is not sample-accurate at all — loading a preset, switching the true-peak oversampling factor, changing the lookahead length — use postMessage to the worklet’s port and update state between blocks. That keeps the hot path cheap and keeps the parameter list short. The tradeoff is that such changes take effect at a block boundary rather than a sample boundary, which for a limiter preset is almost always acceptable.
Internal buffering is the other lever, and it is a latency trade, not a free win. A Web Audio maintainer described the pattern in the same spec discussion: accumulate 384 frames across three callbacks, then on the fourth callback run a 512-frame process, which “induces 3 blocks of latency.” For a limiter whose lookahead window is already a delay line, this can be a reasonable way to amortize fixed costs. But you must account for the added latency explicitly, in blocks and in milliseconds, and decide whether the application can afford it. Do not present it as a default.
Counting underruns honestly
This is the part most ports get wrong, because “underrun” is used loosely. Before you count anything, define what you are counting. There are at least three distinct events:
- Deadline miss: your
process()call took longer than the block duration (128 divided by sample rate) and the audio thread could not deliver in time. This is the one you can instrument yourself. - Processor error: an uncaught exception silenced the node. This surfaces as a
processorerrorevent on the node, not as a counter inside the worklet. - Output starvation: the graph produced silence because an upstream node had nothing to give. This is a graph-level condition, not a limiter bug.
Only the first is cleanly measurable from inside the audio thread, and even then the measurement is approximate. The proposal is this: in the worklet, read a monotonic clock at the top and bottom of process(), compare the elapsed time against the block budget, and increment a pre-allocated integer counter when the budget is exceeded. Do not allocate, do not format strings, do not call console.log in the audio thread. Periodically — every N blocks, or on a message from the main thread — post the counter value out via the port. The main thread owns the reporting, the timestamps, and the correlation with what the user was doing.
Label the result for what it is. A counter of deadline misses is not the same as an audible glitch count, and neither is the same as a dropped-frame count reported by the OS audio layer. If you publish numbers, say which definition produced them. The instrumentation design above is a proposal; it has not been validated against a reference measurement in this article, and you should treat your first run as a calibration exercise rather than a verdict.
Verifying the output, not just the timing
A limiter that never underruns but distorts is not a success. Loudness and true-peak measurement are the relevant checks, and the primary reference is ITU-R BS.1770, whose current in-force version is BS.1770-5 (11/2023), “Algorithms to measure audio programme loudness and true-peak audio level.” EBU R128 builds on that measurement framework for programme loudness normalization. If you claim your limiter meets a loudness target, you need to measure against BS.1770 rather than assert it. The same applies to true-peak: the standard defines the algorithm, and an oversampled peak detector in your WASM module should be checked against it, not against a peak meter that samples at the base rate.
For accessibility-adjacent work, EN 301 549 and WCAG 2.2 govern the user-facing side — captions, audio description, controls — not the limiter’s transfer curve. Keep those concerns separate. A limiter is an audio engineering component; compliance is a property of the whole delivery, measured end to end.
A checklist for the bench
- Read
channel.lengthin every callback. Do not assume 128, even though it is 128 today. - Declare
k-rateexplicitly for controls that do not need sample accuracy. Remember the default isa-rate. - Keep the parameter count small. The traced case above shows the cost is real and scales with count.
- Pre-allocate all WASM views and scratch buffers. No allocation inside
process(). - Return
truewhile the lookahead delay line drains; returnfalseonly when the tail is genuinely finished. - Wrap the WASM call so that no exception escapes
process(). A thrown error silences the node permanently. - Instrument deadline misses with an allocation-free counter and report via
postMessage. State your definition of underrun when you publish numbers. - Measure loudness and true-peak against BS.1770, not against a base-rate peak meter.
- Test with different
latencyHintvalues and note that block-size configurability is discussed in the spec but should not be assumed available.
Questions readers tend to ask
Can I just process 512 frames at a time inside the worklet?
You can buffer internally and process a larger block every fourth callback, but that adds latency. A Web Audio maintainer described exactly this pattern as inducing three blocks of latency. It is a legitimate trade for some workloads; it is not free, and it should be a stated design decision.
Why does my limiter go silent after one bad block?
Because an uncaught error in process() causes the node to emit processorerror and output silence for the rest of its lifetime. Catch everything at the boundary, validate indices, and guard against division by zero in the envelope follower.
Is the 128-frame quantum going to change?
The specification has discussed variable block sizes, and MDN notes that plans are in place to allow the size to change. That is a reason to read channel.length, not a reason to assume a new size is available today. Do not build a product around an unshipped feature.
Does WASM actually help for a limiter?
For arithmetic-heavy DSP, MDN states that WebAssembly can bring near-native performance. The gain depends on how much of your callback is DSP versus marshalling. If your parameter count is high, the overhead may dominate regardless of how fast the WASM is.
What this brief does not claim
It does not claim a specific studio limiter has been reproduced, that a particular underrun rate has been measured on named hardware, or that EBU R128 compliance has been achieved. Those require a measured implementation and a BS.1770 verification run. What it does claim is narrower and, I think, more useful: the platform constraints are documented, the parameter-cost pitfall is documented with a traced case, and the instrumentation can be designed so that when you do measure, the numbers mean something.