diff options
Diffstat (limited to 'tools/mq_editor/app.js')
| -rw-r--r-- | tools/mq_editor/app.js | 232 |
1 files changed, 88 insertions, 144 deletions
diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index 59849da..1c6d548 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -4,19 +4,36 @@ 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); + return Math.acos(clamp(cosW, -1, 1)) * 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); + return Math.acos(clamp(cosW, -1, 1)) * 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; } +// Params dropdown toggle +(function() { + const btn = document.getElementById('paramsBtn'); + const panel = document.getElementById('paramsPanel'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const open = panel.classList.toggle('open'); + btn.classList.toggle('params-open', open); + }); + document.addEventListener('click', (e) => { + if (!panel.contains(e.target) && e.target !== btn) { + panel.classList.remove('open'); + btn.classList.remove('params-open'); + } + }); +})(); + // LP/HP slider live display document.getElementById('lpK1').addEventListener('input', function() { const k = parseFloat(this.value); @@ -32,13 +49,18 @@ document.getElementById('hpK2').addEventListener('input', function() { // Show/hide global resonator params when forceResonator toggled document.getElementById('forceResonator').addEventListener('change', function() { document.getElementById('globalResParams').style.display = this.checked ? '' : 'none'; + if (viewer) viewer.render(); }); document.getElementById('globalR').addEventListener('input', function() { document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4); + if (viewer) viewer.render(); }); document.getElementById('globalGain').addEventListener('input', function() { document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2); + if (viewer) viewer.render(); }); +document.getElementById('forceRGain').addEventListener('change', () => { if (viewer) viewer.render(); }); + let audioBuffer = null; let viewer = null; let audioContext = null; @@ -70,14 +92,18 @@ function pushUndo() { _updateUndoRedoBtns(); } -function _applySnapshot(snap) { - extractedPartials = snap; - editor.setPartials(snap); +function refreshPartialsView(selectIdx = -1) { + editor.setPartials(extractedPartials); if (viewer) { - viewer.setPartials(snap); - viewer.setKeepCount(snap.length > 0 ? getKeepCount() : 0); - viewer.selectPartial(-1); + viewer.setPartials(extractedPartials); + viewer.setKeepCount(extractedPartials && extractedPartials.length > 0 ? getKeepCount() : 0); + viewer.selectPartial(selectIdx); } +} + +function _applySnapshot(snap) { + extractedPartials = snap; + refreshPartialsView(); _updateUndoRedoBtns(); } @@ -168,12 +194,14 @@ function loadAudioBuffer(buffer, label) { }); } + if (viewer) viewer.destroy(); 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.onGetSynthOpts = () => getSynthParams().opts; + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); viewer.onRender = () => editor.onRender(); viewer.onBeforeChange = pushUndo; viewer.onExploreMove = (time, freq) => { @@ -197,16 +225,13 @@ function loadAudioBuffer(buffer, label) { 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 }; + const {spread} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); + if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; + partial.harmonics.spread = spread; extractedPartials.unshift(partial); - editor.setPartials(extractedPartials); - viewer.setPartials(extractedPartials); - viewer.setKeepCount(getKeepCount()); - viewer.selectPartial(0); + refreshPartialsView(0); setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info'); }; - if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); }, 10); } @@ -231,25 +256,6 @@ wavFile.addEventListener('change', async (e) => { } }); -// 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); @@ -295,12 +301,10 @@ function runExtraction() { }); extractedPartials = result.partials; - editor.setPartials(result.partials); + autoSpreadAll(); viewer.setFrames(result.frames); setStatus(`Extracted ${result.partials.length} partials`, 'info'); - viewer.setPartials(result.partials); - viewer.setKeepCount(getKeepCount()); - viewer.selectPartial(-1); + refreshPartialsView(); } catch (err) { setStatus('Extraction error: ' + err.message, 'error'); @@ -334,27 +338,17 @@ function createNewPartial() { 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 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }, }; extractedPartials.unshift(newPartial); - editor.setPartials(extractedPartials); - if (viewer) { - viewer.setPartials(extractedPartials); - viewer.setKeepCount(getKeepCount()); - viewer.selectPartial(0); - } + refreshPartialsView(0); } function clearAllPartials() { if (!extractedPartials || extractedPartials.length === 0) return; pushUndo(); extractedPartials = []; - editor.setPartials([]); - if (viewer) { - viewer.setPartials([]); - viewer.setKeepCount(0); - viewer.selectPartial(-1); - } + refreshPartialsView(); } document.getElementById('newPartialBtn').addEventListener('click', createNewPartial); @@ -364,22 +358,23 @@ document.getElementById('contourBtn').addEventListener('click', () => setExplore document.getElementById('undoBtn').addEventListener('click', undo); document.getElementById('redoBtn').addEventListener('click', redo); -autoSpreadAllBtn.addEventListener('click', () => { +function autoSpreadAll() { 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 }; + const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 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; + const {spread} = autodetectSpread(p, stftCache, fs, sr); + if (!p.harmonics) p.harmonics = { ...defaults }; + p.harmonics.spread = spread; } 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'); -}); +} + +autoSpreadAllBtn.addEventListener('click', autoSpreadAll); threshold.addEventListener('change', () => { if (stftCache) runExtraction(); @@ -428,12 +423,14 @@ function stopAudio() { setStatus('Stopped', 'info'); } -// Play audio -playBtn.addEventListener('click', () => { +function playOriginal() { if (!audioBuffer || !audioContext) return; stopAudio(); playAudioBuffer(audioBuffer, 'Playing...'); -}); +} + +// Play audio +playBtn.addEventListener('click', playOriginal); // Stop audio stopBtn.addEventListener('click', () => { @@ -464,6 +461,25 @@ function getSynthParams() { }; } +// Synthesize partials and return an AudioBuffer, trimmed to [t_start-margin, t_end+margin] +function getAudioBuffer(partials, margin = 0) { + const sr = audioBuffer.sampleRate; + const {integratePhase, opts} = getSynthParams(); + const pcm = synthesizeMQ(partials, sr, audioBuffer.duration, integratePhase, opts); + let startSample = 0, endSample = pcm.length; + if (margin >= 0 && partials.length > 0) { + const times = partials.flatMap(p => p.times); + const tStart = Math.min(...times); + const tEnd = Math.max(...times); + startSample = Math.max(0, Math.floor((tStart - margin) * sr)); + endSample = Math.min(pcm.length, Math.ceil((tEnd + margin) * sr)); + } + const trimmed = pcm.subarray(startSample, endSample); + const buf = audioContext.createBuffer(1, trimmed.length, sr); + buf.getChannelData(0).set(trimmed); + return buf; +} + // Play synthesized audio function playSynthesized() { if (!extractedPartials || extractedPartials.length === 0) { @@ -480,16 +496,14 @@ function playSynthesized() { 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) { + const {integratePhase, opts} = getSynthParams(); + const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, + integratePhase, opts); 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); + const synthBuffer = getAudioBuffer(partialsToUse); playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`); } @@ -505,12 +519,10 @@ document.addEventListener('keydown', (e) => { } if (e.code === 'Digit1') { e.preventDefault(); - playSynthesized(); + if (!playBtn.disabled) playOriginal(); } else if (e.code === 'Digit2') { e.preventDefault(); - if (!playBtn.disabled) { - playBtn.click(); - } + playSynthesized(); } else if (e.code === 'Digit3') { e.preventDefault(); const sel = viewer ? viewer.selectedPartial : -1; @@ -518,12 +530,7 @@ document.addEventListener('keydown', (e) => { 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}...`); + playAudioBuffer(getAudioBuffer([partial], 0.05), `Playing partial #${sel}...`); } else if (e.code === 'KeyP') { e.preventDefault(); if (viewer) viewer.togglePeaks(); @@ -542,6 +549,9 @@ document.addEventListener('keydown', (e) => { } 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 === 'Delete' || e.code === 'Backspace') { + e.preventDefault(); + document.getElementById('deletePartialBtn').click(); } else if (e.code === 'Escape') { if (exploreMode) { setExploreMode(false); return; } if (viewer) viewer.selectPartial(-1); @@ -557,69 +567,3 @@ document.querySelectorAll('.tab-btn').forEach(btn => { 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(); -} |
