summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_extract.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 06:29:13 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 06:29:13 +0100
commit65cd99553cd688c5ad2cfd64d79c6434fe694a33 (patch)
tree4981b41f7dee72cdd6bf60789f1fa8383c5190e2 /tools/mq_editor/mq_extract.js
parentbf3929220be7eddf32cebe12573b870fc9b54997 (diff)
feat(mq_editor): validated dual-sine synthesis pipeline, clean code for real audio
- Fix incoherent per-sample jitter/spread: seed by partial index only - Fix || fallback for zero-valued params (use != null checks) - Phase integration: accumulator (2π*f/SR per sample) replaces 2π*f*t - Add 'Integrate phase' checkbox to toggle between modes - Revert Catmull-Rom back to simple bezier (1/3, 2/3 sample points) - Remove all debug logging, clean up trackPartials, fitBezier handoff(Gemini): dual-sine test validates full MQ pipeline (extract→track→synth). Next: real audio loading and partial detection improvements. 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.js167
1 files changed, 59 insertions, 108 deletions
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 878b2bc..2293d52 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -4,10 +4,9 @@
// Extract partials from audio buffer
function extractPartials(params, stftCache) {
const {fftSize, threshold, sampleRate} = params;
+ const numFrames = stftCache.getNumFrames();
- // Analyze frames from cache
const frames = [];
- const numFrames = stftCache.getNumFrames();
for (let i = 0; i < numFrames; ++i) {
const cachedFrame = stftCache.getFrameAtIndex(i);
const squaredAmp = stftCache.getSquaredAmplitude(cachedFrame.time);
@@ -15,10 +14,8 @@ function extractPartials(params, stftCache) {
frames.push({time: cachedFrame.time, peaks});
}
- // Track trajectories
- const partials = trackPartials(frames, sampleRate);
+ const partials = trackPartials(frames);
- // Fit bezier curves
for (const partial of partials) {
partial.freqCurve = fitBezier(partial.times, partial.freqs);
partial.ampCurve = fitBezier(partial.times, partial.amps);
@@ -27,15 +24,14 @@ function extractPartials(params, stftCache) {
return {partials, frames};
}
-// Detect peaks in FFT frame (squaredAmp is pre-computed cached re*re+im*im)
+// Detect spectral peaks via local maxima + parabolic interpolation
+// squaredAmp: pre-computed re*re+im*im per bin
function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
- // Convert squared amplitude to dB (10*log10 == 20*log10 of magnitude)
const mag = new Float32Array(fftSize / 2);
for (let i = 0; i < fftSize / 2; ++i) {
mag[i] = 10 * Math.log10(Math.max(squaredAmp[i], 1e-20));
}
- // Find local maxima above threshold
const peaks = [];
for (let i = 2; i < mag.length - 2; ++i) {
if (mag[i] > thresholdDB &&
@@ -44,15 +40,13 @@ function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
// Parabolic interpolation for sub-bin accuracy
const alpha = mag[i-1];
- const beta = mag[i];
+ const beta = mag[i];
const gamma = mag[i+1];
const p = 0.5 * (alpha - gamma) / (alpha - 2*beta + gamma);
- const binFreq = (i + p) * sampleRate / fftSize;
+ const freq = (i + p) * sampleRate / fftSize;
const ampDB = beta - 0.25 * (alpha - gamma) * p;
- const ampLin = Math.pow(10, ampDB / 20);
-
- peaks.push({freq: binFreq, amp: ampLin});
+ peaks.push({freq, amp: Math.pow(10, ampDB / 20)});
}
}
@@ -60,152 +54,109 @@ function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
}
// Track partials across frames (birth/death/continuation)
-function trackPartials(frames, sampleRate) {
- const partials = [];
- const activePartials = [];
- const candidatePartials = []; // Pre-birth candidates
- const trackingThresholdRatio = 0.05; // 5% frequency tolerance
- const minTrackingHz = 20; // Minimum 20 Hz
- const birthPersistence = 3; // Require 3 consecutive frames to birth
- const deathAge = 5; // Allow 5 frame gap before death
- const minPartialLength = 10; // Minimum 10 frames for valid partial
+function trackPartials(frames) {
+ const partials = [];
+ const activePartials = [];
+ const candidates = []; // pre-birth
+
+ const trackingRatio = 0.05; // 5% frequency tolerance
+ const minTrackingHz = 20;
+ const birthPersistence = 3; // frames before partial is born
+ const deathAge = 5; // frames without match before death
+ const minLength = 10; // frames required to keep partial
for (const frame of frames) {
const matched = new Set();
- // Match peaks to existing partials
+ // Continue active partials
for (const partial of activePartials) {
const lastFreq = partial.freqs[partial.freqs.length - 1];
- const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
-
- let bestPeak = null;
- let bestDist = Infinity;
+ const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestDist = Infinity;
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- const dist = Math.abs(peak.freq - lastFreq);
-
- if (dist < threshold && dist < bestDist) {
- bestPeak = peak;
- bestDist = dist;
- partial.matchIdx = i;
- }
+ const dist = Math.abs(frame.peaks[i].freq - lastFreq);
+ if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = i; }
}
- if (bestPeak) {
- // Continuation
+ if (bestIdx >= 0) {
+ const pk = frame.peaks[bestIdx];
partial.times.push(frame.time);
- partial.freqs.push(bestPeak.freq);
- partial.amps.push(bestPeak.amp);
+ partial.freqs.push(pk.freq);
+ partial.amps.push(pk.amp);
partial.age = 0;
- matched.add(partial.matchIdx);
+ matched.add(bestIdx);
} else {
- // No match
partial.age++;
}
}
- // Update candidate partials (pre-birth)
- for (let i = candidatePartials.length - 1; i >= 0; --i) {
- const candidate = candidatePartials[i];
- const lastFreq = candidate.freqs[candidate.freqs.length - 1];
- const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
-
- let bestPeak = null;
- let bestDist = Infinity;
-
- for (let i = 0; i < frame.peaks.length; ++i) {
- if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- const dist = Math.abs(peak.freq - lastFreq);
+ // Advance candidates
+ for (let i = candidates.length - 1; i >= 0; --i) {
+ const cand = candidates[i];
+ const lastFreq = cand.freqs[cand.freqs.length - 1];
+ const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestDist = Infinity;
- if (dist < threshold && dist < bestDist) {
- bestPeak = peak;
- bestDist = dist;
- candidate.matchIdx = i;
- }
+ for (let j = 0; j < frame.peaks.length; ++j) {
+ if (matched.has(j)) continue;
+ const dist = Math.abs(frame.peaks[j].freq - lastFreq);
+ if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = j; }
}
- if (bestPeak) {
- candidate.times.push(frame.time);
- candidate.freqs.push(bestPeak.freq);
- candidate.amps.push(bestPeak.amp);
- matched.add(candidate.matchIdx);
-
- // Birth if persistent enough
- if (candidate.times.length >= birthPersistence) {
- activePartials.push(candidate);
- candidatePartials.splice(i, 1);
+ if (bestIdx >= 0) {
+ const pk = frame.peaks[bestIdx];
+ cand.times.push(frame.time);
+ cand.freqs.push(pk.freq);
+ cand.amps.push(pk.amp);
+ matched.add(bestIdx);
+ if (cand.times.length >= birthPersistence) {
+ activePartials.push(cand);
+ candidates.splice(i, 1);
}
} else {
- // Candidate died, remove
- candidatePartials.splice(i, 1);
+ candidates.splice(i, 1);
}
}
- // Create new candidate partials from unmatched peaks
+ // Spawn candidates from unmatched peaks
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- candidatePartials.push({
- times: [frame.time],
- freqs: [peak.freq],
- amps: [peak.amp],
- age: 0,
- matchIdx: -1
- });
+ const pk = frame.peaks[i];
+ candidates.push({times: [frame.time], freqs: [pk.freq], amps: [pk.amp], age: 0});
}
- // Death old partials
+ // Kill aged-out partials
for (let i = activePartials.length - 1; i >= 0; --i) {
if (activePartials[i].age > deathAge) {
- // Move to finished if long enough
- if (activePartials[i].times.length >= minPartialLength) {
- partials.push(activePartials[i]);
- }
+ if (activePartials[i].times.length >= minLength) partials.push(activePartials[i]);
activePartials.splice(i, 1);
}
}
}
- // Finish remaining active partials
+ // Collect remaining active partials
for (const partial of activePartials) {
- if (partial.times.length >= minPartialLength) {
- partials.push(partial);
- }
+ if (partial.times.length >= minLength) partials.push(partial);
}
return partials;
}
-// Fit cubic bezier curve to trajectory using Catmull-Rom tangents.
-// Estimates end tangents via forward/backward differences, then converts
-// Hermite form to Bezier: B1 = P0 + m0*dt/3, B2 = P3 - m3*dt/3.
-// This guarantees the curve passes through both endpoints.
+// Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points
function fitBezier(times, values) {
const n = times.length - 1;
- const t0 = times[0], v0 = values[0];
- const t3 = times[n], v3 = values[n];
+ const t0 = times[0], v0 = values[0];
+ const t3 = times[n], v3 = values[n];
const dt = t3 - t0;
if (dt <= 0 || n === 0) {
return {t0, v0, t1: t0, v1: v0, t2: t3, v2: v3, t3, v3};
}
- // Catmull-Rom endpoint tangents (forward diff at start, backward at end)
- const dt0 = times[1] - times[0];
- const m0 = dt0 > 0 ? (values[1] - values[0]) / dt0 : 0;
-
- const dtn = times[n] - times[n - 1];
- const m3 = dtn > 0 ? (values[n] - values[n - 1]) / dtn : 0;
-
- // Hermite -> Bezier control points
- const v1 = v0 + m0 * dt / 3;
- const v2 = v3 - m3 * dt / 3;
+ const v1 = values[Math.round(n / 3)];
+ const v2 = values[Math.round(2 * n / 3)];
return {t0, v0, t1: t0 + dt / 3, v1, t2: t0 + 2 * dt / 3, v2, t3, v3};
}