diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 05:36:49 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 05:36:49 +0100 |
| commit | d68fa4782e971b8ea41204ef5898141efcb243af (patch) | |
| tree | b380c4381529b19b002cb72bcd9b4257025c9015 /tools/mq_editor/test_fft.html | |
| parent | f43169185592a85d5bd30b9699671eb08a39dfda (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/test_fft.html')
| -rw-r--r-- | tools/mq_editor/test_fft.html | 229 |
1 files changed, 229 insertions, 0 deletions
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> |
