Choose a waveform shape and drag the frequency slider. The top panel draws the wave; the bottom panel plots the autocorrelation function — the self-similarity of the signal at every lag. The lag of the first strong peak after zero is the estimated period, from which the algorithm reads the musical note.
<!-- Demo layout — waveform selector, frequency slider, two canvases, result line -->
<div class="controls">
<label>
<span>Waveform</span>
<select id="waveform">
<option value="sine">Sine</option>
<option value="sawtooth">Sawtooth</option>
<option value="square">Square</option>
</select>
</label>
<label>
<span>Frequency: <b id="freq-val">220</b> Hz</span>
<input id="freq-slider" type="range" min="80" max="880" value="220" step="1">
</label>
</div>
<canvas id="wave-canvas" width="560" height="120" title="Waveform display"></canvas>
<div class="section-label">Autocorrelation</div>
<canvas id="acorr-canvas" width="560" height="140" title="Autocorrelation function — first peak after lag 0 gives the period"></canvas>
<div id="result" class="result" aria-live="polite"></div>
/* Layout and colors for the pitch-detection demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fff; }
.controls {
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
padding: .4rem 0 .6rem; margin-bottom: .2rem;
}
label { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; color: #444; }
select, input[type=range] { font-size: .9rem; }
select { padding: .25rem .4rem; border: 1px solid #aaa; border-radius: 6px; background: #f5f7fa; }
input[type=range] { width: 200px; accent-color: #1d3557; }
canvas { display: block; width: 100%; max-width: 560px; border-radius: 8px; background: #f0f4f8; }
.section-label {
font-size: .75rem; font-weight: 600; letter-spacing: .05em; color: #5a7088;
text-transform: uppercase; margin: .4rem 0 .15rem;
}
.result {
margin-top: .5rem; font-size: 1rem; font-weight: 700; min-height: 1.5em;
color: #1d3557;
}
/* Pitch detection by autocorrelation — synthetic waveforms, no Web Audio API needed */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const SR = 4410; /* synthetic sample rate (Hz) — real enough for the demo */
const FRAME = 882; /* analysis frame: 200 ms at 4410 Hz */
const MIN_LAG = Math.round(SR / 880); /* minimum lag = period at 880 Hz (highest A on guitar) */
const MAX_LAG = Math.round(SR / 80); /* maximum lag = period at 80 Hz (below open E string) */
/* map semitone offset from A4=440 Hz to note name */
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function freqToNote(f) {
const semitones = Math.round(12 * Math.log2(f / 440));
const midi = 69 + semitones;
const name = NOTE_NAMES[((midi % 12) + 12) % 12];
const octave = Math.floor(midi / 12) - 1;
return name + octave;
}
/* generate N samples of the chosen waveform at the given frequency */
function generateWave(type, freq, n) {
const samples = new Float64Array(n);
const period = SR / freq;
for (let i = 0; i < n; i++) {
const phase = (i / period) % 1;
if (type === 'sine') {
samples[i] = Math.sin(2 * Math.PI * phase);
} else if (type === 'sawtooth') {
samples[i] = 2 * phase - 1;
} else {
samples[i] = phase < 0.5 ? 1 : -1; /* square wave — high or low */
}
}
return samples;
}
/* compute autocorrelation r(lag) = sum x[i]*x[i+lag] / (N-lag) */
function autocorrelate(samples) {
const n = samples.length;
const acorr = new Float64Array(MAX_LAG + 1);
for (let lag = 0; lag <= MAX_LAG; lag++) {
let sum = 0;
for (let i = 0; i < n - lag; i++) sum += samples[i] * samples[i + lag];
acorr[lag] = sum / (n - lag);
}
return acorr;
}
/* find the first local peak above threshold — that is the fundamental period */
function findPeak(acorr) {
const lag0 = acorr[0] || 1;
const THRESHOLD = 0.3; /* normalized threshold: ignore peaks below 30% of lag-0 energy */
for (let lag = MIN_LAG + 1; lag < MAX_LAG; lag++) {
const norm = acorr[lag] / lag0;
if (norm > THRESHOLD && acorr[lag] > acorr[lag - 1] && acorr[lag] >= acorr[lag + 1]) {
/* parabolic interpolation for sub-sample period accuracy */
const a = acorr[lag - 1], b = acorr[lag], c = acorr[lag + 1];
const delta = (a - c) / (2 * (a - 2 * b + c));
return SR / (lag + delta);
}
}
return 0;
}
/* draw waveform on canvas (clipped to ±1 amplitude) */
function drawWave(canvas, samples) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < samples.length; i++) {
const x = (i / samples.length) * W;
const y = H / 2 - samples[i] * (H / 2 - 6);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
/* draw normalized autocorrelation curve and a marker at the detected peak */
function drawAcorr(canvas, acorr) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const lag0 = acorr[0] || 1;
const norm = lag => acorr[lag] / lag0;
/* zero line */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let lag = 0; lag <= MAX_LAG; lag++) {
const x = (lag / MAX_LAG) * W;
const y = H / 2 - norm(lag) * (H / 2 - 8);
lag === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
/* vertical dashed line at detected period */
const peakFreq = findPeak(acorr);
if (peakFreq > 0) {
const peakLag = Math.round(SR / peakFreq);
const px = (peakLag / MAX_LAG) * W;
ctx.strokeStyle = '#0a7d33';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.setLineDash([]);
}
}
function update() {
const type = document.getElementById('waveform').value;
const freq = parseFloat(document.getElementById('freq-slider').value);
document.getElementById('freq-val').textContent = freq;
const samples = generateWave(type, freq, FRAME);
const acorr = autocorrelate(samples);
const detectedFreq = findPeak(acorr);
const note = detectedFreq > 0 ? freqToNote(detectedFreq) : '—';
drawWave(document.getElementById('wave-canvas'), samples);
drawAcorr(document.getElementById('acorr-canvas'), acorr);
const resultEl = document.getElementById('result');
if (detectedFreq > 0) {
resultEl.textContent = i18nFmt('Detected: %freq% Hz — note %note%', { freq: detectedFreq.toFixed(1), note: note });
} else {
resultEl.textContent = 'No pitch detected';
}
}
document.getElementById('freq-slider').addEventListener('input', update);
document.getElementById('waveform').addEventListener('change', update);
update(); /* first render on page load */
<!-- Demo layout — waveform selector, frequency slider, two canvases, result line -->
<div class="controls">
<label>
<span>Waveform</span>
<select id="waveform">
<option value="sine">Sine</option>
<option value="sawtooth">Sawtooth</option>
<option value="square">Square</option>
</select>
</label>
<label>
<span>Frequency: <b id="freq-val">220</b> Hz</span>
<input id="freq-slider" type="range" min="80" max="880" value="220" step="1">
</label>
</div>
<canvas id="wave-canvas" width="560" height="120" title="Waveform display"></canvas>
<div class="section-label">Autocorrelation</div>
<canvas id="acorr-canvas" width="560" height="140" title="Autocorrelation function — first peak after lag 0 gives the period"></canvas>
<div id="result" class="result" aria-live="polite"></div>
/* Layout and colors for the pitch-detection demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fff; }
.controls {
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
padding: .4rem 0 .6rem; margin-bottom: .2rem;
}
label { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; color: #444; }
select, input[type=range] { font-size: .9rem; }
select { padding: .25rem .4rem; border: 1px solid #aaa; border-radius: 6px; background: #f5f7fa; }
input[type=range] { width: 200px; accent-color: #1d3557; }
canvas { display: block; width: 100%; max-width: 560px; border-radius: 8px; background: #f0f4f8; }
.section-label {
font-size: .75rem; font-weight: 600; letter-spacing: .05em; color: #5a7088;
text-transform: uppercase; margin: .4rem 0 .15rem;
}
.result {
margin-top: .5rem; font-size: 1rem; font-weight: 700; min-height: 1.5em;
color: #1d3557;
}
/* Pitch detection by autocorrelation — synthetic waveforms, no Web Audio API needed */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const SR = 4410; /* synthetic sample rate (Hz) — real enough for the demo */
const FRAME = 882; /* analysis frame: 200 ms at 4410 Hz */
const MIN_LAG = Math.round(SR / 880); /* minimum lag = period at 880 Hz (highest A on guitar) */
const MAX_LAG = Math.round(SR / 80); /* maximum lag = period at 80 Hz (below open E string) */
/* map semitone offset from A4=440 Hz to note name */
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
function freqToNote(f) {
const semitones = Math.round(12 * Math.log2(f / 440));
const midi = 69 + semitones;
const name = NOTE_NAMES[((midi % 12) + 12) % 12];
const octave = Math.floor(midi / 12) - 1;
return name + octave;
}
/* generate N samples of the chosen waveform at the given frequency */
function generateWave(type, freq, n) {
const samples = new Float64Array(n);
const period = SR / freq;
for (let i = 0; i < n; i++) {
const phase = (i / period) % 1;
if (type === 'sine') {
samples[i] = Math.sin(2 * Math.PI * phase);
} else if (type === 'sawtooth') {
samples[i] = 2 * phase - 1;
} else {
samples[i] = phase < 0.5 ? 1 : -1; /* square wave — high or low */
}
}
return samples;
}
/* compute autocorrelation r(lag) = sum x[i]*x[i+lag] / (N-lag) */
function autocorrelate(samples) {
const n = samples.length;
const acorr = new Float64Array(MAX_LAG + 1);
for (let lag = 0; lag <= MAX_LAG; lag++) {
let sum = 0;
for (let i = 0; i < n - lag; i++) sum += samples[i] * samples[i + lag];
acorr[lag] = sum / (n - lag);
}
return acorr;
}
/* find the first local peak above threshold — that is the fundamental period */
function findPeak(acorr) {
const lag0 = acorr[0] || 1;
const THRESHOLD = 0.3; /* normalized threshold: ignore peaks below 30% of lag-0 energy */
for (let lag = MIN_LAG + 1; lag < MAX_LAG; lag++) {
const norm = acorr[lag] / lag0;
if (norm > THRESHOLD && acorr[lag] > acorr[lag - 1] && acorr[lag] >= acorr[lag + 1]) {
/* parabolic interpolation for sub-sample period accuracy */
const a = acorr[lag - 1], b = acorr[lag], c = acorr[lag + 1];
const delta = (a - c) / (2 * (a - 2 * b + c));
return SR / (lag + delta);
}
}
return 0;
}
/* draw waveform on canvas (clipped to ±1 amplitude) */
function drawWave(canvas, samples) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < samples.length; i++) {
const x = (i / samples.length) * W;
const y = H / 2 - samples[i] * (H / 2 - 6);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
/* draw normalized autocorrelation curve and a marker at the detected peak */
function drawAcorr(canvas, acorr) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const lag0 = acorr[0] || 1;
const norm = lag => acorr[lag] / lag0;
/* zero line */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let lag = 0; lag <= MAX_LAG; lag++) {
const x = (lag / MAX_LAG) * W;
const y = H / 2 - norm(lag) * (H / 2 - 8);
lag === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
/* vertical dashed line at detected period */
const peakFreq = findPeak(acorr);
if (peakFreq > 0) {
const peakLag = Math.round(SR / peakFreq);
const px = (peakLag / MAX_LAG) * W;
ctx.strokeStyle = '#0a7d33';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.setLineDash([]);
}
}
function update() {
const type = document.getElementById('waveform').value;
const freq = parseFloat(document.getElementById('freq-slider').value);
document.getElementById('freq-val').textContent = freq;
const samples = generateWave(type, freq, FRAME);
const acorr = autocorrelate(samples);
const detectedFreq = findPeak(acorr);
const note = detectedFreq > 0 ? freqToNote(detectedFreq) : '—';
drawWave(document.getElementById('wave-canvas'), samples);
drawAcorr(document.getElementById('acorr-canvas'), acorr);
const resultEl = document.getElementById('result');
if (detectedFreq > 0) {
resultEl.textContent = i18nFmt('Detected: %freq% Hz — note %note%', { freq: detectedFreq.toFixed(1), note: note });
} else {
resultEl.textContent = 'No pitch detected';
}
}
document.getElementById('freq-slider').addEventListener('input', update);
document.getElementById('waveform').addEventListener('change', update);
update(); /* first render on page load */
Notice how a pure sine wave produces a smooth, clean autocorrelation, while a sawtooth or square wave (rich in harmonics) creates a more jagged curve — yet the first peak still lands at the true period. This robustness is why autocorrelation beats simply hunting for the loudest frequency in a fast Fourier transform: harmonics can easily fool a spectrum peak-picker, but they reinforce the autocorrelation peak at the fundamental.
Comments
Loading comments...