The demo below simulates a harmonic sound: a base pitch plus its overtones. Drag the slider to change the fundamental frequency and watch how a spike appears in the cepstrum at the quefrency corresponding to that pitch period.
<!-- Controls and two canvases: log-spectrum on the left, cepstrum on the right -->
<p class="hint">A harmonic sound has a pitch (fundamental frequency) and a timbre (spectral shape). The <b>cepstrum</b> — the spectrum of the log-spectrum — separates them: timbre stays near zero quefrency while a lone spike marks the pitch period.</p>
<div class="controls">
<label for="pitchSlider">Pitch: <span id="pitchVal">220</span> Hz</label>
<input id="pitchSlider" type="range" min="80" max="600" value="220" step="5">
</div>
<div class="panels">
<div class="panel">
<div class="panel-title">Log-spectrum</div>
<canvas id="specCanvas" width="340" height="160"></canvas>
</div>
<div class="panel">
<div class="panel-title">Cepstrum</div>
<canvas id="cepCanvas" width="340" height="160"></canvas>
</div>
</div>
<div class="status" id="status"></div>
/* Layout: hint, slider, two side-by-side canvas panels */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { margin-bottom: .8rem; }
.controls label { font-size: .9rem; font-weight: 600; display: block; margin-bottom: .3rem; }
input[type=range] { width: 100%; accent-color: #1d3557; }
.panels { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { flex: 1 1 160px; min-width: 140px; }
.panel-title { font-size: .8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: #1d3557; margin-bottom: .3rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb;
display: block; width: 100%; max-width: 340px; }
.status { font-size: .9rem; font-weight: 600; color: #1d3557; min-height: 1.2em;
margin-top: .6rem; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Cepstrum demo — build a harmonic signal, compute DFT, take log-magnitude, compute DFT again
const SAMPLE_RATE = 4000; // Hz — enough for pitch 80-600 Hz with 8 harmonics
const N = 1024; // DFT size — enough resolution without being too slow for a demo
const HARMONICS = 8; // number of overtones to synthesize (amplitude 1/h)
// Synthesize a harmonic signal with the given fundamental frequency f0.
function buildSignal(f0) {
const sig = new Float32Array(N);
for (let h = 1; h <= HARMONICS; h++) {
const amp = 1 / h; // amplitude of h-th harmonic falls as 1/h (sawtooth-like spectrum)
const freq = h * f0;
if (freq >= SAMPLE_RATE / 2) break;
for (let i = 0; i < N; i++) {
sig[i] += amp * Math.sin(2 * Math.PI * freq * i / SAMPLE_RATE);
}
}
return sig;
}
// Naive O(n^2) DFT — correct and simple enough for N=512 half-spectrum.
function dft(signal) {
const n = signal.length;
const half = n >> 1;
const re = new Float32Array(half);
const im = new Float32Array(half);
for (let k = 0; k < half; k++) {
let sr = 0, si = 0;
const w = -2 * Math.PI * k / n;
for (let t = 0; t < n; t++) {
sr += signal[t] * Math.cos(w * t);
si += signal[t] * Math.sin(w * t);
}
re[k] = sr;
im[k] = si;
}
return { re, im };
}
// Magnitude spectrum from real and imaginary parts.
function magnitude(re, im) {
return re.map((r, i) => Math.sqrt(r * r + im[i] * im[i]));
}
// Full cepstrum pipeline: signal -> DFT -> log-magnitude -> DFT again -> magnitude.
function computeCepstrum(f0) {
const sig = buildSignal(f0);
const half = N >> 1;
// Step 1: magnitude spectrum of the signal.
const { re, im } = dft(sig);
const mag = magnitude(re, im);
// Step 2: logarithm of the magnitude (turns multiplication into addition).
const logMag = mag.map(v => Math.log(Math.max(v, 1e-9)));
// Step 3: DFT of the log-magnitude = cepstrum.
const { re: cre, im: cim } = dft(logMag);
const cepstrum = magnitude(cre, cim);
return { mag, logMag, cepstrum };
}
function drawBar(ctx, data, maxVal, color, highlight, highlightColor, w, h) {
const n = data.length;
const bw = w / n;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < n; i++) {
const bh = (data[i] / maxVal) * (h - 4);
ctx.fillStyle = (highlight !== null && i === highlight) ? highlightColor : color;
ctx.fillRect(i * bw, h - bh, Math.max(bw - 0.5, 1), bh);
}
}
// Find peak in a window around the expected quefrency bin — avoids the large DC bin 0.
function findPeakNear(arr, center, radius) {
const lo = Math.max(1, center - radius);
const hi = Math.min(arr.length - 1, center + radius);
let best = 0, bestI = center;
for (let i = lo; i <= hi; i++) {
if (arr[i] > best) { best = arr[i]; bestI = i; }
}
return bestI;
}
const specCanvas = document.getElementById('specCanvas');
const cepCanvas = document.getElementById('cepCanvas');
const sCtx = specCanvas.getContext('2d');
const cCtx = cepCanvas.getContext('2d');
const slider = document.getElementById('pitchSlider');
const pitchVal = document.getElementById('pitchVal');
const statusEl = document.getElementById('status');
function update() {
const f0 = parseInt(slider.value, 10);
pitchVal.textContent = f0;
const { mag, cepstrum } = computeCepstrum(f0);
// Show only the first 120 frequency bins (low-frequency part of the spectrum).
const specShow = mag.slice(0, 120);
const maxMag = Math.max(...specShow, 1e-9);
drawBar(sCtx, specShow, maxMag, '#7da9c7', null, '#e63946',
specCanvas.width, specCanvas.height);
// Show bins 1..CEP_SHOW of the cepstrum; bin 0 is DC (always huge, skip it).
// 36 bins covers quefrency 3-25, matching f0 range 80-600 Hz.
const CEP_SHOW = 36;
const cepShow = cepstrum.slice(1, CEP_SHOW + 1);
const maxCep = Math.max(...cepShow, 1e-9);
// Expected peak bin in the displayed cepstrum (offset by 1 since we sliced from bin 1).
// Peak quefrency bin = SAMPLE_RATE/(2*f0): the DFT of the half-spectrum (N/2 pts) maps period p bins -> quefrency k = (N/2)/p = SR/(2*f0).
const expectedQ = Math.round(SAMPLE_RATE / (2 * f0));
const peakOffset = findPeakNear(cepstrum, expectedQ, 4) - 1;
drawBar(cCtx, cepShow, maxCep, '#a8c5da', peakOffset, '#e63946',
cepCanvas.width, cepCanvas.height);
statusEl.textContent = i18nFmt('Pitch: %f0% Hz — expected cepstrum peak at quefrency ≈ %q% bins', { f0, q: expectedQ });
}
slider.addEventListener('input', update);
update();
<!-- Controls and two canvases: log-spectrum on the left, cepstrum on the right -->
<p class="hint">A harmonic sound has a pitch (fundamental frequency) and a timbre (spectral shape). The <b>cepstrum</b> — the spectrum of the log-spectrum — separates them: timbre stays near zero quefrency while a lone spike marks the pitch period.</p>
<div class="controls">
<label for="pitchSlider">Pitch: <span id="pitchVal">220</span> Hz</label>
<input id="pitchSlider" type="range" min="80" max="600" value="220" step="5">
</div>
<div class="panels">
<div class="panel">
<div class="panel-title">Log-spectrum</div>
<canvas id="specCanvas" width="340" height="160"></canvas>
</div>
<div class="panel">
<div class="panel-title">Cepstrum</div>
<canvas id="cepCanvas" width="340" height="160"></canvas>
</div>
</div>
<div class="status" id="status"></div>
/* Layout: hint, slider, two side-by-side canvas panels */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { margin-bottom: .8rem; }
.controls label { font-size: .9rem; font-weight: 600; display: block; margin-bottom: .3rem; }
input[type=range] { width: 100%; accent-color: #1d3557; }
.panels { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { flex: 1 1 160px; min-width: 140px; }
.panel-title { font-size: .8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: #1d3557; margin-bottom: .3rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb;
display: block; width: 100%; max-width: 340px; }
.status { font-size: .9rem; font-weight: 600; color: #1d3557; min-height: 1.2em;
margin-top: .6rem; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Cepstrum demo — build a harmonic signal, compute DFT, take log-magnitude, compute DFT again
const SAMPLE_RATE = 4000; // Hz — enough for pitch 80-600 Hz with 8 harmonics
const N = 1024; // DFT size — enough resolution without being too slow for a demo
const HARMONICS = 8; // number of overtones to synthesize (amplitude 1/h)
// Synthesize a harmonic signal with the given fundamental frequency f0.
function buildSignal(f0) {
const sig = new Float32Array(N);
for (let h = 1; h <= HARMONICS; h++) {
const amp = 1 / h; // amplitude of h-th harmonic falls as 1/h (sawtooth-like spectrum)
const freq = h * f0;
if (freq >= SAMPLE_RATE / 2) break;
for (let i = 0; i < N; i++) {
sig[i] += amp * Math.sin(2 * Math.PI * freq * i / SAMPLE_RATE);
}
}
return sig;
}
// Naive O(n^2) DFT — correct and simple enough for N=512 half-spectrum.
function dft(signal) {
const n = signal.length;
const half = n >> 1;
const re = new Float32Array(half);
const im = new Float32Array(half);
for (let k = 0; k < half; k++) {
let sr = 0, si = 0;
const w = -2 * Math.PI * k / n;
for (let t = 0; t < n; t++) {
sr += signal[t] * Math.cos(w * t);
si += signal[t] * Math.sin(w * t);
}
re[k] = sr;
im[k] = si;
}
return { re, im };
}
// Magnitude spectrum from real and imaginary parts.
function magnitude(re, im) {
return re.map((r, i) => Math.sqrt(r * r + im[i] * im[i]));
}
// Full cepstrum pipeline: signal -> DFT -> log-magnitude -> DFT again -> magnitude.
function computeCepstrum(f0) {
const sig = buildSignal(f0);
const half = N >> 1;
// Step 1: magnitude spectrum of the signal.
const { re, im } = dft(sig);
const mag = magnitude(re, im);
// Step 2: logarithm of the magnitude (turns multiplication into addition).
const logMag = mag.map(v => Math.log(Math.max(v, 1e-9)));
// Step 3: DFT of the log-magnitude = cepstrum.
const { re: cre, im: cim } = dft(logMag);
const cepstrum = magnitude(cre, cim);
return { mag, logMag, cepstrum };
}
function drawBar(ctx, data, maxVal, color, highlight, highlightColor, w, h) {
const n = data.length;
const bw = w / n;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < n; i++) {
const bh = (data[i] / maxVal) * (h - 4);
ctx.fillStyle = (highlight !== null && i === highlight) ? highlightColor : color;
ctx.fillRect(i * bw, h - bh, Math.max(bw - 0.5, 1), bh);
}
}
// Find peak in a window around the expected quefrency bin — avoids the large DC bin 0.
function findPeakNear(arr, center, radius) {
const lo = Math.max(1, center - radius);
const hi = Math.min(arr.length - 1, center + radius);
let best = 0, bestI = center;
for (let i = lo; i <= hi; i++) {
if (arr[i] > best) { best = arr[i]; bestI = i; }
}
return bestI;
}
const specCanvas = document.getElementById('specCanvas');
const cepCanvas = document.getElementById('cepCanvas');
const sCtx = specCanvas.getContext('2d');
const cCtx = cepCanvas.getContext('2d');
const slider = document.getElementById('pitchSlider');
const pitchVal = document.getElementById('pitchVal');
const statusEl = document.getElementById('status');
function update() {
const f0 = parseInt(slider.value, 10);
pitchVal.textContent = f0;
const { mag, cepstrum } = computeCepstrum(f0);
// Show only the first 120 frequency bins (low-frequency part of the spectrum).
const specShow = mag.slice(0, 120);
const maxMag = Math.max(...specShow, 1e-9);
drawBar(sCtx, specShow, maxMag, '#7da9c7', null, '#e63946',
specCanvas.width, specCanvas.height);
// Show bins 1..CEP_SHOW of the cepstrum; bin 0 is DC (always huge, skip it).
// 36 bins covers quefrency 3-25, matching f0 range 80-600 Hz.
const CEP_SHOW = 36;
const cepShow = cepstrum.slice(1, CEP_SHOW + 1);
const maxCep = Math.max(...cepShow, 1e-9);
// Expected peak bin in the displayed cepstrum (offset by 1 since we sliced from bin 1).
// Peak quefrency bin = SAMPLE_RATE/(2*f0): the DFT of the half-spectrum (N/2 pts) maps period p bins -> quefrency k = (N/2)/p = SR/(2*f0).
const expectedQ = Math.round(SAMPLE_RATE / (2 * f0));
const peakOffset = findPeakNear(cepstrum, expectedQ, 4) - 1;
drawBar(cCtx, cepShow, maxCep, '#a8c5da', peakOffset, '#e63946',
cepCanvas.width, cepCanvas.height);
statusEl.textContent = i18nFmt('Pitch: %f0% Hz — expected cepstrum peak at quefrency ≈ %q% bins', { f0, q: expectedQ });
}
slider.addEventListener('input', update);
update();
Notice that the spike's position on the quefrency axis shifts when you change the pitch — while the smooth envelope of the spectrum (which encodes timbre) stays broad and spread out. The cepstrum does in one step what the ear does automatically: it pulls the periodicity of the harmonic series out of the messy spectrum and reports it as a single number.
Comments
Loading comments...