diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 17:59:49 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 17:59:49 +0100 |
| commit | 49e4e374bc51517d8119e4c82d46e3a94ea0b75b (patch) | |
| tree | e0ac136257faba9dc322978a5cd1df52012541c5 /tools/mq_editor/mq_extract.js | |
| parent | b9f6429b6cc741c5240c1fea8bbf4c6244e4e5d1 (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.js | 96 |
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; |
