diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/mq_editor/editor.js | 25 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 19 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 66 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 33 |
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">📂 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; |
