// 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(); }