<!-- CQT demo: compare how musical notes land on a linear (FFT) vs. log (CQT) frequency axis -->
<p class="hint">Click any piano key to see where that note lands on each axis. On the <b>linear axis</b> (left) the notes bunch at the bottom; on the <b>log axis</b> (right) they are perfectly evenly spaced — that is the Constant-Q Transform.</p>
<div class="axes-wrap">
<div class="axis-block">
<div class="axis-label">FFT (linear)</div>
<div id="linear-axis" class="axis linear-axis" title="Linear frequency axis — the FFT view"></div>
<div class="axis-desc">Equal Hz spacing — low notes crowd together.</div>
</div>
<div class="axis-block">
<div class="axis-label">CQT (log)</div>
<div id="log-axis" class="axis log-axis" title="Logarithmic frequency axis — the CQT view"></div>
<div class="axis-desc">Equal semitone spacing — every note is equidistant.</div>
</div>
</div>
<!-- piano keyboard — one octave C4 to C5 -->
<div id="keyboard" class="keyboard" aria-label="Piano keyboard from C4 to C5"></div>
<div class="status" id="status">Click a key to highlight its position on both axes.</div>
/* CQT demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.axes-wrap { display: flex; gap: 1rem; margin-bottom: .8rem; }
.axis-block { flex: 1; display: flex; flex-direction: column; gap: .3rem; }
.axis-label { font-size: .75rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .05em; }
.axis { position: relative; height: 180px; background: #eef1f5; border-radius: 8px; overflow: hidden; border: 1px solid #cdd9e3; }
.axis-desc { font-size: .72rem; color: #666; line-height: 1.35; }
/* frequency tick marks */
.tick { position: absolute; width: 100%; border-top: 1px solid #cdd9e3; }
.tick-label { position: absolute; right: 4px; font-size: 9px; color: #888; transform: translateY(-50%); white-space: nowrap; }
/* highlighted note dot */
.note-dot { position: absolute; width: 10px; height: 10px; border-radius: 50%; background: #e63946; left: 50%; transform: translate(-50%, -50%); transition: opacity .25s; }
.note-dot.active { box-shadow: 0 0 6px 3px rgba(230,57,70,.4); }
/* piano key styles */
.keyboard { display: flex; gap: 3px; margin-bottom: .6rem; }
.key { flex: 1; height: 60px; border-radius: 6px; cursor: pointer; border: none; font-size: 9px; font-weight: 700; color: #555; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; transition: background .12s; user-select: none; }
.key.white { background: #f5f7fa; border: 1px solid #c5ccd5; }
.key.black { background: #2d3748; color: #ddd; flex: 0.65; }
.key.white:hover { background: #dce6f0; }
.key.black:hover { background: #1a2030; }
.key.active { background: #e63946 !important; color: #fff; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; color: #1d3557; }
// Constant-Q Transform demo — shows how musical notes fall on linear vs. log frequency axes
// C4..C5 = one octave; linear spacing (FFT) bunches notes at the bottom; log spacing (CQT) keeps semitones equal
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// One octave of notes from C4 to C5 with their frequencies in Hz
const NOTES = [
{ name: 'C4', freq: 261.63, type: 'white' },
{ name: 'C#4', freq: 277.18, type: 'black' },
{ name: 'D4', freq: 293.66, type: 'white' },
{ name: 'D#4', freq: 311.13, type: 'black' },
{ name: 'E4', freq: 329.63, type: 'white' },
{ name: 'F4', freq: 349.23, type: 'white' },
{ name: 'F#4', freq: 369.99, type: 'black' },
{ name: 'G4', freq: 392.00, type: 'white' },
{ name: 'G#4', freq: 415.30, type: 'black' },
{ name: 'A4', freq: 440.00, type: 'white' },
{ name: 'A#4', freq: 466.16, type: 'black' },
{ name: 'B4', freq: 493.88, type: 'white' },
{ name: 'C5', freq: 523.25, type: 'white' }
];
// frequency range: one octave C4 to C5
const F_MIN = 261.63;
const F_MAX = 523.25;
// Linear position: (freq - min) / (max - min) — uniform Hz spacing
function linearPos(freq) {
return (freq - F_MIN) / (F_MAX - F_MIN);
}
// Log position: log2(freq/min) / log2(max/min) — uniform semitone spacing
function logPos(freq) {
return Math.log2(freq / F_MIN) / Math.log2(F_MAX / F_MIN);
}
// Draw tick marks and note dots on a frequency axis element
function drawAxis(el, posFn, freqs) {
el.innerHTML = '';
const HEIGHT = el.clientHeight || 180;
freqs.forEach(freq => {
const p = posFn(freq);
const tick = document.createElement('div');
tick.className = 'tick';
tick.style.top = ((1 - p) * HEIGHT) + 'px';
const lbl = document.createElement('span');
lbl.className = 'tick-label';
lbl.textContent = freq >= 1000 ? (freq / 1000).toFixed(2) + 'k' : Math.round(freq) + ' Hz';
tick.appendChild(lbl);
el.appendChild(tick);
// invisible dot that lights up when this note is selected
const dot = document.createElement('div');
dot.className = 'note-dot';
dot.style.top = ((1 - p) * HEIGHT) + 'px';
dot.style.opacity = '0';
dot.dataset.freq = freq;
el.appendChild(dot);
});
}
// Build the one-octave piano keyboard
function buildKeyboard() {
const kb = document.getElementById('keyboard');
NOTES.forEach((note, i) => {
const btn = document.createElement('button');
btn.className = 'key ' + note.type;
btn.textContent = note.name;
btn.setAttribute('aria-label', note.name + ' ' + Math.round(note.freq) + ' Hz');
btn.addEventListener('click', () => selectNote(note, btn));
kb.appendChild(btn);
});
}
let activeBtn = null;
function selectNote(note, btn) {
// hide all dots and deactivate previous key
document.querySelectorAll('.note-dot').forEach(d => { d.style.opacity = '0'; d.classList.remove('active'); });
if (activeBtn) activeBtn.classList.remove('active');
// light up the dot for this note on both axes
[document.getElementById('linear-axis'), document.getElementById('log-axis')].forEach(ax => {
const dot = [...ax.querySelectorAll('.note-dot')].find(d => parseFloat(d.dataset.freq).toFixed(2) === note.freq.toFixed(2));
if (dot) { dot.style.opacity = '1'; dot.classList.add('active'); }
});
btn.classList.add('active');
activeBtn = btn;
document.getElementById('status').textContent = i18nFmt('%name% — %freq% Hz', { name: note.name, freq: Math.round(note.freq) });
}
const FREQS = NOTES.map(n => n.freq);
const linEl = document.getElementById('linear-axis');
const logEl = document.getElementById('log-axis');
drawAxis(linEl, linearPos, FREQS);
drawAxis(logEl, logPos, FREQS);
buildKeyboard();
// Constant-Q Transform demo — shows how musical notes fall on linear vs. log frequency axes
// C4..C5 = one octave; linear spacing (FFT) bunches notes at the bottom; log spacing (CQT) keeps semitones equal
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// One octave of notes from C4 to C5 with their frequencies in Hz
const NOTES = [
{ name: 'C4', freq: 261.63, type: 'white' },
{ name: 'C#4', freq: 277.18, type: 'black' },
{ name: 'D4', freq: 293.66, type: 'white' },
{ name: 'D#4', freq: 311.13, type: 'black' },
{ name: 'E4', freq: 329.63, type: 'white' },
{ name: 'F4', freq: 349.23, type: 'white' },
{ name: 'F#4', freq: 369.99, type: 'black' },
{ name: 'G4', freq: 392.00, type: 'white' },
{ name: 'G#4', freq: 415.30, type: 'black' },
{ name: 'A4', freq: 440.00, type: 'white' },
{ name: 'A#4', freq: 466.16, type: 'black' },
{ name: 'B4', freq: 493.88, type: 'white' },
{ name: 'C5', freq: 523.25, type: 'white' }
];
// frequency range: one octave C4 to C5
const F_MIN = 261.63;
const F_MAX = 523.25;
// Linear position: (freq - min) / (max - min) — uniform Hz spacing
function linearPos(freq) {
return (freq - F_MIN) / (F_MAX - F_MIN);
}
// Log position: log2(freq/min) / log2(max/min) — uniform semitone spacing
function logPos(freq) {
return Math.log2(freq / F_MIN) / Math.log2(F_MAX / F_MIN);
}
// Draw tick marks and note dots on a frequency axis element
function drawAxis(el, posFn, freqs) {
el.innerHTML = '';
const HEIGHT = el.clientHeight || 180;
freqs.forEach(freq => {
const p = posFn(freq);
const tick = document.createElement('div');
tick.className = 'tick';
tick.style.top = ((1 - p) * HEIGHT) + 'px';
const lbl = document.createElement('span');
lbl.className = 'tick-label';
lbl.textContent = freq >= 1000 ? (freq / 1000).toFixed(2) + 'k' : Math.round(freq) + ' Hz';
tick.appendChild(lbl);
el.appendChild(tick);
// invisible dot that lights up when this note is selected
const dot = document.createElement('div');
dot.className = 'note-dot';
dot.style.top = ((1 - p) * HEIGHT) + 'px';
dot.style.opacity = '0';
dot.dataset.freq = freq;
el.appendChild(dot);
});
}
// Build the one-octave piano keyboard
function buildKeyboard() {
const kb = document.getElementById('keyboard');
NOTES.forEach((note, i) => {
const btn = document.createElement('button');
btn.className = 'key ' + note.type;
btn.textContent = note.name;
btn.setAttribute('aria-label', note.name + ' ' + Math.round(note.freq) + ' Hz');
btn.addEventListener('click', () => selectNote(note, btn));
kb.appendChild(btn);
});
}
let activeBtn = null;
function selectNote(note, btn) {
// hide all dots and deactivate previous key
document.querySelectorAll('.note-dot').forEach(d => { d.style.opacity = '0'; d.classList.remove('active'); });
if (activeBtn) activeBtn.classList.remove('active');
// light up the dot for this note on both axes
[document.getElementById('linear-axis'), document.getElementById('log-axis')].forEach(ax => {
const dot = [...ax.querySelectorAll('.note-dot')].find(d => parseFloat(d.dataset.freq).toFixed(2) === note.freq.toFixed(2));
if (dot) { dot.style.opacity = '1'; dot.classList.add('active'); }
});
btn.classList.add('active');
activeBtn = btn;
document.getElementById('status').textContent = i18nFmt('%name% — %freq% Hz', { name: note.name, freq: Math.round(note.freq) });
}
const FREQS = NOTES.map(n => n.freq);
const linEl = document.getElementById('linear-axis');
const logEl = document.getElementById('log-axis');
drawAxis(linEl, linearPos, FREQS);
drawAxis(logEl, logPos, FREQS);
buildKeyboard();
Comments
Loading comments...