01 · Why this is harder than it looks
Every musician has used a phone tuner app. Building one in the browser sounds straightforward: grab the mic, run FFT, show the note. Ship it.
Then reality shows up.
Browser mic input comes with echo cancellation, noise suppression, and auto-gain control — all useful for video calls, all terrible for pitch detection. AGC in particular will quietly stretch your waveform and throw off frequency readings. Silence gets misread as signal. The gauge flickers between notes. On mobile, updating React state on every audio buffer kills frame rate.
I ran into all of this while building two free browser tools on vocalrangetest.org:
Pitch Detector — general-purpose note and frequency display with a live chart
Guitar Tuner — same detection core, plus string targeting, reference tones, and in-tune feedback
Both run 100% client-side. No server, no account, no audio uploaded anywhere.
This post walks through the architecture I landed on — what failed first, what actually worked, and how one pipeline powers both tools.
02 · The three-layer pipeline
The key decision was separating concerns early. Everything funnels through three layers:
Mic → Audio Graph → Engine → Display → UI
Engine — raw pitch detection. Takes a Float32Array buffer, returns { frequency, clarity } or null.
Display — maps Hz to note names and cents. Handles the "should I clear the gauge?" logic.
UI — React components that read display state at a throttled rate. Never on every audio callback.
This lives in a small factory:
export function createPitchDetectorPipeline(options?: PitchEngineOptions) {
const engine = createPitchEngine(options)
const display = createPitchDisplay()
function processFrame(timeData: Float32Array, sampleRate: number) {
const sample = engine.detect(timeData, sampleRate)
return display.process(sample)
} return { processFrame, reset }
}
The guitar tuner imports the same hook (usePitchDetectorListening) and the same PitchGauge component. The only difference is what happens above the pipeline — target string matching, reference audio playback, peg highlighting.
Build the core once. Specialize at the edges.
03 · Mic setup: turn off the "helpful" stuff
This was the single biggest accuracy win.
export const PITCH_DETECTOR_MIC_CONSTRAINTS: MediaStreamConstraints = {
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
}
With all three enabled (browser defaults), readings drift even when you hold a steady tone. With them off, the raw waveform stays intact and the detector behaves predictably.
I still fall back to { audio: true } if the strict constraints fail on some devices — better to work imperfectly than not at all.
04 · Wiring the audio graph
For pitch detection you need time-domain samples, not frequency bins. I use a ScriptProcessorNode with a 4096-sample buffer (~93 ms at 44.1 kHz):
export function connectPitchDetectorAudioGraph(context, stream) {
const source = context.createMediaStreamSource(stream)
const scriptProcessor = context.createScriptProcessor(4096, 1, 1)
const silentGain = context.createGain()
silentGain.gain.value = 0 // must connect to destination, but stay silent
source.connect(scriptProcessor)
scriptProcessor.connect(silentGain)
silentGain.connect(context.destination)
return { source, scriptProcessor, disconnect: /* ... */ }
}
Yes, ScriptProcessorNode is deprecated. AudioWorklet is the modern replacement. I went with ScriptProcessor first because it's simpler to ship and debug, and 4096-sample buffers at ~10 callbacks/sec is fine for a tuner. Worklet migration is on the list — the pipeline interface won't change when it happens.
Inside the callback, only one thing happens:
scriptProcessor.onaudioprocess = (event) => {
const channel = event.inputBuffer.getChannelData(0)
const state = pipeline.processFrame(channel, audioContext.sampleRate)
latestDisplayRef.current = state // ref only — no setState here
}
Audio thread writes to a ref. React state updates happen elsewhere.
05 · Pitch detection with pitchy
I use pitchy — a small autocorrelation-based detector. The engine wraps it with three gates before accepting a reading:
RMS threshold — ignore near-silence (0.004 worked well in testing)
Peak normalization — scale the buffer so quiet but valid input still registers
Clarity + Hz bounds — pitchy returns a clarity score; the guitar tuner rejects anything below 0.85, and both tools clamp to 50–2200 Hz
function detect(timeData: Float32Array, sampleRate: number) {
const rms = bufferRms(timeData, BUFFER_SIZE)
if (rms < RMS_THRESHOLD) return null
const input = normalizeForPitch(timeData, BUFFER_SIZE)
const [pitch, clarity] = detector.findPitch(input, sampleRate)
if (pitch <= 0 || clarity < clarityThreshold) return null
if (pitch < minHz || pitch > maxHz) return null
return { frequency: pitch, clarity }
}
The guitar tuner tightens bounds to roughly E2–E5 and raises the clarity bar. The pitch detector stays wider for voice and general use.
06 · Display logic: don't clear on every silence frame
Early versions cleared the gauge whenever a frame returned null. Result: constant flicker between "A4" and blank, even while holding a note.
The fix was a "hold last reading" display layer:
function process(sample) {
if (!sample) {
if (display.noteName != null) {
display = { ...display, phase: "held" }
}
return display
}
const note = mapFrequencyToNote(sample.frequency)
display = {
frequency: note.frequency,
noteName: note.scientificName, // e.g. "A4"
cents: note.cents, // deviation from nearest semitone
phase: "live",
}
return display
}
Three phases: idle (mic off), live (valid pitch right now), held (brief gap, keep showing last note). The gauge dial dims slightly in held mode so users know the reading is stale — but it doesn't vanish.
Note mapping is standard equal-temperament math against A4 = 440 Hz:
const midi = Math.round(12 * Math.log2(frequency / 440)) + 69
const cents = Math.floor(1200 * Math.log2(frequency / standardFrequency(midi)))
07 · Throttling UI updates (especially on mobile)
Even with the display layer sorted, pushing every frame into React state is expensive. The hook decouples detection from rendering:
Audio callback → writes to latestDisplayRef
requestAnimationFrame loop → reads the ref, calls setState at most every 48 ms (desktop) or 120 ms (mobile)
const tick = () => {
const now = performance.now()
if (state && now - lastGaugeUiRef.current >= gaugeUpdateMs) {
lastGaugeUiRef.current = now
flushGaugeUi(state)
}
uiRafRef.current = requestAnimationFrame(tick)
}
The pitch detector's live chart uses a similar throttle (~50 ms desktop, ~80 ms mobile) and caps history at 250 points.
On a mid-range Android phone, this was the difference between a smooth dial and visible jank.
08 · From pitch detector to guitar tuner
The pitch detector answers: what note am I playing?
The guitar tuner answers: am I close to the target string?
That extra logic sits entirely outside the shared pipeline.
Target matching compares detected frequency against each string's target Hz, with harmonic tolerance — plucking a low E sometimes registers at 2× or 3× the fundamental:
function noteMatchesAtThreshold(detectedHz, targetHz, thresholdCents) {
const fundamentalCents = toCents(detectedHz / targetHz)
if (Math.abs(fundamentalCents) <= thresholdCents) return true
// also check 2nd and 3rd harmonics
const h2 = toCents(detectedHz / (targetHz * 2))
const h3 = toCents(detectedHz / (targetHz * 3))
return Math.abs(h2) <= thresholdCents || Math.abs(h3) <= thresholdCents
}
Stable in-tune detection requires holding within ±5 cents for 250 ms before marking a peg green. Without that debounce, a passing reading would flash "in tune" and disappear.
Reference audio is a separate AudioContext path — preloaded MP3 samples per string, cached in a Map, played through a gain node with device-specific volume boost. Detection and playback don't share a graph, so playing a reference tone doesn't interfere with mic input.
The headstock UI, peg buttons, and string line overlays are guitar-specific. The gauge, mic toggle, gradient shell, and listening hook are shared.
09 · What I'd do differently next time
Area Current approach Next step
Audio processing
ScriptProcessorNode
Migrate to AudioWorklet
Harmonic matching
Hard-coded 2×/3× check
Configurable per instrument
Mobile latency
4096 buffer (~93 ms)
Test 2048 with clarity tradeoff
Testing
Manual + real instruments
Synthetic sine wave unit tests for engine
10 · Try it
Both tools are live if you want to see the pipeline in action:
Pitch Detector — sing, hum, or play anything; watch note + cents + history chart
Guitar Tuner — standard/drop/alternate tunings, reference tones, peg-level feedback
No install, no sign-up. Just allow mic access.
If you've built browser audio tools and hit different edge cases — iOS Safari quirks, Bluetooth mic latency, weird harmonic behavior on certain instruments — I'd genuinely like to hear about them. That kind of feedback is how these tools get better.

Top comments (0)