DEV Community

xiao lu
xiao lu

Posted on

Real-Time Pitch Detection in the Browser with YIN and the Web Audio API

I built PitchTester, a free online pitch detector. You open the page, allow microphone access, and sing or play an instrument — it shows the note name, frequency in Hz, and how sharp or flat you are in cents, all in real time.

The entire thing is one vanilla JavaScript file. No backend, no WebAssembly, no dependencies. Here's how the audio math works, and how I kept it accurate enough to actually be useful.

The pipeline

Getting from microphone to a note name is four steps:

  1. CapturegetUserMediaAudioContextAnalyserNode
  2. Detect pitch — YIN autocorrelation on the time-domain buffer
  3. Map to a note — twelve-tone frequency → note name + cents
  4. Constrain by voice range — filter octave mis-reads

Step 1: Capture

The Web Audio API makes the plumbing nearly free:

function getAudioCtx() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (audioCtx.state === 'suspended') audioCtx.resume();
  return audioCtx;
}
Enter fullscreen mode Exit fullscreen mode

An AnalyserNode gives us the raw time-domain samples we need for autocorrelation. No FFT required for pitch detection — that's a common misconception. The YIN algorithm works directly on the time-domain buffer.

Step 2: YIN pitch detection

YIN (the "You In" pitch detection algorithm, de Cheveigné & Kawahara 2002) is the workhorse here. The core idea: find the smallest period at which the signal correlates with itself. That period is the fundamental frequency's wavelength.

First, compute the difference function — for each candidate period τ, sum the squared difference between the signal and itself shifted by τ:

const diff = new Float32Array(tauMax + 1);
for (let tau = 1; tau <= tauMax; tau++) {
  let sum = 0;
  for (let i = 0; i < buffer.length - tau; i++) {
    const d = buffer[i] - buffer[i + tau];
    sum += d * d;
  }
  diff[tau] = sum;
}
Enter fullscreen mode Exit fullscreen mode

At the true period, the signal aligns with itself, so the difference is at a minimum. But raw difference values aren't comparable across pitches, so YIN normalizes with the cumulative mean normalized difference (CMND):

const cmnd = new Float32Array(tauMax + 1);
cmnd[0] = 1;
let running = 0;
for (let tau = 1; tau <= tauMax; tau++) {
  running += diff[tau];
  cmnd[tau] = running === 0 ? 1 : (diff[tau] * tau) / running;
}
Enter fullscreen mode Exit fullscreen mode

Then find the first τ where the CMND drops below a threshold (0.15 in my implementation). That's the estimated period:

let tauEst = -1;
for (let tau = tauMin; tau <= tauMax; tau++) {
  if (cmnd[tau] < threshold) {
    while (tau + 1 <= tauMax && cmnd[tau + 1] < cmnd[tau]) tau++;
    tauEst = tau;
    break;
  }
}
// Fallback: global min
if (tauEst === -1) {
  let minV = Infinity;
  for (let tau = tauMin; tau <= tauMax; tau++) {
    if (cmnd[tau] < minV) { minV = cmnd[tau]; tauEst = tau; }
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, parabolic interpolation around the minimum gives sub-sample accuracy — this is what takes the frequency estimate from "close" to "in tune":

if (tauEst > 0 && tauEst < tauMax) {
  const s0 = cmnd[tauEst - 1], s1 = cmnd[tauEst], s2 = cmnd[tauEst + 1];
  const denom = s0 - 2 * s1 + s2;
  if (denom !== 0) {
    const shift = (s0 - s2) / (2 * denom);
    bestTau = tauEst + Math.max(-1, Math.min(1, shift));
  }
}
const freq = sampleRate / bestTau;
Enter fullscreen mode Exit fullscreen mode

Step 3: Frequency → note name + cents

Twelve-tone mapping is straightforward math. Semitones from A4, rounded to the nearest note, and cents as the residual:

function freqToNote(f, a4) {
  const semis = 12 * Math.log2(f / a4);
  const rounded = Math.round(semis);
  const nearestFreq = a4 * Math.pow(2, rounded / 12);
  let cents = Math.round(1200 * Math.log2(f / nearestFreq));
  if (cents > 50) cents -= 100;
  if (cents < -50) cents += 100;
  const idx = ((rounded + 9) % 12 + 12) % 12; // A is 9 semitones above C
  const octave = Math.floor((rounded + 9) / 12) + 4; // A4 -> octave 4
  return { name: NOTE_NAMES[idx] + octave, cents: cents };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: The voice-range constraint (the trick that matters)

Raw YIN is accurate for monophonic audio, but it has a classic failure mode: octave errors. Sing a low A and it might report A5 — the algorithm latched onto a harmonic instead of the fundamental.

The fix is domain-specific and cheap: let the user pick their voice range, and clamp detection to that octave band:

const VOICE_RANGES = {
  soprano:  [175, 880],   // F3–A5
  mezzo:    [147, 784],   // D3–G5
  alto:     [123, 659],   // B2–E5
  tenor:    [110, 494],   // A2–B4
  baritone: [82, 392],    // E2–G4
  bass:     [65, 330]     // C2–E4
};
Enter fullscreen mode Exit fullscreen mode

The YIN search range (tauMin/tauMax) is derived from these bounds, so the detector simply can't lock onto a harmonic outside the singer's range. This one feature eliminated the most common "wrong note" complaint.

Bonus: key detection with Krumhansl-Schmuckler

While I was at it, the same engine powers a song key finder. It builds a pitch-class histogram from detected notes, then correlates it against the Krumhansl-Schmuckler major and minor key profiles:

const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17];
Enter fullscreen mode Exit fullscreen mode

For each of the 12 roots × 2 modes, rotate the profile and compute Pearson correlation against the observed histogram. The best match wins:

function detectKey(histogram) {
  const results = [];
  for (let root = 0; root < 12; root++) {
    const major = KS_MAJOR.map((_, i) => KS_MAJOR[(i - root + 12) % 12]);
    const minor = KS_MINOR.map((_, i) => KS_MINOR[(i - root + 12) % 12]);
    results.push({ key: KEY_NAMES[root], type: 'major', corr: pearson(histogram, major) });
    results.push({ key: KEY_NAMES[root], type: 'minor', corr: pearson(histogram, minor) });
  }
  results.sort((a, b) => b.corr - a.corr);
  const best = results[0], alt = results[1];
  return {
    key: best.key + (best.type === 'minor' ? 'm' : ''),
    alt: alt.key + (alt.type === 'minor' ? 'm' : ''),
    confidence: Math.max(0, best.corr - alt.corr)
  };
}
Enter fullscreen mode Exit fullscreen mode

The confidence (gap between best and second-best correlation) tells the user how sure the detector is — a common real-world pitfall is that a song with mostly C and G notes is ambiguous between C major and G major.

Key takeaways

  1. YIN beats FFT for monophonic pitch detection. It's simpler to implement correctly, more accurate at the edges, and needs no windowing tricks.
  2. Domain constraints beat generic algorithms. The voice-range filter is the difference between a demo and a usable tool.
  3. Sub-sample interpolation is what makes it "in tune". Without parabolic interpolation, the cents reading jumps ±10 cents randomly.
  4. Everything runs in the browser, privately. No audio ever leaves the device — which is both a privacy win and an infrastructure win (zero servers, zero cost).

The live tool is at pitchtester.com — try singing into it and see how close you are to perfect pitch. The full engine is a single dependency-free JS file.

Have you built anything with the Web Audio API? I'd love to hear how you handled pitch accuracy in the comments.

Top comments (0)