From c5ee9ed388013133fe1e028c403370242c558e2d Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 18 Feb 2026 18:12:48 +0100 Subject: feat(mq_editor): add iso-contour tracking mode for bass/diffuse regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trackIsoContour() follows constant energy level through STFT frames instead of peaks. Useful for broad bass areas where peak detector finds nothing. Preview in cyan, auto-detects spread on commit (naturally large). Toggle: ≋ Contour button or C key. Mutually exclusive with ⊕ Explore. Co-Authored-By: Claude Sonnet 4.6 --- tools/mq_editor/app.js | 51 ++++++++++++++--------- tools/mq_editor/index.html | 2 + tools/mq_editor/mq_extract.js | 94 +++++++++++++++++++++++++++++++++++++++++++ tools/mq_editor/viewer.js | 9 +++-- 4 files changed, 135 insertions(+), 21 deletions(-) (limited to 'tools/mq_editor') diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index 62c1bb7..59849da 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -45,13 +45,13 @@ let audioContext = null; let currentSource = null; let extractedPartials = null; let stftCache = null; -let exploreModeActive = false; +let exploreMode = false; // false | 'peak' | 'contour' -function setExploreMode(enabled) { - exploreModeActive = enabled; - const btn = document.getElementById('exploreBtn'); - btn.classList.toggle('explore-active', enabled); - if (viewer) viewer.setExploreMode(enabled); +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 @@ -171,29 +171,40 @@ function loadAudioBuffer(buffer, label) { 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) => { - if (!viewer.frames || viewer.frames.length === 0) return; - const params = { - hopSize: Math.max(64, parseInt(hopSize.value) || 64), - sampleRate: audioBuffer.sampleRate, - deathAge: parseInt(deathAgeEl.value), - phaseErrorWeight: parseFloat(phaseErrorWeightEl.value), - }; - viewer.setPreviewPartial(trackFromSeed(viewer.frames, time, freq, params)); + 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(`Explore: added partial (${extractedPartials.length} total)`, 'info'); + setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info'); }; if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); }, 10); @@ -348,7 +359,8 @@ function clearAllPartials() { document.getElementById('newPartialBtn').addEventListener('click', createNewPartial); document.getElementById('clearAllBtn').addEventListener('click', clearAllPartials); -document.getElementById('exploreBtn').addEventListener('click', () => setExploreMode(!exploreModeActive)); +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); @@ -526,9 +538,12 @@ document.addEventListener('keydown', (e) => { if (!extractBtn.disabled) extractBtn.click(); } else if (e.code === 'KeyX' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); - if (!document.getElementById('exploreBtn').disabled) setExploreMode(!exploreModeActive); + 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 (exploreModeActive) { setExploreMode(false); return; } + if (exploreMode) { setExploreMode(false); return; } if (viewer) viewer.selectPartial(-1); } }); diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 9d50044..dea6e50 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -42,6 +42,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } #extractBtn { background: #666; color: #fff; font-weight: bold; border-color: #888; } button.explore-active { background: #554; border-color: #aa8; color: #ffd; } + button.contour-active { background: #145; border-color: #0cc; color: #aff; } input[type="file"] { display: none; } .params { display: flex; @@ -288,6 +289,7 @@ + diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 8f6961f..ff38d63 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -432,6 +432,100 @@ function trackFromSeed(frames, seedTime, seedFreq, params) { }; } +// Track an iso-energy contour starting from (seedTime, seedFreq). +// Instead of following spectral peaks, follows where energy ≈ seedEnergy. +// Useful for broad/diffuse bass regions with no detectable peaks. +// Returns a partial with large default spread, or null if seed energy is zero. +function trackIsoContour(stftCache, seedTime, seedFreq, params) { + const { sampleRate, deathAge = 8 } = params; + const numFrames = stftCache.getNumFrames(); + const fftSize = stftCache.fftSize; + const binHz = sampleRate / fftSize; + const halfBins = fftSize / 2; + + // Find seed frame + let seedFrameIdx = 0, bestDt = Infinity; + for (let i = 0; i < numFrames; ++i) { + const dt = Math.abs(stftCache.getFrameAtIndex(i).time - seedTime); + if (dt < bestDt) { bestDt = dt; seedFrameIdx = i; } + } + + const seedFrame = stftCache.getFrameAtIndex(seedFrameIdx); + const seedBin = Math.max(1, Math.min(halfBins - 2, Math.round(seedFreq / binHz))); + const targetSq = seedFrame.squaredAmplitude[seedBin]; + if (targetSq <= 0) return null; + const targetDB = 10 * Math.log10(targetSq); + + const trackingRatio = 0.15; // larger search window than peak tracker + const minTrackHz = 30; + const maxDbDev = 15; // dB: declare miss if nothing within this range + + // Find bin minimizing |dB(b) - targetDB| near refBin, with mild position bias. + function findContourBin(sq, refBin) { + const tol = Math.max(refBin * binHz * trackingRatio, minTrackHz); + const tolBins = Math.ceil(tol / binHz); + const lo = Math.max(1, refBin - tolBins); + const hi = Math.min(halfBins - 2, refBin + tolBins); + let bestBin = -1, bestCost = Infinity; + for (let b = lo; b <= hi; ++b) { + const dE = Math.abs(10 * Math.log10(Math.max(sq[b], 1e-20)) - targetDB); + if (dE > maxDbDev) continue; + const dPos = Math.abs(b - refBin) / Math.max(1, tolBins); + const cost = dE + 3 * dPos; // energy match dominates, position breaks ties + if (cost < bestCost) { bestCost = cost; bestBin = b; } + } + return bestBin; + } + + const times = [seedFrame.time]; + const freqs = [seedBin * binHz]; + const amps = [Math.sqrt(Math.max(0, targetSq))]; + + // Forward pass + let fwdBin = seedBin, fwdAge = 0; + for (let i = seedFrameIdx + 1; i < numFrames; ++i) { + const frame = stftCache.getFrameAtIndex(i); + const b = findContourBin(frame.squaredAmplitude, fwdBin); + if (b >= 0) { + times.push(frame.time); + freqs.push(b * binHz); + amps.push(Math.sqrt(Math.max(0, frame.squaredAmplitude[b]))); + fwdBin = b; fwdAge = 0; + } else { if (++fwdAge > deathAge) break; } + } + + // Backward pass + const bwdTimes = [], bwdFreqs = [], bwdAmps = []; + let bwdBin = seedBin, bwdAge = 0; + for (let i = seedFrameIdx - 1; i >= 0; --i) { + const frame = stftCache.getFrameAtIndex(i); + const b = findContourBin(frame.squaredAmplitude, bwdBin); + if (b >= 0) { + bwdTimes.unshift(frame.time); + bwdFreqs.unshift(b * binHz); + bwdAmps.unshift(Math.sqrt(Math.max(0, frame.squaredAmplitude[b]))); + bwdBin = b; bwdAge = 0; + } else { if (++bwdAge > deathAge) break; } + } + + const allTimes = [...bwdTimes, ...times]; + const allFreqs = [...bwdFreqs, ...freqs]; + const allAmps = [...bwdAmps, ...amps]; + if (allTimes.length < 2) return null; + + const freqCurve = fitBezier(allTimes, allFreqs); + const ac = fitBezier(allTimes, allAmps); + freqCurve.a0 = ac.v0; freqCurve.a1 = ac.v1; + freqCurve.a2 = ac.v2; freqCurve.a3 = ac.v3; + + return { + times: allTimes, freqs: allFreqs, amps: allAmps, + phases: new Array(allTimes.length).fill(0), + muted: false, freqCurve, + replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 }, + }; +} + // Fit cubic bezier to trajectory using least-squares for inner control points function fitBezier(times, values) { const n = times.length - 1; diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 8f97c3b..c69d9e7 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -384,7 +384,9 @@ class SpectrogramViewer { const h = this.cursorCanvas.height; ctx.clearRect(0, 0, this.cursorCanvas.width, h); if (x < 0) return; - ctx.strokeStyle = this.exploreMode ? 'rgba(255,160,0,0.8)' : 'rgba(255,60,60,0.7)'; + ctx.strokeStyle = this.exploreMode === 'contour' ? 'rgba(0,220,220,0.8)' + : this.exploreMode ? 'rgba(255,160,0,0.8)' + : 'rgba(255,60,60,0.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, 0); @@ -410,11 +412,12 @@ class SpectrogramViewer { _drawPreviewPartial(ctx, partial) { const curve = partial.freqCurve; if (!curve) return; + const col = this.exploreMode === 'contour' ? '0,220,220' : '255,160,0'; ctx.save(); - ctx.strokeStyle = 'rgba(255,160,0,0.9)'; + ctx.strokeStyle = `rgba(${col},0.9)`; ctx.lineWidth = 2; ctx.setLineDash([6, 3]); - ctx.shadowColor = 'rgba(255,160,0,0.5)'; + ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 6; ctx.beginPath(); let started = false; -- cgit v1.2.3