A singing trainer lives or dies by latency. If the pitch line lags behind your voice, you start chasing the line instead of the note. Singler does all of its audio work in the browser, on phones too, and no audio ever leaves the device. So the pipeline has to be fast and stay out of the main thread's way.
Three threads, one job each
microphone → AudioWorklet (collect samples)
→ Web Worker (detect pitch, gate, smooth)
→ main thread (draw)The AudioWorklet runs on the audio thread. It does almost nothing: it copies samples into a buffer and, every few milliseconds, transfers that buffer away:
process(inputs: Float32Array[][]): boolean {
const channel = inputs[0]?.[0];
if (!channel) return true;
for (let i = 0; i < channel.length; i++) {
this.buf[this.n++] = channel[i];
if (this.n === this.hop) {
(this.out ?? this.port).postMessage({ samples: this.buf, time: currentTime }, [this.buf.buffer]);
this.buf = new Float32Array(this.hop);
this.n = 0;
}
}
return true;
}Note this.out: at startup, the main thread creates a MessageChannel and hands one end to the worklet and the other to a Web Worker. Samples then go from the worklet straight to the worker, so a busy main thread (a layout, a garbage collection, a slow phone) can't delay detection.
Pitch, then judgment
The worker runs the McLeod Pitch Method via pitchy (MIT, small, fast), which returns a frequency and a clarity between 0 and 1. That raw estimate still needs checking, because breath noise, a fridge and the reference tone leaking from the speaker all produce "pitches". Each frame has to pass three gates:
- Clarity above a threshold: is this periodic at all?
- Level above the noise floor: an adaptive floor tracks the room until the user calibrates their microphone, then the calibrated gate takes over.
- Range: is this a frequency a human voice produces?
Survivors are converted to MIDI values and smoothed with a short median, which removes octave jumps without the lag of a long average.
Holding is different from starting
While someone holds a note, their voice naturally gets softer at the end. If the level gate stayed fixed, the hold would "break" just before a good finish. So while sustaining, the gate relaxes by a few decibels. It's a one-line change that made the hold exercise feel fair.
Drawing at the speed of the screen
The worker posts about 90 frames a second; the canvas draws on requestAnimationFrame from whatever arrived since the last paint. Neither side ever waits for the other, so detection keeps its pace whatever the page is doing, and the main thread stays free for the rest of the UI.