Build a pitch detector that runs entirely in the browser
I wanted a pitch detector that opens instantly, asks for nothing but the microphone, and never sends audio to a server. It turns out the browser can do all of it. Here is the core of the approach, small enough to read in
I wanted a pitch detector that opens instantly, asks for nothing but the microphone, and never sends audio to a server. It turns out the browser can do all of it. Here is the core of the approach, small enough to read in one sitting.
1. Get clean microphone input
Browsers "help" voice calls by cleaning up the mic signal. For pitch detection that help gets in the way, so turn it off:
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
Ask for the mic only after the user taps a button. iOS Safari also needs the AudioContext to be created or resumed inside that tap.
2. Feed it into an AnalyserNode
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
Nothing is connected to ctx.destination, so you don't hear yourself, and nothing leaves the page.
3. Find the pitch
A plain FFT peak is a poor pitch detector: for many voices and instruments the loudest peak is a harmonic, not the note you hear. Time-domain methods like YIN or the McLeod Pitch Method handle this much better. The small pitchy package implements McLeod:
import { PitchDetector } from "pitchy";
const detector = PitchDetector.forFloat32Array(analyser.fftSize);
const input = new Float32Array(detector.inputLength);
function update() {
analyser.getFloatTimeDomainData(input);
const [pitch, clarity] = detector.findPitch(input, ctx.sampleRate);
if (clarity > 0.9) render(pitch); // ignore noisy, unclear frames
requestAnimationFrame(update);
}
update();
The clarity threshold matters more than anything else for a steady display. Below about 0.9 you mostly get breath, room noise and the attack of a note.
4. Turn Hz into a note and cents
With A4 = 440 Hz as MIDI note 69, every semitone is a factor of 2^(1/12):
const NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
function toNote(freq, a4 = 440) {
const n = 12 * Math.log2(freq / a4) + 69;
const midi = Math.round(n);
return {
name: NAMES[midi % 12],
octave: Math.floor(midi / 12) - 1,
cents: Math.round((n - midi) * 100), // -50 to +50
};
}
toNote(446) gives A4 at +23 cents. Making a4 a setting lets people tune to 432 or 442 Hz.
5. Small things that make it feel good
- Smooth the cents value over a few frames, or the needle jitters.
- Hold the last note for half a second after the signal drops, so the reading doesn't vanish between notes.
- Show a clear message when mic permission is denied, with how to re-enable it per browser.
- Start the volume and sensitivity at sensible defaults; most people never open settings.
That's the whole pipeline: mic, analyser, a time-domain pitch method, and some music math. You can try a finished version with a tuner-style needle and a vocal range mode on Gizmetto's pitch detector. It runs fully in the browser, so the audio never leaves your device.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.