summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 18:12:48 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 18:12:48 +0100
commitc5ee9ed388013133fe1e028c403370242c558e2d (patch)
treee86d07941c74b9953bf6f9c8e7203848b05ade0e /tools/mq_editor
parentd73414c6d42fbec2666cb24b65b1f597b335c1e9 (diff)
feat(mq_editor): add iso-contour tracking mode for bass/diffuse regions
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 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/app.js51
-rw-r--r--tools/mq_editor/index.html2
-rw-r--r--tools/mq_editor/mq_extract.js94
-rw-r--r--tools/mq_editor/viewer.js9
4 files changed, 135 insertions, 21 deletions
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 @@
<button id="newPartialBtn" disabled>+ Partial</button>
<button id="clearAllBtn" disabled>✕ Clear All</button>
<button id="exploreBtn" disabled>⊕ Explore</button>
+ <button id="contourBtn" disabled>≋ Contour</button>
<button id="undoBtn" disabled>↩ Undo</button>
<button id="redoBtn" disabled>↪ Redo</button>
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;