If you have ever tried building a guitar tuner, voice pitch detector, or frequency analyzer using the browser's Web Audio API, your first instinct was probably:
- Capture microphone audio using
navigator.mediaDevices.getUserMedia(). - Connect the stream to an
AnalyserNode. - Call
getFloatFrequencyData(). - Pick the frequency bin with the highest magnitude.
This approach works reasonably well for pure sine waves and simple test tones. However, when you start analyzing human speech, singing, or musical instruments, it can produce misleading results.
Your detector might jump between octaves, report 440 Hz when the fundamental pitch is closer to 220 Hz, or fluctuate between neighboring frequencies.
The underlying problem is that the strongest frequency component is not necessarily the fundamental frequency.
In this article, we'll explore why a simple FFT peak detector can fail on harmonic audio and how to implement a zero-dependency fundamental frequency detector in TypeScript using normalized autocorrelation and parabolic interpolation.
Why Naive FFT Fails on Voice and Instruments
Sound produced by human vocal cords and acoustic instruments is generally harmonic rather than a single pure sine wave.
For example, when you produce a note with a fundamental frequency of 110 Hz (A2), the resulting signal can contain energy at:
- Fundamental: 110 Hz
- Second harmonic: 220 Hz
- Third harmonic: 330 Hz
- Fourth harmonic: 440 Hz
Depending on the instrument, vocal characteristics, microphone response, and recording environment, a higher harmonic can have greater amplitude than the fundamental.
An FFT peak detector simply selects the strongest frequency bin. Consequently, it might report 220 Hz or 330 Hz instead of the actual 110 Hz fundamental.
This is commonly associated with octave errors and harmonic misidentification.
The frequency resolution problem
FFT frequency resolution depends on the sample rate and FFT size.
The frequency spacing between adjacent bins is:
Frequency Resolution = Sample Rate / FFT Size
= 48,000 / 2048
= 23.44 Hz per bin
At lower frequencies, this spacing can be relatively coarse.
For example, when analyzing bass notes between 50 Hz and 150 Hz, a frequency difference of approximately 23 Hz can represent a substantial portion of the distance between musical notes.
Increasing FFT size improves frequency-bin spacing, but it also requires more samples and can increase the observation window and processing requirements.
This is why selecting the strongest FFT bin is not always the best approach for estimating musical pitch.
The Solution: Time-Domain Autocorrelation
Instead of asking:
"Which frequency has the most energy?"
Autocorrelation asks:
"If I shift this waveform by a certain number of samples, at what delay does it look most similar to itself?"
A periodic waveform tends to resemble itself after one complete period.
The delay, represented by τ (tau), corresponds to the waveform's period.
The fundamental frequency can then be estimated using:
Fundamental Frequency (f0) = Sample Rate / Lag (τ)
For example, if the sample rate is 48,000 Hz and the detected period is approximately 436 samples:
f0 = 48,000 / 436
f0 ≈ 110.09 Hz
Normalized cross-correlation
To reduce sensitivity to differences in signal amplitude, we can normalize the correlation using the energy of both signal segments.
The normalized correlation can be represented as:
corr[lag] =
sum(x[i] * x[i + lag])
/
sqrt(sum(x[i]^2) * sum(x[i + lag]^2))
A higher correlation value indicates greater similarity between the original signal and its delayed version.
This helps identify periodic signals, although harmonic ambiguity and background noise can still affect the result.
The Complete TypeScript Implementation
Let's implement the main components of a browser-based pitch detector.
The following examples use the Web Audio API and standard TypeScript functionality without requiring an external DSP library.
1. Capturing Raw Audio from the Browser
First, request microphone access and create an audio processing pipeline.
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
// Configure the time-domain buffer.
analyser.fftSize = 2048;
source.connect(analyser);
const timeData = new Float32Array(analyser.fftSize);
The getUserMedia() method requests microphone access.
The AudioContext provides the browser's audio-processing environment, while the AnalyserNode exposes time-domain and frequency-domain data.
Disabling echo cancellation, noise suppression, and automatic gain control can help preserve the microphone signal, although actual processing behavior can vary between devices and browsers.
The audio remains in the browser unless the application explicitly sends it elsewhere.
2. The Core detectPitch Function
Now we can implement a pitch-detection function using normalized autocorrelation.
/**
* Estimates fundamental frequency using normalized
* autocorrelation and parabolic peak refinement.
*
* Returns null for silence or signals without
* sufficiently strong periodicity.
*/
export function detectPitch(
buf: Float32Array,
sampleRate: number,
minHz = 50,
maxHz = 2000,
minRms = 0.01
): number | null {
const n = buf.length;
// 1. Reject silence using RMS amplitude.
let rms = 0;
for (let i = 0; i < n; i++) {
rms += (buf[i] ?? 0) ** 2;
}
rms = Math.sqrt(rms / n);
if (rms < minRms) return null;
// 2. Restrict the lag search to the desired
// frequency range.
const minLag = Math.max(
2,
Math.floor(sampleRate / maxHz)
);
const maxLag = Math.min(
n - 2,
Math.ceil(sampleRate / minHz)
);
if (maxLag <= minLag) return null;
// 3. Calculate normalized cross-correlation.
const corr = new Float32Array(maxLag + 2);
for (
let lag = minLag - 1;
lag <= maxLag + 1;
lag++
) {
let sum = 0;
let e1 = 0;
let e2 = 0;
for (let i = 0; i + lag < n; i++) {
const a = buf[i] ?? 0;
const b = buf[i + lag] ?? 0;
sum += a * b;
e1 += a * a;
e2 += b * b;
}
corr[lag] =
e1 && e2
? sum / Math.sqrt(e1 * e2)
: 0;
}
// 4. Find the strongest local correlation peak.
let best = -1;
let bestLag = -1;
for (let lag = minLag; lag <= maxLag; lag++) {
const c = corr[lag] ?? 0;
if (
c > (corr[lag - 1] ?? 0) &&
c >= (corr[lag + 1] ?? 0) &&
c > best
) {
best = c;
bestLag = lag;
}
}
// Reject weakly periodic signals.
if (bestLag < 0 || best < 0.6) {
return null;
}
// 5. Harmonic-selection heuristic.
// Select an earlier strong peak when it is
// sufficiently close to the strongest peak.
for (let lag = minLag; lag < bestLag; lag++) {
const c = corr[lag] ?? 0;
if (
c > (corr[lag - 1] ?? 0) &&
c >= (corr[lag + 1] ?? 0) &&
c >= best * 0.9
) {
bestLag = lag;
break;
}
}
// 6. Refine the peak using parabolic interpolation.
const y0 = corr[bestLag - 1] ?? 0;
const y1 = corr[bestLag] ?? 0;
const y2 = corr[bestLag + 1] ?? 0;
const denom = y0 - 2 * y1 + y2;
const shift = denom
? (0.5 * (y0 - y2)) / denom
: 0;
const refinedLag = bestLag + shift;
if (refinedLag <= 0) return null;
return sampleRate / refinedLag;
}
The function performs several operations:
- Calculates RMS amplitude to reject silence.
- Restricts the search to a specified frequency range.
- Calculates normalized correlation for candidate delays.
- Finds strong local correlation peaks.
- Applies a peak-selection heuristic to reduce some harmonic errors.
- Uses parabolic interpolation to refine the estimated lag.
One important limitation: choosing the earliest strong correlation peak is a heuristic, not a guarantee of identifying the true fundamental. More advanced approaches such as YIN or the YIN difference function can improve pitch tracking in difficult signals.
Converting Pitch (Hz) to Musical Notes and Cents
Once the detector estimates a frequency, we can convert it into a musical note.
For standard twelve-tone equal temperament, A4 is typically tuned to 440 Hz.
The MIDI note number can be estimated using:
MIDI Note = 69 + 12 × log2(frequency / 440)
Here is the TypeScript implementation:
const NOTE_NAMES = [
"C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"
] as const;
export function frequencyToNote(
hz: number,
a4 = 440
) {
if (!Number.isFinite(hz) || hz <= 0) {
return null;
}
const exact = 69 + 12 * Math.log2(hz / a4);
const midi = Math.round(exact);
const name =
NOTE_NAMES[((midi % 12) + 12) % 12] ?? "C";
const octave = Math.floor(midi / 12) - 1;
const targetHz =
a4 * Math.pow(2, (midi - 69) / 12);
const cents = Math.round(
1200 * Math.log2(hz / targetHz)
);
return {
name,
octave,
label: `${name}${octave}`,
cents,
targetHz: Math.round(targetHz * 100) / 100,
};
}
The function returns:
- Musical note name.
- Octave.
- Nearest MIDI note.
- Difference in cents.
- Target frequency for the nearest note.
For example, a frequency close to 440 Hz should produce A4 with approximately zero cents of deviation.
A positive cents value means the detected frequency is sharp relative to the nearest note. A negative value means it is flat.
The a4 parameter also makes it possible to support alternative tuning references such as 432 Hz and 442 Hz.
When Should You Still Use FFT?
Autocorrelation is useful for estimating the fundamental frequency of periodic signals, particularly voices and musical instruments.
However, it is not the best solution for every audio-analysis task.
For non-harmonic signals, high-frequency test tones, and general spectrum visualization, FFT-based analysis is often more appropriate.
For example, a spectrum peak detector can identify the strongest frequency component in a frequency-domain buffer.
export function peakFrequency(
spectrumDb: Float32Array,
sampleRate: number,
fftSize: number
): { hz: number; db: number } | null {
const binHz = sampleRate / fftSize;
let idx = -1;
let max = -Infinity;
for (let i = 1; i < spectrumDb.length - 1; i++) {
const v = spectrumDb[i] ?? -Infinity;
if (v > max) {
max = v;
idx = i;
}
}
if (idx < 0 || !Number.isFinite(max)) {
return null;
}
// Parabolic interpolation across neighboring bins.
const a = spectrumDb[idx - 1] ?? max;
const b = max;
const c = spectrumDb[idx + 1] ?? max;
const d = a - 2 * b + c;
const shift = d
? (0.5 * (a - c)) / d
: 0;
return {
hz: (idx + shift) * binHz,
db: max,
};
}
This approach estimates the strongest spectral peak with sub-bin interpolation.
It does not necessarily identify the fundamental frequency of a harmonic signal, but it is useful for general frequency-spectrum analysis.
The two approaches solve different problems:
| Method | Best use case |
|---|---|
| Autocorrelation | Periodic signals and fundamental pitch estimation |
| FFT peak detection | Dominant spectral frequencies |
| Parabolic interpolation | Refining a detected peak |
| RMS analysis | Measuring signal amplitude and rejecting silence |
A practical browser audio application can combine these techniques depending on the signal being analyzed.
Live Demo and Testing
Implementing a pitch detector is only one part of building a useful browser-based audio tool. Testing it with real microphone input is equally important.
You can explore a working implementation in the SoundLevelTest Microphone Frequency and Pitch Detector.
The browser-based tool lets users:
- Measure the frequency of sounds captured through a microphone.
- View detected pitch in Hertz.
- Identify the nearest musical note.
- Check whether a detected note is sharp or flat.
- Explore frequency-spectrum visualization.
- Test voice, instruments, whistles, and other tones.
It also demonstrates how microphone input, real-time audio analysis, and browser-based visualization can work together without requiring a separate audio-processing application.
Microphone permissions, browser processing, background noise, and device hardware can all influence the results. Pitch estimates should therefore be interpreted as approximate measurements rather than guaranteed laboratory-grade values.
Conclusion
Building a browser-based pitch detector requires more than finding the strongest FFT bin.
For harmonic audio signals, the fundamental frequency can be weaker than its harmonics, which makes simple peak detection unreliable in some situations.
Normalized autocorrelation provides another way to estimate pitch by examining the periodic structure of the waveform. Parabolic interpolation can further refine the estimated period between discrete sample positions.
FFT analysis still has an important role in identifying dominant spectral components and visualizing complex audio signals.
Combining these approaches provides a practical foundation for building real-time audio-analysis applications using TypeScript and the Web Audio API.
What approach do you use for pitch tracking in your Web Audio API projects?
Have you found autocorrelation, YIN, or FFT-based methods more effective for your particular applications? Share your experience and optimizations in the comments.
Top comments (0)