summaryrefslogtreecommitdiff
path: root/tools/mq_editor/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/app.js')
-rw-r--r--tools/mq_editor/app.js625
1 files changed, 625 insertions, 0 deletions
diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js
new file mode 100644
index 0000000..59849da
--- /dev/null
+++ b/tools/mq_editor/app.js
@@ -0,0 +1,625 @@
+// MQ Editor — application glue (extracted from index.html)
+
+// LP: y[n] = k*x[n] + (1-k)*y[n-1] => -3dB at cos(w) = (2-2k-k²)/(2(1-k))
+function k1ToHz(k, sr) {
+ if (k >= 1.0) return sr / 2;
+ const cosW = (2 - 2*k - k*k) / (2*(1 - k));
+ return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+}
+// HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²)
+function k2ToHz(k, sr) {
+ if (k >= 1.0) return 0;
+ const cosW = 2*k / (1 + k*k);
+ return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+}
+function fmtHz(f) {
+ return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz';
+}
+function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; }
+
+// LP/HP slider live display
+document.getElementById('lpK1').addEventListener('input', function() {
+ const k = parseFloat(this.value);
+ const f = k1ToHz(k, getSR());
+ document.getElementById('lpK1Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
+});
+document.getElementById('hpK2').addEventListener('input', function() {
+ const k = parseFloat(this.value);
+ const f = k2ToHz(k, getSR());
+ document.getElementById('hpK2Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
+});
+
+// Show/hide global resonator params when forceResonator toggled
+document.getElementById('forceResonator').addEventListener('change', function() {
+ document.getElementById('globalResParams').style.display = this.checked ? '' : 'none';
+});
+document.getElementById('globalR').addEventListener('input', function() {
+ document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4);
+});
+document.getElementById('globalGain').addEventListener('input', function() {
+ document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2);
+});
+let audioBuffer = null;
+let viewer = null;
+let audioContext = null;
+let currentSource = null;
+let extractedPartials = null;
+let stftCache = null;
+let exploreMode = false; // false | 'peak' | 'contour'
+
+function setExploreMode(mode) { // false | 'peak' | 'contour'
+ exploreMode = mode;
+ document.getElementById('exploreBtn').classList.toggle('explore-active', mode === 'peak');
+ document.getElementById('contourBtn').classList.toggle('contour-active', mode === 'contour');
+ if (viewer) viewer.setExploreMode(mode);
+}
+
+// Undo/redo
+const undoStack = [];
+const redoStack = [];
+
+function _updateUndoRedoBtns() {
+ document.getElementById('undoBtn').disabled = undoStack.length === 0;
+ document.getElementById('redoBtn').disabled = redoStack.length === 0;
+}
+
+function pushUndo() {
+ undoStack.push(JSON.parse(JSON.stringify(extractedPartials || [])));
+ redoStack.length = 0;
+ if (undoStack.length > 50) undoStack.shift();
+ _updateUndoRedoBtns();
+}
+
+function _applySnapshot(snap) {
+ extractedPartials = snap;
+ editor.setPartials(snap);
+ if (viewer) {
+ viewer.setPartials(snap);
+ viewer.setKeepCount(snap.length > 0 ? getKeepCount() : 0);
+ viewer.selectPartial(-1);
+ }
+ _updateUndoRedoBtns();
+}
+
+function undo() {
+ if (!undoStack.length) return;
+ redoStack.push(JSON.parse(JSON.stringify(extractedPartials || [])));
+ _applySnapshot(undoStack.pop());
+}
+
+function redo() {
+ if (!redoStack.length) return;
+ undoStack.push(JSON.parse(JSON.stringify(extractedPartials || [])));
+ _applySnapshot(redoStack.pop());
+}
+
+const wavFile = document.getElementById('wavFile');
+const chooseFileBtn = document.getElementById('chooseFileBtn');
+const extractBtn = document.getElementById('extractBtn');
+const autoSpreadAllBtn = document.getElementById('autoSpreadAllBtn');
+const playBtn = document.getElementById('playBtn');
+const stopBtn = document.getElementById('stopBtn');
+const canvas = document.getElementById('canvas');
+const status = document.getElementById('status');
+const fileLabel = document.getElementById('fileLabel');
+
+const hopSize = document.getElementById('hopSize');
+const threshold = document.getElementById('threshold');
+const prominence = document.getElementById('prominence');
+const freqWeightCb = document.getElementById('freqWeight');
+const birthPersistenceEl = document.getElementById('birthPersistence');
+const deathAgeEl = document.getElementById('deathAge');
+const phaseErrorWeightEl = document.getElementById('phaseErrorWeight');
+const minLengthEl = document.getElementById('minLength');
+const keepPct = document.getElementById('keepPct');
+const keepPctLabel = document.getElementById('keepPctLabel');
+const fftSize = 1024; // Fixed
+
+function getKeepCount() {
+ return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100));
+}
+
+keepPct.addEventListener('input', () => {
+ keepPctLabel.textContent = keepPct.value + '%';
+ if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount());
+});
+
+// --- Editor ---
+const editor = new PartialEditor();
+editor.onPartialDeleted = () => {
+ if (viewer && extractedPartials)
+ viewer.setKeepCount(extractedPartials.length > 0 ? getKeepCount() : 0);
+};
+editor.onBeforeChange = pushUndo;
+
+// Initialize audio context
+function initAudioContext() {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+}
+
+// Shared: initialize editor from an AudioBuffer
+function loadAudioBuffer(buffer, label) {
+ audioBuffer = buffer;
+ initAudioContext();
+ extractBtn.disabled = false;
+ playBtn.disabled = false;
+ setStatus('Computing STFT cache...', 'info');
+
+ // Reset partials from previous file
+ extractedPartials = null;
+ editor.setPartials(null);
+
+ setTimeout(() => {
+ const signal = audioBuffer.getChannelData(0);
+ stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, Math.max(64, parseInt(hopSize.value) || 64));
+ setStatus(`${label} — ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch (${stftCache.getNumFrames()} frames cached)`, 'info');
+
+ // Pre-compute peak frames so explore mode works immediately (before Extract)
+ const peakFrames = [];
+ for (let i = 0; i < stftCache.getNumFrames(); ++i) {
+ const f = stftCache.getFrameAtIndex(i);
+ peakFrames.push({
+ time: f.time,
+ peaks: detectPeaks(f.squaredAmplitude, f.phase, fftSize, audioBuffer.sampleRate,
+ parseFloat(threshold.value), freqWeightCb.checked,
+ parseFloat(prominence.value)),
+ });
+ }
+
+ viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache);
+ viewer.setFrames(peakFrames);
+ document.getElementById('exploreBtn').disabled = false;
+ document.getElementById('contourBtn').disabled = false;
+ editor.setViewer(viewer);
+ viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
+ viewer.onRender = () => editor.onRender();
+ viewer.onBeforeChange = pushUndo;
+ viewer.onExploreMove = (time, freq) => {
+ let partial = null;
+ if (exploreMode === 'peak') {
+ if (!viewer.frames || viewer.frames.length === 0) return;
+ partial = trackFromSeed(viewer.frames, time, freq, {
+ hopSize: Math.max(64, parseInt(hopSize.value) || 64),
+ sampleRate: audioBuffer.sampleRate,
+ deathAge: parseInt(deathAgeEl.value),
+ phaseErrorWeight: parseFloat(phaseErrorWeightEl.value),
+ });
+ } else if (exploreMode === 'contour') {
+ partial = trackIsoContour(stftCache, time, freq, {
+ sampleRate: audioBuffer.sampleRate,
+ deathAge: parseInt(deathAgeEl.value),
+ });
+ }
+ viewer.setPreviewPartial(partial);
+ };
+ viewer.onExploreCommit = (partial) => {
+ if (!extractedPartials) extractedPartials = [];
+ pushUndo();
+ const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
+ partial.replicas = { ...partial.replicas, spread_above, spread_below };
+ extractedPartials.unshift(partial);
+ editor.setPartials(extractedPartials);
+ viewer.setPartials(extractedPartials);
+ viewer.setKeepCount(getKeepCount());
+ viewer.selectPartial(0);
+ setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info');
+ };
+ if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache);
+ }, 10);
+}
+
+// File chooser button
+chooseFileBtn.addEventListener('click', () => wavFile.click());
+
+// Load WAV file
+wavFile.addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ fileLabel.textContent = file.name;
+ setStatus('Loading WAV...', 'info');
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ const ctx = new AudioContext();
+ const buf = await ctx.decodeAudioData(arrayBuffer);
+ loadAudioBuffer(buf, `Loaded: ${file.name}`);
+ } catch (err) {
+ setStatus('Error loading WAV: ' + err.message, 'error');
+ console.error(err);
+ }
+});
+
+// Test WAV: generate synthetic signal (two sine waves) in-memory
+document.getElementById('testWavBtn').addEventListener('click', () => {
+ initAudioContext();
+ const SR = 32000;
+ const duration = 2.0;
+ const numSamples = SR * duration;
+
+ // Two sine waves: 440 Hz (A4) + 660 Hz (E5, perfect fifth), equal amplitude
+ const buf = audioContext.createBuffer(1, numSamples, SR);
+ const data = buf.getChannelData(0);
+ for (let i = 0; i < numSamples; ++i) {
+ data[i] = 0.5 * Math.sin(2 * Math.PI * 440 * i / SR)
+ + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR);
+ }
+
+ fileLabel.textContent = 'test-440+660hz.wav';
+ loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)');
+});
+
+// Update cache when hop size changes
+hopSize.addEventListener('change', () => {
+ const val = Math.max(64, parseInt(hopSize.value) || 64);
+ hopSize.value = val;
+ if (stftCache) {
+ setStatus('Updating STFT cache...', 'info');
+ setTimeout(() => {
+ stftCache.setHopSize(val);
+ setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info');
+ if (viewer) viewer.render();
+ }, 10);
+ }
+});
+
+function runExtraction() {
+ if (!stftCache) return;
+
+ setStatus('Extracting partials...', 'info');
+ extractBtn.disabled = true;
+
+ setTimeout(() => {
+ try {
+ const params = {
+ fftSize: fftSize,
+ hopSize: parseInt(hopSize.value),
+ threshold: parseFloat(threshold.value),
+ prominence: parseFloat(prominence.value),
+ freqWeight: freqWeightCb.checked,
+ birthPersistence: parseInt(birthPersistenceEl.value),
+ deathAge: parseInt(deathAgeEl.value),
+ phaseErrorWeight: parseFloat(phaseErrorWeightEl.value),
+ minLength: parseInt(minLengthEl.value),
+ sampleRate: audioBuffer.sampleRate
+ };
+
+ const result = extractPartials(params, stftCache);
+
+ // Sort by decreasing peak amplitude
+ result.partials.sort((a, b) => {
+ const peakA = a.amps.reduce((m, v) => Math.max(m, v), 0);
+ const peakB = b.amps.reduce((m, v) => Math.max(m, v), 0);
+ return peakB - peakA;
+ });
+
+ extractedPartials = result.partials;
+ editor.setPartials(result.partials);
+ viewer.setFrames(result.frames);
+ setStatus(`Extracted ${result.partials.length} partials`, 'info');
+ viewer.setPartials(result.partials);
+ viewer.setKeepCount(getKeepCount());
+ viewer.selectPartial(-1);
+
+ } catch (err) {
+ setStatus('Extraction error: ' + err.message, 'error');
+ console.error(err);
+ }
+ extractBtn.disabled = false;
+ autoSpreadAllBtn.disabled = false;
+ document.getElementById('newPartialBtn').disabled = false;
+ document.getElementById('clearAllBtn').disabled = false;
+ undoStack.length = 0; redoStack.length = 0; _updateUndoRedoBtns();
+ }, 50);
+}
+
+extractBtn.addEventListener('click', () => {
+ if (!audioBuffer) return;
+ runExtraction();
+});
+
+function createNewPartial() {
+ if (!audioBuffer || !extractedPartials) return;
+ pushUndo();
+ const dur = audioBuffer.duration;
+ const newPartial = {
+ times: [0, dur],
+ freqs: [440, 440],
+ amps: [1.0, 1.0],
+ phases: [0, 0],
+ muted: false,
+ freqCurve: {
+ t0: 0, t1: dur / 3, t2: dur * 2 / 3, t3: dur,
+ v0: 440, v1: 440, v2: 440, v3: 440,
+ a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0,
+ },
+ replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ };
+ extractedPartials.unshift(newPartial);
+ editor.setPartials(extractedPartials);
+ if (viewer) {
+ viewer.setPartials(extractedPartials);
+ viewer.setKeepCount(getKeepCount());
+ viewer.selectPartial(0);
+ }
+}
+
+function clearAllPartials() {
+ if (!extractedPartials || extractedPartials.length === 0) return;
+ pushUndo();
+ extractedPartials = [];
+ editor.setPartials([]);
+ if (viewer) {
+ viewer.setPartials([]);
+ viewer.setKeepCount(0);
+ viewer.selectPartial(-1);
+ }
+}
+
+document.getElementById('newPartialBtn').addEventListener('click', createNewPartial);
+document.getElementById('clearAllBtn').addEventListener('click', clearAllPartials);
+document.getElementById('exploreBtn').addEventListener('click', () => setExploreMode(exploreMode === 'peak' ? false : 'peak'));
+document.getElementById('contourBtn').addEventListener('click', () => setExploreMode(exploreMode === 'contour' ? false : 'contour'));
+document.getElementById('undoBtn').addEventListener('click', undo);
+document.getElementById('redoBtn').addEventListener('click', redo);
+
+autoSpreadAllBtn.addEventListener('click', () => {
+ if (!extractedPartials || !stftCache) return;
+ const fs = stftCache.fftSize;
+ const sr = audioBuffer.sampleRate;
+ const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ for (const p of extractedPartials) {
+ const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr);
+ if (!p.replicas) p.replicas = { ...defaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ }
+ if (viewer) viewer.render();
+ const sel = viewer ? viewer.selectedPartial : -1;
+ if (sel >= 0) editor.onPartialSelect(sel);
+ setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info');
+});
+
+threshold.addEventListener('change', () => {
+ if (stftCache) runExtraction();
+});
+
+freqWeightCb.addEventListener('change', () => {
+ if (stftCache) runExtraction();
+});
+
+for (const el of [birthPersistenceEl, deathAgeEl, phaseErrorWeightEl, minLengthEl]) {
+ el.addEventListener('change', () => { if (stftCache) runExtraction(); });
+}
+
+function playAudioBuffer(buffer, statusMsg) {
+ const startTime = audioContext.currentTime;
+ currentSource = audioContext.createBufferSource();
+ currentSource.buffer = buffer;
+ currentSource.connect(audioContext.destination);
+ currentSource.start();
+ currentSource.onended = () => {
+ currentSource = null;
+ playBtn.disabled = false;
+ stopBtn.disabled = true;
+ viewer.setPlayheadTime(-1);
+ setStatus('Stopped', 'info');
+ };
+ playBtn.disabled = true;
+ stopBtn.disabled = false;
+ setStatus(statusMsg, 'info');
+ function tick() {
+ if (!currentSource) return;
+ viewer.setPlayheadTime(audioContext.currentTime - startTime);
+ requestAnimationFrame(tick);
+ }
+ tick();
+}
+
+function stopAudio() {
+ if (currentSource) {
+ try { currentSource.stop(); } catch (e) {}
+ currentSource = null;
+ }
+ if (viewer) viewer.setPlayheadTime(-1);
+ playBtn.disabled = false;
+ stopBtn.disabled = true;
+ setStatus('Stopped', 'info');
+}
+
+// Play audio
+playBtn.addEventListener('click', () => {
+ if (!audioBuffer || !audioContext) return;
+ stopAudio();
+ playAudioBuffer(audioBuffer, 'Playing...');
+});
+
+// Stop audio
+stopBtn.addEventListener('click', () => {
+ stopAudio();
+});
+
+function setStatus(msg, type = '') {
+ status.innerHTML = msg;
+ status.className = type;
+}
+
+function getSynthParams() {
+ const forceResonator = document.getElementById('forceResonator').checked;
+ const lpK1Raw = parseFloat(document.getElementById('lpK1').value);
+ const hpK2Raw = parseFloat(document.getElementById('hpK2').value);
+ return {
+ integratePhase: document.getElementById('integratePhase').checked,
+ opts: {
+ disableJitter: document.getElementById('disableJitter').checked,
+ disableSpread: document.getElementById('disableSpread').checked,
+ forceResonator,
+ forceRGain: forceResonator && document.getElementById('forceRGain').checked,
+ globalR: parseFloat(document.getElementById('globalR').value),
+ globalGain: parseFloat(document.getElementById('globalGain').value),
+ k1: lpK1Raw < 1.0 ? lpK1Raw : null,
+ k2: hpK2Raw < 1.0 ? hpK2Raw : null,
+ },
+ };
+}
+
+// Play synthesized audio
+function playSynthesized() {
+ if (!extractedPartials || extractedPartials.length === 0) {
+ setStatus('No partials extracted yet', 'warn');
+ return;
+ }
+ if (!audioBuffer || !audioContext) return;
+
+ stopAudio();
+
+ setStatus('Synthesizing...', 'info');
+
+ const keepCount = getKeepCount();
+ const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted);
+ setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info');
+
+ const {integratePhase, opts} = getSynthParams();
+ const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration,
+ integratePhase, opts);
+
+ if (viewer) {
+ viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value)));
+ }
+
+ const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate);
+ synthBuffer.getChannelData(0).set(pcm);
+ playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`);
+}
+
+// Keyboard shortcuts
+document.addEventListener('keydown', (e) => {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+ if (e.ctrlKey && e.code === 'KeyZ' && !e.shiftKey) {
+ e.preventDefault(); undo(); return;
+ } else if (e.ctrlKey && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
+ e.preventDefault(); redo(); return;
+ } else if (e.code === 'KeyN' && !e.ctrlKey && !e.metaKey) {
+ e.preventDefault(); createNewPartial(); return;
+ }
+ if (e.code === 'Digit1') {
+ e.preventDefault();
+ playSynthesized();
+ } else if (e.code === 'Digit2') {
+ e.preventDefault();
+ if (!playBtn.disabled) {
+ playBtn.click();
+ }
+ } else if (e.code === 'Digit3') {
+ e.preventDefault();
+ const sel = viewer ? viewer.selectedPartial : -1;
+ if (sel < 0 || !extractedPartials || !audioBuffer || !audioContext) return;
+ const partial = extractedPartials[sel];
+ if (!partial) return;
+ stopAudio();
+ const {integratePhase, opts} = getSynthParams();
+ const pcm = synthesizeMQ([partial], audioBuffer.sampleRate, audioBuffer.duration,
+ integratePhase, opts);
+ const buf = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate);
+ buf.getChannelData(0).set(pcm);
+ playAudioBuffer(buf, `Playing partial #${sel}...`);
+ } else if (e.code === 'KeyP') {
+ e.preventDefault();
+ if (viewer) viewer.togglePeaks();
+ } else if (e.code === 'KeyA') {
+ e.preventDefault();
+ if (viewer) {
+ viewer.showSynthFFT = !viewer.showSynthFFT;
+ viewer.renderSpectrum();
+ }
+ } else if (e.code === 'KeyE') {
+ e.preventDefault();
+ if (!extractBtn.disabled) extractBtn.click();
+ } else if (e.code === 'KeyX' && !e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ if (!document.getElementById('exploreBtn').disabled) setExploreMode(exploreMode === 'peak' ? false : 'peak');
+ } else if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ if (!document.getElementById('contourBtn').disabled) setExploreMode(exploreMode === 'contour' ? false : 'contour');
+ } else if (e.code === 'Escape') {
+ if (exploreMode) { setExploreMode(false); return; }
+ if (viewer) viewer.selectPartial(-1);
+ }
+});
+
+// Curve tab switching
+document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
+ document.getElementById('tab' + btn.dataset.tab).style.display = '';
+ });
+});
+
+// --- Test WAV peak validation ---
+function validateTestWAVPeaks(cache) {
+ const SR = cache.sampleRate;
+ const N = cache.fftSize;
+ const binWidth = SR / N; // Hz per bin
+ const numBins = N / 2;
+ const numBars = 100; // mini-spectrum bar count
+
+ // Use a mid-signal frame (avoid edge effects)
+ const midFrame = cache.frames[Math.floor(cache.frames.length / 2)];
+ if (!midFrame) { console.error('[TestWAV] No frames computed'); return; }
+ const sq = midFrame.squaredAmplitude;
+ const t = midFrame.time;
+
+ console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's');
+
+ // Top 5 bins by magnitude
+ const ranked = Array.from(sq)
+ .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) }))
+ .sort((a, b) => b.db - a.db);
+ console.log('Top 5 FFT bins:');
+ ranked.slice(0, 5).forEach(x =>
+ console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`));
+
+ // Expected bins for 440/660 Hz
+ const bin440 = Math.round(440 / binWidth);
+ const bin660 = Math.round(660 / binWidth);
+ const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20));
+ const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20));
+ console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`);
+ console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`);
+
+ // Validate: 440/660 Hz must be in top-10
+ const top10Freqs = ranked.slice(0, 10).map(x => x.freq);
+ const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2);
+ const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2);
+ console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') +
+ ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗'));
+
+ // Mini-spectrum: which bar do these peaks land in?
+ const bar440 = Math.floor(bin440 * numBars / numBins);
+ const bar660 = Math.floor(bin660 * numBars / numBins);
+ const sampledBin440 = Math.floor(bar440 * numBins / numBars);
+ const sampledBin660 = Math.floor(bar660 * numBars / numBars);
+ console.log('Mini-spectrum (linear scale, 100 bars):');
+ console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`);
+ console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`);
+ if (bar440 < 5 || bar660 < 5) {
+ console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 +
+ ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' +
+ ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.');
+ }
+
+ // Main spectrogram: confirm bins are in draw range
+ const mainFreqStart = 20, mainFreqEnd = 16000;
+ const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd;
+ const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd;
+ const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart));
+ const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart));
+ console.log('Main spectrogram (log Y-axis, 600px):');
+ console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`);
+ console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`);
+
+ console.groupEnd();
+}