summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/editor.js25
-rw-r--r--tools/mq_editor/index.html19
-rw-r--r--tools/mq_editor/mq_extract.js66
-rw-r--r--tools/mq_editor/viewer.js33
4 files changed, 139 insertions, 4 deletions
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
index 441f787..9cfcdf1 100644
--- a/tools/mq_editor/editor.js
+++ b/tools/mq_editor/editor.js
@@ -126,6 +126,7 @@ class PartialEditor {
{ key: 'spread_above', label: 'spread ↑', step: '0.001' },
{ key: 'spread_below', label: 'spread ↓', step: '0.001' },
];
+ const inputs = {};
for (const p of params) {
const val = rep[p.key] != null ? rep[p.key] : defaults[p.key];
const lbl = document.createElement('span');
@@ -142,10 +143,34 @@ class PartialEditor {
if (!this.partials[index].replicas)
this.partials[index].replicas = { ...defaults };
this.partials[index].replicas[p.key] = v;
+ if (this.viewer) this.viewer.render();
});
+ inputs[p.key] = inp;
grid.appendChild(lbl);
grid.appendChild(inp);
}
+
+ // Auto-detect spread button
+ const autoLbl = document.createElement('span');
+ autoLbl.textContent = 'spread';
+ const autoBtn = document.createElement('button');
+ autoBtn.textContent = 'Auto';
+ autoBtn.title = 'Infer spread_above/below from frequency variance around the bezier curve';
+ autoBtn.addEventListener('click', () => {
+ if (!this.partials) return;
+ const p = this.partials[index];
+ const sc = this.viewer ? this.viewer.stftCache : null;
+ const sr = this.viewer ? this.viewer.audioBuffer.sampleRate : 44100;
+ const fs = sc ? sc.fftSize : 2048;
+ const {spread_above, spread_below} = autodetectSpread(p, sc, fs, sr);
+ if (!p.replicas) p.replicas = { ...defaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ inputs['spread_above'].value = spread_above.toFixed(4);
+ inputs['spread_below'].value = spread_below.toFixed(4);
+ });
+ grid.appendChild(autoLbl);
+ grid.appendChild(autoBtn);
}
_makeCurveUpdater(partialIndex, curveKey, field, pointIndex) {
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index 8f36314..5e1e5c4 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -212,6 +212,7 @@
<button id="chooseFileBtn">&#x1F4C2; Open WAV</button>
<button id="testWavBtn">⚗ Test WAV</button>
<button id="extractBtn" disabled>Extract Partials</button>
+ <button id="autoSpreadAllBtn" disabled>Auto Spread All</button>
<button id="playBtn" disabled>▶ Play</button>
<button id="stopBtn" disabled>■ Stop</button>
@@ -464,6 +465,7 @@
console.error(err);
}
extractBtn.disabled = false;
+ autoSpreadAllBtn.disabled = false;
}, 50);
}
@@ -473,6 +475,23 @@
runExtraction();
});
+ // Auto-spread all partials
+ const autoSpreadAllBtn = document.getElementById('autoSpreadAllBtn');
+ autoSpreadAllBtn.addEventListener('click', () => {
+ if (!extractedPartials || !stftCache) return;
+ const fs = stftCache.fftSize;
+ const sr = audioBuffer.sampleRate;
+ const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ for (const p of extractedPartials) {
+ const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr);
+ if (!p.replicas) p.replicas = { ...defaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ }
+ if (viewer) viewer.render();
+ setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info');
+ });
+
threshold.addEventListener('change', () => {
if (stftCache) runExtraction();
});
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;
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index db23c72..3b2e1b2 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -326,6 +326,31 @@ class SpectrogramViewer {
ctx.stroke();
ctx.setLineDash([]);
+ // 50% drop-off reference lines (dotted, dimmer)
+ const p5upper = [], p5lower = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue;
+ const f = evalBezier(curve, t);
+ p5upper.push([this.timeToX(t), this.freqToY(f * 1.50)]);
+ p5lower.push([this.timeToX(t), this.freqToY(f * 0.50)]);
+ }
+ if (p5upper.length >= 2) {
+ ctx.globalAlpha = 0.55;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([1, 5]);
+ ctx.beginPath();
+ ctx.moveTo(p5upper[0][0], p5upper[0][1]);
+ for (let i = 1; i < p5upper.length; ++i) ctx.lineTo(p5upper[i][0], p5upper[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(p5lower[0][0], p5lower[0][1]);
+ for (let i = 1; i < p5lower.length; ++i) ctx.lineTo(p5lower[i][0], p5lower[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
ctx.globalAlpha = savedAlpha;
}
@@ -468,11 +493,11 @@ class SpectrogramViewer {
const bStart = Math.max(0, Math.floor(fStart / binWidth));
const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth));
- let sum = 0, count = 0;
- for (let b = bStart; b <= bEnd; ++b) { sum += squaredAmp[b]; ++count; }
- if (count === 0) continue;
+ let maxSq = 0;
+ for (let b = bStart; b <= bEnd; ++b) { if (squaredAmp[b] > maxSq) maxSq = squaredAmp[b]; }
+ if (bStart > bEnd) continue;
- const magDB = 10 * Math.log10(Math.max(sum / count, 1e-20));
+ const magDB = 10 * Math.log10(Math.max(maxSq, 1e-20));
const barHeight = Math.round(this.normalizeDB(magDB, cache.maxDB) * height);
if (barHeight === 0) continue;