diff options
Diffstat (limited to 'tools/mq_editor/app.js')
| -rw-r--r-- | tools/mq_editor/app.js | 625 |
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(); +} |
