
Sample Accuracy
To build a synthesizer or a DAW that doesn't click, you have to bypass the JavaScript event loop and master the high-precision constraints of the Audio Worklet thread.
If you try to trigger a fast arpeggio using setInterval, your music will eventually sound like a bag of marbles falling down stairs. In the world of the Web Audio API, "eventually" is usually about three seconds after you hit play. The JavaScript event loop is a masterpiece of engineering for UIs, but for high-performance audio, it’s a disaster. It is plagued by "jitter"—small, unpredictable fluctuations in timing caused by garbage collection, DOM reflows, or that heavy React component re-rendering in the background.
To build a professional synthesizer or a Digital Audio Workstation (DAW) in the browser, you need sample accuracy. This means the ability to schedule an event—a note on, a volume change, a filter sweep—at the exact sample it’s supposed to occur. If your sample rate is 44.1kHz, a single sample lasts about 0.022 milliseconds. The standard setTimeout can’t even guarantee 10ms precision.
The Event Loop is the Enemy
When you use context.currentTime to schedule a BufferSourceNode, you are using the Web Audio API’s internal clock. This is "scheduled" timing. It’s better than setTimeout, but it has a massive limitation: it’s one-way communication. You tell the API, "Play this sound at $T+500ms$," and it does. But what if the logic that decides *what* to play needs to react to the audio itself in real-time?
If you try to handle the timing logic on the main thread, you're at the mercy of the browser's busy schedule.
// DON'T DO THIS for high-precision sequencers
function playTick() {
const nextTickTime = lastTickTime + (60 / bpm / 4);
if (audioContext.currentTime >= nextTickTime) {
triggerOscillator(nextTickTime);
lastTickTime = nextTickTime;
}
requestAnimationFrame(playTick);
}The requestAnimationFrame callback fires roughly every 16.6ms. If the main thread hangs for even one frame, your playTick logic skips. Even if it doesn't skip, the time between the logic check and the actual sound trigger varies. This is jitter. In rhythmic music, jitter kills the "groove." In synthesis, jitter creates audible clicks and pops because the wave shapes don't line up.
The AudioWorklet: A Dedicated Thread
The AudioWorklet is the solution. It moves your audio processing into a separate, high-priority thread that runs independently of the main JS event loop. It’s a bare-bones environment. No window, no document, and definitely no setTimeout.
Inside an AudioWorkletProcessor, you operate on blocks of exactly 128 samples. This is the "quantum" of the Web Audio API.
// processor.js
class SampleAccurateProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const output = outputs[0];
const channel = output[0];
for (let i = 0; i < channel.length; i++) {
// channel.length is almost always 128
channel[i] = Math.random() * 2 - 1; // White noise
}
return true;
}
}
registerProcessor('sample-processor', SampleAccurateProcessor);While the code above is simple, it’s fundamentally different from the main thread. The process function is called by the audio engine precisely when the sound card needs the next 128 samples. If you don't provide them in time, the audio "underruns," and the user hears a nasty crackle. This thread is fragile; you cannot do heavy math or block it for even a microsecond.
Calculating the Exact Sample
To achieve sample accuracy, you have to stop thinking in milliseconds and start thinking in frames. A frame is a single snapshot of audio across all channels. At 44,100Hz, there are 44,100 frames per second.
Inside the worklet, you have access to currentFrame. This is a dead-accurate counter of how many samples have passed since the audio context started. If you want to trigger a sound exactly at the start of the next beat, you calculate that beat's position in frames.
Let’s say we want to build a simple metronome that clicks every 500ms (120 BPM).
// Inside the process() method of an AudioWorkletProcessor
process(inputs, outputs, parameters) {
const output = outputs[0];
const channel = output[0];
const sampleRate = 44100; // In reality, use sampleRate global
// Calculate how many samples should pass between clicks
const samplesPerBeat = sampleRate * 0.5;
for (let i = 0; i < 128; i++) {
const absoluteFrame = currentFrame + i;
if (absoluteFrame % samplesPerBeat < 1) {
// Trigger! We are at the exact sample where the beat starts.
this.isClicking = true;
this.clickCounter = 0;
}
if (this.isClicking) {
// Generate a simple square wave 'click' for 100 samples
channel[i] = this.clickCounter < 100 ? 0.5 : 0;
this.clickCounter++;
if (this.clickCounter > 100) this.isClicking = false;
} else {
channel[i] = 0;
}
}
return true;
}By checking currentFrame + i, we are inspecting the timing of every single individual sample within that 128-sample block. If the beat falls on sample 13 of the current block, we trigger the click right there. This is the essence of sample accuracy.
The 128-Sample Boundary Problem
The most common mistake I see when developers move to AudioWorklets is forgetting that an event might occur *inside* a block.
If you only check for events at the start of the process() function, you are effectively introducing a jitter of up to 128 samples (about 2.9ms at 44.1kHz). While 2.9ms sounds small, it's enough to cause phase cancellation if you’re layering two drum sounds. You must iterate through the 128 samples and check your logic for every single index i.
Handling Parameter Changes
The Web Audio API provides AudioParam, which allows you to automate values. These are also sample-accurate. In your worklet, parameters contains an array of values for each sample in the block if the parameter is being automated.
If a parameter is "a-rate" (audio rate), it provides 128 values. If it's "k-rate" (control rate), it provides 1 value for the whole block.
// processor.js
static get parameterDescriptors() {
return [{
name: 'gain',
defaultValue: 0.5,
minValue: 0,
maxValue: 1
}];
}
process(inputs, outputs, parameters) {
const output = outputs[0];
const gain = parameters.gain;
for (let i = 0; i < 128; i++) {
// If 'gain' is being automated via gain.linearRampToValueAtTime,
// gain.length will be 128. Otherwise, it's 1.
const currentGain = gain.length === 128 ? gain[i] : gain[0];
output[0][i] = inputs[0][0][i] * currentGain;
}
return true;
}By using the provided parameters array, you ensure that even if the gain is changing rapidly (like an envelope), the change is applied smoothly across the samples rather than stepping once every 128 samples.
Crossing the Thread Gap: Messaging
The Worklet lives in a silo. To make it do anything useful, the main thread needs to talk to it. You use port.postMessage(), but here’s the "gotcha": `postMessage` is asynchronous.
When you send a message from the UI to the Worklet, you don't know exactly when the Worklet will receive it. It might be in 2ms, or it might be in 10ms if the CPU is spiked.
To maintain sample accuracy, you cannot send a message saying "Start playing now." Instead, you must send a message saying "Start playing at frame X."
// Main Thread
myWorkletNode.port.postMessage({
type: 'TRIGGER',
time: audioContext.currentTime + 0.1 // Schedule 100ms in the future
});
// Processor Thread
this.port.onmessage = (event) => {
if (event.data.type === 'TRIGGER') {
// Convert the time (seconds) to a specific frame
this.triggerFrame = Math.round(event.data.time * sampleRate);
}
};
process(inputs, outputs, parameters) {
for (let i = 0; i < 128; i++) {
if (currentFrame + i === this.triggerFrame) {
this.startSynthesis();
}
}
}This approach combines the best of both worlds: the main thread handles the high-level scheduling (the "musical" time), and the Worklet handles the "execution" time with sample-level precision.
SharedArrayBuffer: The Pro Way
For high-performance apps like a DAW, postMessage might still be too slow because it involves serializing data and incurs overhead. This is where SharedArrayBuffer (SAB) comes in.
SAB allows the main thread and the Worklet thread to look at the same piece of memory. You can write MIDI events into a shared buffer on the main thread, and the Worklet can read them instantly without any message-passing overhead.
*Note: Using SAB requires specific HTTP headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy) due to Spectre/Meltdown security mitigations.*
// Main Thread
const sharedBuffer = new SharedArrayBuffer(1024);
const state = new Int32Array(sharedBuffer);
// Send the buffer once
myWorkletNode.port.postMessage({ sharedBuffer });
// Later, trigger a note by flipping a bit
Atomics.store(state, 0, 1);
// Processor Thread
this.port.onmessage = (e) => {
this.sharedState = new Int32Array(e.data.sharedBuffer);
};
process(inputs, outputs, parameters) {
// Check the shared memory every block
if (Atomics.load(this.sharedState, 0) === 1) {
this.startSynthesis();
Atomics.store(this.sharedState, 0, 0); // Reset
}
}Using Atomics ensures that reading and writing to the shared memory is thread-safe. This is the closest you can get to "bare metal" audio programming in the browser.
The "No Allocation" Rule
I’ve seen many developers wonder why their perfect Worklet starts clicking after 30 seconds. The culprit is almost always the Garbage Collector (GC).
In your process() loop, never allocate memory.
- No new Float32Array(128).
- No [1, 2, 3].map(...).
- No { note: 60 }.
Every time you create an object or an array inside process(), you’re leaving a piece of trash for the GC to clean up. Eventually, the GC will wake up to sweep that trash, and when it does, it might pause your Worklet for 2ms. That’s enough to miss your 128-sample window.
Pre-allocate everything in the constructor. If you need a buffer to store delayed samples for an echo effect, create it once and reuse it.
class DelayProcessor extends AudioWorkletProcessor {
constructor() {
super();
// Pre-allocate the buffer once
this.delayLine = new Float32Array(44100);
this.writeIndex = 0;
}
process(inputs, outputs) {
const input = inputs[0][0];
const output = outputs[0][0];
for (let i = 0; i < 128; i++) {
// Use the pre-allocated buffer
this.delayLine[this.writeIndex] = input[i];
// ... logic ...
this.writeIndex = (this.writeIndex + 1) % this.delayLine.length;
}
return true;
}
}Anti-Aliasing and Sub-sample Accuracy
For most synthesizers, sample accuracy is enough. But if you’re building an oscillator, even sample accuracy can fail you. If your wave cycle doesn't end exactly on a sample boundary (and it rarely does), you get aliasing.
Imagine a square wave. If the transition from -1 to 1 happens *between* sample 44 and 45, simply jumping the value at sample 45 creates a jagged edge that introduces high-frequency noise (aliasing).
True pro-grade audio engines use sub-sample accuracy. This involves linear interpolation or BLEP (Band-Limited Step) methods to smooth out those transitions that occur "between" the samples. This is a deep rabbit hole, but it’s the difference between a "toy" synth and a "studio" synth.
Why Bother?
It sounds like a lot of work. Why not just use OscillatorNode?
Because OscillatorNode is a black box. You can’t implement a custom physical modeling synth, a granular engine, or a complex FM algorithm with the built-in nodes alone. When you want to push the boundaries of what the web can do musically, you have to take control of the buffer.
The Audio Worklet is the most powerful tool in the Web Audio API arsenal. It’s the only way to ensure that your polyphonic synth doesn't choke when the user opens a new browser tab, and it’s the only way to ensure that your drum machine actually keeps a steady beat.
Summary Checklist for Sample Accuracy
1. Move to AudioWorklet: Get off the main thread immediately.
2. Use `currentFrame`: Track time using sample counts, not seconds.
3. Loop the 128: Always iterate through the sample block; never assume an event happens at the start of a block.
4. Schedule via Frames: When sending messages to the worklet, include a target currentFrame for execution.
5. Zero Allocation: Keep the process() loop pure. No new, no [], no {}.
6. Use SharedArrayBuffer: If you need ultra-low latency control and can handle the security headers.
Mastering these constraints is difficult. Browsers are not naturally built for real-time guarantees. But if you treat the AudioWorklet thread with the respect it deserves—keeping it lean, predictable, and memory-stable—you can build audio software that rivals native desktop applications.