summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_extract.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 17:59:49 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 17:59:49 +0100
commit49e4e374bc51517d8119e4c82d46e3a94ea0b75b (patch)
treee0ac136257faba9dc322978a5cd1df52012541c5 /tools/mq_editor/mq_extract.js
parentb9f6429b6cc741c5240c1fea8bbf4c6244e4e5d1 (diff)
feat(mq_editor): add explore mode for interactive partial tracking
Hover to preview a tracked partial from mouse position (peak-snapped, forward+backward MQ tracking). Click to commit. Toggle with ⊕ Explore button or X key. Escape exits explore mode. handoff(Gemini): explore mode added in mq_extract.trackFromSeed + viewer.js/app.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor/mq_extract.js')
-rw-r--r--tools/mq_editor/mq_extract.js96
1 files changed, 96 insertions, 0 deletions
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 97191e2..8f6961f 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -336,6 +336,102 @@ function autodetectSpread(partial, stftCache, fftSize, sampleRate) {
return {spread_above, spread_below};
}
+// Track a single partial starting from a (time, freq) seed position.
+// Snaps to nearest spectral peak, then tracks forward and backward.
+// Returns a partial object (with freqCurve), or null if no peak found near seed.
+function trackFromSeed(frames, seedTime, seedFreq, params) {
+ if (!frames || frames.length === 0) return null;
+
+ // Find nearest frame to seedTime
+ let seedFrameIdx = 0;
+ let bestDt = Infinity;
+ for (let i = 0; i < frames.length; ++i) {
+ const dt = Math.abs(frames[i].time - seedTime);
+ if (dt < bestDt) { bestDt = dt; seedFrameIdx = i; }
+ }
+
+ // Snap to nearest spectral peak within 10% freq tolerance
+ const seedFrame = frames[seedFrameIdx];
+ const snapTol = Math.max(seedFreq * 0.10, 50);
+ let seedPeak = null, bestDist = snapTol;
+ for (const pk of seedFrame.peaks) {
+ const d = Math.abs(pk.freq - seedFreq);
+ if (d < bestDist) { bestDist = d; seedPeak = pk; }
+ }
+ if (!seedPeak) return null;
+
+ const { hopSize, sampleRate, deathAge = 5, phaseErrorWeight = 2.0 } = params;
+ const trackingRatio = 0.05;
+ const minTrackingHz = 20;
+
+ // Forward pass from seed frame
+ const times = [seedFrame.time];
+ const freqs = [seedPeak.freq];
+ const amps = [seedPeak.amp];
+ const phases = [seedPeak.phase];
+
+ let fwdFreq = seedPeak.freq, fwdPhase = seedPeak.phase, fwdVel = 0, fwdAge = 0;
+ for (let i = seedFrameIdx + 1; i < frames.length; ++i) {
+ const predicted = fwdFreq + fwdVel;
+ const predPhase = fwdPhase + 2 * Math.PI * fwdFreq * (fwdAge + 1) * hopSize / sampleRate;
+ const tol = Math.max(predicted * trackingRatio, minTrackingHz);
+ const { bestIdx } = findBestPeak(frames[i].peaks, new Set(), predicted, predPhase, tol, phaseErrorWeight);
+ if (bestIdx >= 0) {
+ const pk = frames[i].peaks[bestIdx];
+ times.push(frames[i].time);
+ freqs.push(pk.freq);
+ amps.push(pk.amp);
+ phases.push(pk.phase);
+ fwdVel = pk.freq - fwdFreq;
+ fwdFreq = pk.freq; fwdPhase = pk.phase; fwdAge = 0;
+ } else {
+ fwdAge++;
+ if (fwdAge > deathAge) break;
+ }
+ }
+
+ // Backward pass from seed frame
+ const bwdTimes = [], bwdFreqs = [], bwdAmps = [], bwdPhases = [];
+ let bwdFreq = seedPeak.freq, bwdAge = 0;
+ for (let i = seedFrameIdx - 1; i >= 0; --i) {
+ const tol = Math.max(bwdFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bDist = tol;
+ for (let j = 0; j < frames[i].peaks.length; ++j) {
+ const d = Math.abs(frames[i].peaks[j].freq - bwdFreq);
+ if (d < bDist) { bDist = d; bestIdx = j; }
+ }
+ if (bestIdx >= 0) {
+ const pk = frames[i].peaks[bestIdx];
+ bwdTimes.unshift(frames[i].time);
+ bwdFreqs.unshift(pk.freq);
+ bwdAmps.unshift(pk.amp);
+ bwdPhases.unshift(pk.phase);
+ bwdFreq = pk.freq; bwdAge = 0;
+ } else {
+ bwdAge++;
+ if (bwdAge > deathAge) break;
+ }
+ }
+
+ const allTimes = [...bwdTimes, ...times];
+ const allFreqs = [...bwdFreqs, ...freqs];
+ const allAmps = [...bwdAmps, ...amps];
+ const allPhases = [...bwdPhases, ...phases];
+
+ 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: allPhases,
+ muted: false, freqCurve,
+ replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ };
+}
+
// Fit cubic bezier to trajectory using least-squares for inner control points
function fitBezier(times, values) {
const n = times.length - 1;