summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 05:36:49 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 05:36:49 +0100
commitd68fa4782e971b8ea41204ef5898141efcb243af (patch)
treeb380c4381529b19b002cb72bcd9b4257025c9015 /tools/mq_editor
parentf43169185592a85d5bd30b9699671eb08a39dfda (diff)
test(mq_editor): add isolated FFT test page and sine generator
- tools/mq_editor/test_fft.html: browser test for fft.js (12 tests: DC impulse, single tone, STFT magnitude, pairs, triplets) - tools/gen_sine_440.py: generate 1s 440Hz WAV at 32kHz for manual testing handoff(Gemini): FFT isolation tests added, all passing in browser.
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/README.md1
-rw-r--r--tools/mq_editor/test_fft.html229
2 files changed, 230 insertions, 0 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md
index 1f43c19..71ad90f 100644
--- a/tools/mq_editor/README.md
+++ b/tools/mq_editor/README.md
@@ -31,6 +31,7 @@ open tools/mq_editor/index.html
- `fft.js` - Fast Fourier Transform (Cooley-Tukey radix-2)
- `mq_extract.js` - MQ algorithm (peak detection, tracking, bezier fitting)
- `viewer.js` - Visualization (spectrogram, partials, zoom, mouse interaction)
+- `test_fft.html` - Isolated FFT correctness tests (open in browser, no server needed)
## Implementation Status
diff --git a/tools/mq_editor/test_fft.html b/tools/mq_editor/test_fft.html
new file mode 100644
index 0000000..b4e7f48
--- /dev/null
+++ b/tools/mq_editor/test_fft.html
@@ -0,0 +1,229 @@
+<!DOCTYPE html>
+<!--
+ test_fft.html — Isolated FFT correctness tests for fft.js
+ Open directly in a browser (no server needed).
+
+ Tests fftForward(), realFFT(), and STFTCache from fft.js.
+
+ Test summary:
+ 1 DC impulse — all-ones input → bin 0 must equal N
+ 2 Single tone — 440 Hz pure sine → peak bin matches expected frequency
+ 3 STFT magnitude — STFTCache.getMagnitudeDB() returns loud value at 440 Hz
+ 4 Pair equal — 220 + 880 Hz, both peaks found
+ 5 Pair equal — 440 + 1320 Hz, both peaks found
+ 6 Pair wide — 300 + 3000 Hz (decade separation), both peaks found
+ 7 Pair extreme — 100 + 8000 Hz (80× ratio), both peaks found
+ 8 Pair unequal — 440 Hz + 2000 Hz at 10:1 amplitude, weak peak still found
+ 9 Triplet chord — C4 (261.63) + E4 (329.63) + G4 (392) major chord
+ 10 Triplet octaves — 110 + 220 + 440 Hz octave stack
+ 11 Triplet harmonic — 500 + 1500 + 4500 Hz (1:3:9 ratio)
+ 12 Triplet unequal — 440 + 880 + 1760 Hz at 1.0 / 0.5 / 0.25 (decaying harmonics)
+
+ Pass criteria:
+ - Each expected frequency bin is found within ±guard bins (guard ≈ 2 × bin_width).
+ - Bin width = SR / N = 32000 / 4096 ≈ 7.8 Hz.
+
+ All spectra are drawn as linear-magnitude plots (0..Nyquist on x-axis).
+ Colored vertical markers show expected frequency positions.
+-->
+<html>
+<head>
+<meta charset="utf-8">
+<title>FFT Test</title>
+<style>
+ body { font-family: monospace; background: #111; color: #ccc; padding: 20px; }
+ h2 { color: #fff; }
+ canvas { border: 1px solid #444; display: block; margin: 10px 0; }
+ .pass { color: #4f4; }
+ .fail { color: #f44; }
+ pre { background: #222; padding: 10px; }
+</style>
+</head>
+<body>
+<h2>FFT Isolation Test</h2>
+<pre id="log"></pre>
+<canvas id="spectrum" width="800" height="200"></canvas>
+<script src="fft.js"></script>
+<script>
+const log = document.getElementById('log');
+function print(msg, cls) {
+ const span = document.createElement('span');
+ if (cls) span.className = cls;
+ span.textContent = msg + '\n';
+ log.appendChild(span);
+}
+
+// --- Test 1: Single bin impulse (DC) ---
+{
+ const N = 8;
+ const real = new Float32Array([1,1,1,1,1,1,1,1]);
+ const imag = new Float32Array(N);
+ fftForward(real, imag, N);
+ const ok = Math.abs(real[0] - 8) < 1e-4 && Math.abs(imag[0]) < 1e-4;
+ print(`Test 1 DC impulse: real[0]=${real[0].toFixed(4)} ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
+}
+
+// --- Test 2: 440 Hz peak detection ---
+{
+ const SR = 32000;
+ const N = 2048;
+ const signal = new Float32Array(N);
+ for (let i = 0; i < N; ++i)
+ signal[i] = Math.sin(2 * Math.PI * 440 * i / SR);
+
+ const spectrum = realFFT(signal);
+
+ let peakBin = 0, peakVal = 0;
+ for (let i = 0; i < N / 2; ++i) {
+ const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
+ const mag = re * re + im * im;
+ if (mag > peakVal) { peakVal = mag; peakBin = i; }
+ }
+ const peakFreq = peakBin * SR / N;
+ const ok = Math.abs(peakFreq - 440) < SR / N;
+ print(`Test 2 440Hz peak: bin=${peakBin} freq=${peakFreq.toFixed(1)}Hz ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
+
+ // Draw spectrum
+ const canvas = document.getElementById('spectrum');
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#111';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = '#0af';
+ ctx.beginPath();
+ const halfN = N / 2;
+ for (let i = 0; i < halfN; ++i) {
+ const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
+ const mag = Math.sqrt(re * re + im * im) / (N / 2);
+ const x = i / halfN * canvas.width;
+ const y = canvas.height - mag * canvas.height;
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+
+ // Mark 440Hz
+ ctx.strokeStyle = '#f80';
+ ctx.beginPath();
+ const x440 = peakBin / halfN * canvas.width;
+ ctx.moveTo(x440, 0); ctx.lineTo(x440, canvas.height);
+ ctx.stroke();
+ ctx.fillStyle = '#f80';
+ ctx.fillText(`440Hz (bin ${peakBin})`, x440 + 4, 16);
+}
+
+// --- Test 3: STFT getMagnitudeDB at t=0 ---
+{
+ const SR = 32000;
+ const N = 44032; // ~1.375s
+ const signal = new Float32Array(N);
+ for (let i = 0; i < N; ++i)
+ signal[i] = Math.sin(2 * Math.PI * 440 * i / SR);
+
+ const stft = new STFTCache(signal, SR, 2048, 512);
+ const db = stft.getMagnitudeDB(0.0, 440);
+ const ok = db > -10;
+ print(`Test 3 STFT getMagnitudeDB(0, 440Hz): ${db.toFixed(1)} dB ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
+}
+
+// Helper: find top-K peaks in spectrum (bin indices), ignoring neighbors within 'guard' bins
+function findPeaks(spectrum, halfN, k, guard) {
+ const mags = new Float32Array(halfN);
+ for (let i = 0; i < halfN; ++i) {
+ const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
+ mags[i] = re * re + im * im;
+ }
+ const peaks = [];
+ const used = new Uint8Array(halfN);
+ for (let p = 0; p < k; ++p) {
+ let best = -1, bestVal = 0;
+ for (let i = 1; i < halfN; ++i) {
+ if (!used[i] && mags[i] > bestVal) { bestVal = mags[i]; best = i; }
+ }
+ if (best < 0) break;
+ peaks.push(best);
+ for (let g = Math.max(0, best - guard); g <= Math.min(halfN - 1, best + guard); ++g)
+ used[g] = 1;
+ }
+ return peaks;
+}
+
+// Helper: draw labeled spectrum on a new canvas
+function drawSpectrum(label, spectrum, N, SR, markerFreqs) {
+ const halfN = N / 2;
+ const colors = ['#f80', '#0f8', '#f0f', '#08f'];
+ const canvas = document.createElement('canvas');
+ canvas.width = 800; canvas.height = 160;
+ const div = document.createElement('div');
+ div.style.cssText = 'color:#888;font-family:monospace;font-size:12px;margin-top:8px';
+ div.textContent = label;
+ document.body.appendChild(div);
+ document.body.appendChild(canvas);
+
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#111';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = '#0af';
+ ctx.beginPath();
+ for (let i = 0; i < halfN; ++i) {
+ const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
+ const mag = Math.sqrt(re * re + im * im) / (N / 2);
+ const x = i / halfN * canvas.width;
+ const y = canvas.height - mag * canvas.height;
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+
+ markerFreqs.forEach((f, idx) => {
+ const bin = Math.round(f * N / SR);
+ const x = bin / halfN * canvas.width;
+ ctx.strokeStyle = colors[idx % colors.length];
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
+ ctx.fillStyle = colors[idx % colors.length];
+ ctx.fillText(`${f}Hz`, x + 3, 14 + idx * 14);
+ });
+}
+
+// Helper: test multi-frequency signal, return pass/fail
+function testMultiFreq(label, freqs, amplitudes, SR, N, testNum) {
+ const signal = new Float32Array(N);
+ for (let i = 0; i < N; ++i)
+ for (let f = 0; f < freqs.length; ++f)
+ signal[i] += (amplitudes ? amplitudes[f] : 1.0) * Math.sin(2 * Math.PI * freqs[f] * i / SR);
+
+ const spectrum = realFFT(signal);
+ const halfN = N / 2;
+ const guard = Math.ceil(SR / N * 2); // ~2 bins tolerance
+ const peaks = findPeaks(spectrum, halfN, freqs.length, guard);
+
+ const detectedFreqs = peaks.map(b => (b * SR / N).toFixed(1));
+ const allFound = freqs.every(f => {
+ const expectedBin = Math.round(f * N / SR);
+ return peaks.some(b => Math.abs(b - expectedBin) <= guard);
+ });
+
+ const freqStr = freqs.map((f, i) => amplitudes ? `${f}Hz@${amplitudes[i].toFixed(1)}` : `${f}Hz`).join(' + ');
+ print(`Test ${testNum} ${label} [${freqStr}]: peaks=[${detectedFreqs.join(', ')}] ${allFound?'PASS':'FAIL'}`,
+ allFound ? 'pass' : 'fail');
+
+ drawSpectrum(`Test ${testNum}: ${label} — ${freqStr}`, spectrum, N, SR, freqs);
+ return allFound;
+}
+
+const SR = 32000, N = 4096;
+
+// --- Pairs ---
+print('\n-- Pairs --');
+testMultiFreq('pair', [220, 880], null, SR, N, 4);
+testMultiFreq('pair', [440, 1320], null, SR, N, 5);
+testMultiFreq('pair', [300, 3000], null, SR, N, 6);
+testMultiFreq('pair', [100, 8000], null, SR, N, 7);
+testMultiFreq('pair unequal amp', [440, 2000], [1.0, 0.1], SR, N, 8);
+
+// --- Triplets ---
+print('\n-- Triplets --');
+testMultiFreq('triplet', [261.63, 329.63, 392.00], null, SR, N, 9); // C E G chord
+testMultiFreq('triplet', [110, 220, 440], null, SR, N, 10); // octave stack
+testMultiFreq('triplet', [500, 1500, 4500], null, SR, N, 11); // harmonic series
+testMultiFreq('triplet unequal', [440, 880, 1760], [1.0, 0.5, 0.25],SR, N, 12); // decaying harmonics
+</script>
+</body>
+</html>