From db5c023acd237d7015933bd21a5a6dbe5755841d Mon Sep 17 00:00:00 2001 From: skal Date: Thu, 19 Feb 2026 00:00:06 +0100 Subject: fix(mq_editor): fuse spread_above/below into single spread param Asymmetric spread offset the pitch center. Replace with a single symmetric `spread` in harmonics config. autodetectSpread now returns max(above, below). Update all defaults, UI, comments, and README. handoff(Gemini): spread is now a single param; no compat shims. Co-Authored-By: Claude Sonnet 4.6 --- tools/mq_editor/README.md | 2 +- tools/mq_editor/app.js | 16 +++++++--------- tools/mq_editor/editor.js | 25 +++++++++++-------------- tools/mq_editor/mq_extract.js | 19 +++++++++---------- tools/mq_editor/mq_synth.js | 20 +++++++++----------- tools/mq_editor/viewer.js | 7 +++---- 6 files changed, 40 insertions(+), 49 deletions(-) (limited to 'tools') diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 7d7d06b..3827d4f 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -180,7 +180,7 @@ y[n] = 2r·cos(ω₀)·y[n-1] − r²·y[n-2] + A(t)·√(1−r²)·noise[n] | `gain` | 1.0 | [0, ∞) | Output multiplier on top of power normalization. | **Coefficient translation from spread:** -`r = exp(−π · BW / SR)` where `BW = f₀ · (spread_above + spread_below) / 2`. +`r = exp(−π · BW / SR)` where `BW = f₀ · spread`. For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π·8.8/32000) ≈ 0.9991`. **When to use:** diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index d54e5f3..380bb12 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -231,10 +231,9 @@ function loadAudioBuffer(buffer, label) { viewer.onExploreCommit = (partial) => { if (!extractedPartials) extractedPartials = []; pushUndo(); - const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); - if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; - partial.harmonics.spread_above = spread_above; - partial.harmonics.spread_below = spread_below; + const {spread} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); + if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; + partial.harmonics.spread = spread; extractedPartials.unshift(partial); refreshPartialsView(0); setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info'); @@ -345,7 +344,7 @@ function createNewPartial() { v0: 440, v1: 440, v2: 440, v3: 440, a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0, }, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }, }; extractedPartials.unshift(newPartial); refreshPartialsView(0); @@ -369,12 +368,11 @@ function autoSpreadAll() { if (!extractedPartials || !stftCache) return; const fs = stftCache.fftSize; const sr = audioBuffer.sampleRate; - const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; for (const p of extractedPartials) { - const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr); + const {spread} = autodetectSpread(p, stftCache, fs, sr); if (!p.harmonics) p.harmonics = { ...defaults }; - p.harmonics.spread_above = spread_above; - p.harmonics.spread_below = spread_below; + p.harmonics.spread = spread; } if (viewer) viewer.render(); const sel = viewer ? viewer.selectedPartial : -1; diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index b07664e..98c92e5 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -124,7 +124,7 @@ class PartialEditor { const grid = this._synthGrid; grid.innerHTML = ''; - const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; const resDefaults = { r: 0.995, gainComp: 1.0 }; const isResonator = !!(partial.resonator && partial.resonator.enabled); @@ -158,11 +158,10 @@ class PartialEditor { const harm = partial.harmonics || {}; const sinParams = [ - { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' }, - { key: 'freq_mult', label: 'h.freq', step: '0.01' }, - { key: 'jitter', label: 'jitter', step: '0.001' }, - { key: 'spread_above', label: 'spread ↑', step: '0.001' }, - { key: 'spread_below', label: 'spread ↓', step: '0.001' }, + { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' }, + { key: 'freq_mult', label: 'h.freq', step: '0.01' }, + { key: 'jitter', label: 'jitter', step: '0.001' }, + { key: 'spread', label: 'spread', step: '0.001' }, ]; const sinInputs = {}; for (const p of sinParams) { @@ -206,22 +205,20 @@ class PartialEditor { // Auto-detect spread button const autoLbl = document.createElement('span'); - autoLbl.textContent = 'spread'; + autoLbl.textContent = ''; const autoBtn = document.createElement('button'); - autoBtn.textContent = 'Auto'; - autoBtn.title = 'Infer spread_above/below from frequency variance around the bezier curve'; + autoBtn.textContent = 'Auto spread'; + autoBtn.title = 'Infer spread from half-power bandwidth 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); + const {spread} = autodetectSpread(p, sc, fs, sr); if (!p.harmonics) p.harmonics = { ...harmDefaults }; - p.harmonics.spread_above = spread_above; - p.harmonics.spread_below = spread_below; - sinInputs['spread_above'].value = spread_above.toFixed(4); - sinInputs['spread_below'].value = spread_below.toFixed(4); + p.harmonics.spread = spread; + sinInputs['spread'].value = spread.toFixed(4); }); sinSection.appendChild(autoLbl); sinSection.appendChild(autoBtn); diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 47c21b9..0940cf1 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -270,13 +270,12 @@ 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). +// Autodetect spread from the spectrogram. +// Measures -3dB half-bandwidth above and below the peak in each STFT frame, +// returns spread = max(above, below) / f0 as a fractional frequency offset. function autodetectSpread(partial, stftCache, fftSize, sampleRate) { const curve = partial.freqCurve; - if (!curve || !stftCache) return {spread_above: 0.02, spread_below: 0.02}; + if (!curve || !stftCache) return {spread: 0.02}; const numFrames = stftCache.getNumFrames(); const binHz = sampleRate / fftSize; @@ -331,9 +330,9 @@ function autodetectSpread(partial, stftCache, fftSize, sampleRate) { ++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}; + const sa = count > 0 ? Math.sqrt(sumAbove / count) : 0.01; + const sb = count > 0 ? Math.sqrt(sumBelow / count) : 0.01; + return {spread: Math.max(sa, sb)}; } // Track a single partial starting from a (time, freq) seed position. @@ -428,7 +427,7 @@ function trackFromSeed(frames, seedTime, seedFreq, params) { return { times: allTimes, freqs: allFreqs, amps: allAmps, phases: allPhases, muted: false, freqCurve, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }, }; } @@ -522,7 +521,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) { times: allTimes, freqs: allFreqs, amps: allAmps, phases: new Array(allTimes.length).fill(0), muted: false, freqCurve, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.15 }, }; } diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index e5f7e1a..eeb3b00 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -26,7 +26,7 @@ function buildHarmonics(harmonics) { // Synthesize audio from MQ partials // partials: array of {freqCurve (with a0-a3 for amp), harmonics?, resonator?} -// harmonics: {decay, freq_mult, jitter, spread_above, spread_below} +// harmonics: {decay, freq_mult, jitter, spread} // resonator: {enabled, r, gainComp} — two-pole resonator mode per partial // integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq) // false = 2π*f*t (simpler, only correct for constant freq) @@ -37,11 +37,10 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt const pcm = new Float32Array(numSamples); const defaultHarmonics = { - decay: 0.0, - freq_mult: 1.0, - jitter: 0.05, - spread_above: 0.02, - spread_below: 0.02 + decay: 0.0, + freq_mult: 1.0, + jitter: 0.05, + spread: 0.02 }; // Pre-build per-partial configs with fixed spread/jitter and phase accumulators @@ -78,17 +77,16 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt } else { // --- Sinusoidal (harmonic) mode --- const harm = partial.harmonics || defaultHarmonics; - const spread_above = harm.spread_above ?? 0.0; - const spread_below = harm.spread_below ?? 0.0; - const jitter = harm.jitter ?? 0.0; + const spread = harm.spread ?? 0.0; + const jitter = harm.jitter ?? 0.0; const harmonicList = buildHarmonics(harm); const replicaData = []; for (let h = 0; h < harmonicList.length; ++h) { const hc = harmonicList[h]; - const spread = randFloat(p * 67890 + h * 999, -spread_below, spread_above); + const spreadVal = randFloat(p * 67890 + h * 999, -spread, spread); const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * jitter * 2.0 * Math.PI; - replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread, phase: initPhase }); + replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread: spreadVal, phase: initPhase }); } configs.push({ mode: 'sinusoid', fc, replicaData }); } diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 1ed609c..c841acb 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -304,12 +304,11 @@ class SpectrogramViewer { const {ctx} = this; const curve = partial.freqCurve; const harm = partial.harmonics || {}; - const sa = harm.spread_above != null ? harm.spread_above : 0.02; - const sb = harm.spread_below != null ? harm.spread_below : 0.02; + const spread = harm.spread != null ? harm.spread : 0.02; const decay = harm.decay != null ? harm.decay : 0.0; const freqMult = harm.freq_mult != null ? harm.freq_mult : 2.0; - const {upper, lower} = buildBandPoints(this, curve, sa, sb); + const {upper, lower} = buildBandPoints(this, curve, spread, spread); if (upper.length < 2) return; const savedAlpha = ctx.globalAlpha; @@ -379,7 +378,7 @@ class SpectrogramViewer { } // Spread band fill + boundary dashes - const {upper: hu, lower: hl} = buildBandPoints(this, curve, sa, sb, hRatio); + const {upper: hu, lower: hl} = buildBandPoints(this, curve, spread, spread, hRatio); if (hu.length >= 2) { ctx.beginPath(); ctx.moveTo(hu[0][0], hu[0][1]); -- cgit v1.2.3