summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_extract.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 10:51:25 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 10:51:25 +0100
commitf8f664964594a341884b2e9947f64feea4b925a6 (patch)
tree4ac77ce036b188d5a374267c886a17f3366e1455 /tools/mq_editor/mq_extract.js
parent78faa77b208d20c01c242a942a1ddf9278f4ef17 (diff)
feat(mq_editor): spread autodetection, 50% drop-off line, mini-spectrum peak fix
- autodetectSpread(): measures half-power (-3dB) peak width in spectrogram to infer spread_above/below (replaces near-zero bezier residual approach) - 'Auto' button per partial + 'Auto Spread All' toolbar button - Spread panel inputs now trigger viewer refresh on change - 50% drop-off dotted reference lines on spread band (selected partial only) - Mini-spectrum: use max() instead of mean() over bins per pixel column, fixing high-frequency amplitude mismatch between original and synthesis handoff(Gemini): spread autodetection and mini-spectrum fixes done
Diffstat (limited to 'tools/mq_editor/mq_extract.js')
-rw-r--r--tools/mq_editor/mq_extract.js66
1 files changed, 66 insertions, 0 deletions
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 8a0ea0e..d29cfbc 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -182,6 +182,72 @@ function expandPartialsLeft(partials, frames) {
}
}
+// Autodetect spread_above / spread_below from the spectrogram.
+// For each (subsampled) STFT frame within the partial, measures the
+// half-power (-3dB) width of the spectral peak above and below the center.
+// spread = half_bandwidth / f0 (fractional).
+function autodetectSpread(partial, stftCache, fftSize, sampleRate) {
+ const curve = partial.freqCurve;
+ if (!curve || !stftCache) return {spread_above: 0.02, spread_below: 0.02};
+
+ const numFrames = stftCache.getNumFrames();
+ const binHz = sampleRate / fftSize;
+ const halfBins = fftSize / 2;
+
+ let sumAbove = 0, sumBelow = 0, count = 0;
+
+ const STEP = 4;
+ for (let fi = 0; fi < numFrames; fi += STEP) {
+ const frame = stftCache.getFrameAtIndex(fi);
+ if (!frame) continue;
+ const t = frame.time;
+ if (t < curve.t0 || t > curve.t3) continue;
+
+ const f0 = evalBezier(curve, t);
+ if (f0 <= 0) continue;
+
+ const sq = frame.squaredAmplitude;
+ if (!sq) continue;
+
+ // Find peak bin in ±10% window
+ const binCenter = f0 / binHz;
+ const searchBins = Math.max(3, Math.round(f0 * 0.10 / binHz));
+ const binLo = Math.max(1, Math.floor(binCenter - searchBins));
+ const binHi = Math.min(halfBins - 2, Math.ceil(binCenter + searchBins));
+
+ let peakBin = binLo, peakVal = sq[binLo];
+ for (let b = binLo + 1; b <= binHi; ++b) {
+ if (sq[b] > peakVal) { peakVal = sq[b]; peakBin = b; }
+ }
+
+ const halfPower = peakVal * 0.5; // -3dB in power
+
+ // Walk above peak until half-power, interpolate crossing
+ let aboveBin = peakBin;
+ while (aboveBin < halfBins - 1 && sq[aboveBin] > halfPower) ++aboveBin;
+ const tA = aboveBin > peakBin && sq[aboveBin - 1] !== sq[aboveBin]
+ ? (halfPower - sq[aboveBin - 1]) / (sq[aboveBin] - sq[aboveBin - 1])
+ : 0;
+ const widthAbove = (aboveBin - 1 + tA - peakBin) * binHz;
+
+ // Walk below peak until half-power, interpolate crossing
+ let belowBin = peakBin;
+ while (belowBin > 1 && sq[belowBin] > halfPower) --belowBin;
+ const tB = belowBin < peakBin && sq[belowBin + 1] !== sq[belowBin]
+ ? (halfPower - sq[belowBin + 1]) / (sq[belowBin] - sq[belowBin + 1])
+ : 0;
+ const widthBelow = (peakBin - belowBin - 1 + tB) * binHz;
+
+ sumAbove += (widthAbove / f0) * (widthAbove / f0);
+ sumBelow += (widthBelow / f0) * (widthBelow / f0);
+ ++count;
+ }
+
+ const spread_above = count > 0 ? Math.sqrt(sumAbove / count) : 0.01;
+ const spread_below = count > 0 ? Math.sqrt(sumBelow / count) : 0.01;
+ return {spread_above, spread_below};
+}
+
// 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;