diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 11:45:34 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 11:45:34 +0100 |
| commit | 91d546c3ff52ac30daf3e3e0fe90bbeab4a366ac (patch) | |
| tree | dd3ead0fbe380fec0b0b563c818c9f58a9148a20 /tools | |
| parent | 48d8a9fe8af83fd1c8ef029a3c5fb8d87421a46e (diff) | |
feat(mq_editor): per-partial two-pole resonator synthesis mode
Each partial in the Synth tab now has a Sinusoid/Resonator toggle.
Resonator path: y[n] = 2r·cos(ω₀)·y1 − r²·y2 + A(t)·√(1−r²)·noise,
coefficients recomputed per-sample from the freq Bezier curve.
gainNorm=√(1−r²) normalises steady-state power; gainComp for trim.
UI: mode toggle buttons, r + gain jog sliders, RES badge in header.
Docs updated in tools/mq_editor/README.md.
handoff(Claude): resonator mode complete, coefficients translated from
spread params in README, ready for perceptual comparison testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/mq_editor/README.md | 44 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 178 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 11 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 116 |
4 files changed, 291 insertions, 58 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 0ef2e72..78986bf 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -17,7 +17,7 @@ open tools/mq_editor/index.html - **Top bar:** title + loaded filename - **Toolbar:** file, extraction, playback controls and parameters - **Main view:** log-scale time-frequency spectrogram with partial trajectories -- **Right panel:** synthesis checkboxes (integrate phase, disable jitter, disable spread) +- **Right panel:** per-partial mode toggle (Sinusoid / Resonator), synth params, global checkboxes - **Mini-spectrum** (bottom-right overlay): FFT slice at mouse/playhead time - Blue/orange gradient: original spectrum; green/yellow: synthesized - Green bars: detected spectral peaks @@ -49,7 +49,7 @@ open tools/mq_editor/index.html - `editor.js` — Property panel and amplitude bezier editor for selected partials - `fft.js` — Cooley-Tukey radix-2 FFT + STFT cache - `mq_extract.js` — MQ algorithm: peak detection, forward tracking, backward expansion, bezier fitting -- `mq_synth.js` — Oscillator bank synthesis from extracted partials +- `mq_synth.js` — Oscillator bank + two-pole resonator synthesis from extracted partials - `viewer.js` — Visualization: coordinate API, spectrogram, partials, mini-spectrum, mouse/zoom ### viewer.js coordinate API @@ -65,6 +65,45 @@ open tools/mq_editor/index.html | `normalizeDB(db, maxDB)` | dB → intensity [0..1] over 80 dB range | | `partialColor(p)` | partial index → display color | +## Resonator Synthesis Mode + +Each partial has a **per-partial synthesis mode** selectable in the **Synth** tab: + +### Sinusoid (default) +Replica oscillator bank — direct additive sinusoids with optional spread/jitter/decay shaping. + +### Resonator +Two-pole IIR bandpass resonator driven by band-limited noise: + +``` +y[n] = 2r·cos(ω₀)·y[n-1] − r²·y[n-2] + A(t)·√(1−r²)·noise[n] +``` + +- **ω₀** = `2π·f₀(t)/SR` — recomputed each sample from the freq Bezier curve (handles glides/vibrato) +- **A(t)** — amp Bezier curve scales excitation continuously +- **√(1−r²)** — power normalization, keeps output level ≈ sinusoidal mode at `gainComp = 1` +- **noise[n]** — deterministic per-partial LCG (reproducible renders) + +**Parameters:** + +| Param | Default | Range | Meaning | +|-------|---------|-------|---------| +| `r (pole)` | 0.995 | [0, 0.9999] | Pole radius. r→1 = narrow BW / long ring. r→0 = wide / fast decay. | +| `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`. +For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π·8.8/32000) ≈ 0.9991`. + +**When to use:** +- Metallic / percussive partials with natural exponential decay +- Wide spectral peaks (large spread) where a bandpass filter is more physically accurate +- Comparing resonator vs. sinusoidal timbre on the same partial + +**Note:** `RES` badge appears in the panel header when a partial is in resonator mode. + +--- + ## Algorithm 1. **STFT:** Overlapping Hann windows, radix-2 FFT @@ -93,6 +132,7 @@ open tools/mq_editor/index.html - [x] Synth params with jog sliders (decay, jitter, spread) - [x] Auto-spread detection per partial and global - [x] Mute / delete partials + - [x] Per-partial resonator synthesis mode (Synth tab toggle) - [ ] Phase 4: Export (.spec + C++ code generation) ## See Also diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index 157186a..f57e177 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -62,7 +62,10 @@ class PartialEditor { const partial = this.partials[index]; const color = this.viewer ? this.viewer.partialColor(index) : '#888'; - document.getElementById('propTitle').textContent = 'Partial #' + index; + const titleEl = document.getElementById('propTitle'); + const badge = partial.resonator && partial.resonator.enabled + ? ' <span class="res-badge">RES</span>' : ''; + titleEl.innerHTML = 'Partial #' + index + badge; document.getElementById('propSwatch').style.background = color; let peakAmp = 0, peakIdx = 0; @@ -118,17 +121,49 @@ class PartialEditor { _buildSynthGrid(partial, index) { const grid = this._synthGrid; grid.innerHTML = ''; - const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + + const repDefaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + const resDefaults = { r: 0.995, gainComp: 1.0 }; + + const isResonator = !!(partial.resonator && partial.resonator.enabled); + + // --- Mode toggle --- + const modeLbl = document.createElement('span'); + modeLbl.textContent = 'mode'; + + const modeWrap = document.createElement('div'); + modeWrap.style.cssText = 'display:flex;gap:3px;'; + + const btnSin = document.createElement('button'); + btnSin.textContent = 'Sinusoid'; + btnSin.className = 'tab-btn' + (isResonator ? '' : ' active'); + btnSin.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;'; + + const btnRes = document.createElement('button'); + btnRes.textContent = 'Resonator'; + btnRes.className = 'tab-btn' + (isResonator ? ' active' : ''); + btnRes.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;'; + + modeWrap.appendChild(btnSin); + modeWrap.appendChild(btnRes); + grid.appendChild(modeLbl); + grid.appendChild(modeWrap); + + // --- Sinusoid section --- + const sinSection = document.createElement('div'); + sinSection.style.cssText = 'display:contents;'; + sinSection.dataset.section = 'sinusoid'; + const rep = partial.replicas || {}; - const params = [ + const sinParams = [ { key: 'decay_alpha', label: 'decay', step: '0.001' }, { key: 'jitter', label: 'jitter', step: '0.001' }, { 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 sinInputs = {}; + for (const p of sinParams) { + const val = rep[p.key] != null ? rep[p.key] : repDefaults[p.key]; const lbl = document.createElement('span'); lbl.textContent = p.label; const inp = document.createElement('input'); @@ -140,21 +175,19 @@ class PartialEditor { if (!this.partials) return; const v = parseFloat(e.target.value); if (isNaN(v)) return; - if (!this.partials[index].replicas) - this.partials[index].replicas = { ...defaults }; + if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults }; this.partials[index].replicas[p.key] = v; if (this.viewer) this.viewer.render(); }); - inputs[p.key] = inp; + sinInputs[p.key] = inp; - const jog = this._makeJogSlider(inp, partial, index, p, defaults); + const jog = this._makeJogSlider(inp, partial, index, p, repDefaults); const wrap = document.createElement('div'); wrap.className = 'synth-field-wrap'; wrap.appendChild(inp); wrap.appendChild(jog); - - grid.appendChild(lbl); - grid.appendChild(wrap); + sinSection.appendChild(lbl); + sinSection.appendChild(wrap); } // Auto-detect spread button @@ -170,14 +203,123 @@ class PartialEditor { 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 }; + if (!p.replicas) p.replicas = { ...repDefaults }; 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); + sinInputs['spread_above'].value = spread_above.toFixed(4); + sinInputs['spread_below'].value = spread_below.toFixed(4); + }); + sinSection.appendChild(autoLbl); + sinSection.appendChild(autoBtn); + + // --- Resonator section --- + const resSection = document.createElement('div'); + resSection.style.cssText = 'display:contents;'; + resSection.dataset.section = 'resonator'; + + const resObj = partial.resonator || {}; + const resParams = [ + { key: 'r', label: 'r (pole)', step: '0.001', min: '0', max: '0.9999', + title: 'Pole radius. r→1 = narrow bandwidth / long ring. r→0 = wide / fast decay.' }, + { key: 'gainComp', label: 'gain', step: '0.01', min: '0', max: '100', + title: 'Output gain multiplier (gainNorm=√(1-r²) normalises power; use this to trim level).' }, + ]; + for (const p of resParams) { + const val = resObj[p.key] != null ? resObj[p.key] : resDefaults[p.key]; + const lbl = document.createElement('span'); + lbl.textContent = p.label; + lbl.title = p.title || ''; + const inp = document.createElement('input'); + inp.type = 'number'; + inp.value = val.toFixed(4); + inp.step = p.step; + inp.min = p.min || '0'; + inp.max = p.max || ''; + inp.title = p.title || ''; + inp.addEventListener('change', (e) => { + if (!this.partials) return; + const v = parseFloat(e.target.value); + if (isNaN(v)) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator[p.key] = v; + if (this.viewer) this.viewer.render(); + }); + + // Inline jog slider for resonator params + const step = parseFloat(p.step); + const sensitivity = step * 5; + const jog = document.createElement('div'); + jog.className = 'jog-slider'; + const thumb = document.createElement('div'); + thumb.className = 'jog-thumb'; + jog.appendChild(thumb); + let dragging = false, startX = 0, startVal = 0; + const onMove = (ev) => { + if (!dragging) return; + const dx = ev.clientX - startX; + const half = jog.offsetWidth / 2; + const clamped = Math.max(-half, Math.min(half, dx)); + thumb.style.transition = 'none'; + thumb.style.left = `calc(50% - 3px + ${clamped}px)`; + const newVal = Math.max(parseFloat(inp.min) || 0, + Math.min(parseFloat(inp.max) || 1e9, startVal + dx * sensitivity)); + inp.value = newVal.toFixed(4); + if (!this.partials || !this.partials[index]) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator[p.key] = newVal; + if (this.viewer) this.viewer.render(); + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + thumb.style.transition = ''; + thumb.style.left = 'calc(50% - 3px)'; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + jog.addEventListener('mousedown', (ev) => { + dragging = true; + startX = ev.clientX; + startVal = parseFloat(inp.value) || 0; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + ev.preventDefault(); + }); + + const wrap = document.createElement('div'); + wrap.className = 'synth-field-wrap'; + wrap.appendChild(inp); + wrap.appendChild(jog); + resSection.appendChild(lbl); + resSection.appendChild(wrap); + } + + // Show/hide helper + const applyMode = (resonator) => { + for (const el of sinSection.children) el.style.display = resonator ? 'none' : ''; + for (const el of resSection.children) el.style.display = resonator ? '' : 'none'; + btnSin.classList.toggle('active', !resonator); + btnRes.classList.toggle('active', resonator); + }; + + // Initial state + grid.appendChild(sinSection); + grid.appendChild(resSection); + applyMode(isResonator); + + // Toggle handlers + btnSin.addEventListener('click', () => { + if (!this.partials) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator.enabled = false; + applyMode(false); + }); + btnRes.addEventListener('click', () => { + if (!this.partials) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator.enabled = true; + applyMode(true); }); - grid.appendChild(autoLbl); - grid.appendChild(autoBtn); } _makeJogSlider(inp, partial, index, p, defaults) { diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index aab603b..81b5bef 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -227,6 +227,17 @@ padding-top: 8px; margin-top: auto; } + /* resonator mode badge shown in partial header color swatch area */ + .res-badge { + font-size: 9px; + color: #8cf; + border: 1px solid #8cf; + border-radius: 2px; + padding: 0 3px; + vertical-align: middle; + margin-left: 4px; + opacity: 0.8; + } #status { margin-top: 10px; padding: 8px; diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 1eec709..2d3111b 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -1,5 +1,5 @@ // MQ Synthesizer -// Replica oscillator bank for sinusoidal synthesis +// Replica oscillator bank for sinusoidal synthesis, plus two-pole resonator mode // Evaluate cubic bezier curve at time t function evalBezier(curve, t) { @@ -21,8 +21,9 @@ function randFloat(seed, min, max) { } // Synthesize audio from MQ partials -// partials: array of {freqCurve, ampCurve, replicas?} -// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below} +// partials: array of {freqCurve, ampCurve, replicas?, resonator?} +// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below} +// 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) function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) { @@ -43,27 +44,43 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt // Pre-build per-partial configs with fixed spread/jitter and phase accumulators const configs = []; for (let p = 0; p < partials.length; ++p) { - const rep = partials[p].replicas != null ? partials[p].replicas : defaultReplicas; - const offsets = rep.offsets != null ? rep.offsets : [1.0]; - const decay_alpha = rep.decay_alpha != null ? rep.decay_alpha : 0.0; - const jitter = rep.jitter != null ? rep.jitter : 0.0; - const spread_above = rep.spread_above != null ? rep.spread_above : 0.0; - const spread_below = rep.spread_below != null ? rep.spread_below : 0.0; + const partial = partials[p]; + const fc = partial.freqCurve; + const ac = partial.ampCurve; - const replicaData = []; - for (let r = 0; r < offsets.length; ++r) { - // Fixed per-replica spread (frequency detuning) and initial phase (jitter) - const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above); - const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI; - replicaData.push({ratio: offsets[r], spread, phase: initPhase}); - } + if (partial.resonator && partial.resonator.enabled) { + // --- Two-pole resonator mode --- + // Driven by band-limited noise scaled by amp curve. + // r controls pole radius (bandwidth): r→1 = narrow, r→0 = wide. + // gainNorm = sqrt(1 - r²) normalises steady-state output power to ~A. + const res = partial.resonator; + const r = res.r != null ? Math.min(0.9999, Math.max(0, res.r)) : 0.995; + const gainComp = res.gainComp != null ? res.gainComp : 1.0; + const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r)); + configs.push({ + mode: 'resonator', + fc, ac, + r, gainComp, gainNorm, + y1: 0.0, y2: 0.0, + noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0 + }); + } else { + // --- Sinusoidal (replica) mode --- + const rep = partial.replicas != null ? partial.replicas : defaultReplicas; + const offsets = rep.offsets != null ? rep.offsets : [1.0]; + const decay_alpha = rep.decay_alpha != null ? rep.decay_alpha : 0.0; + const jitter = rep.jitter != null ? rep.jitter : 0.0; + const spread_above = rep.spread_above != null ? rep.spread_above : 0.0; + const spread_below = rep.spread_below != null ? rep.spread_below : 0.0; - configs.push({ - fc: partials[p].freqCurve, - ac: partials[p].ampCurve, - decay_alpha, - replicaData - }); + const replicaData = []; + for (let r = 0; r < offsets.length; ++r) { + const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above); + const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI; + replicaData.push({ratio: offsets[r], spread, phase: initPhase}); + } + configs.push({ mode: 'sinusoid', fc, ac, decay_alpha, replicaData }); + } } for (let i = 0; i < numSamples; ++i) { @@ -71,26 +88,49 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt let sample = 0.0; for (let p = 0; p < configs.length; ++p) { - const {fc, ac, decay_alpha, replicaData} = configs[p]; - if (t < fc.t0 || t > fc.t3) continue; + const cfg = configs[p]; + const {fc, ac} = cfg; - const f0 = evalBezier(fc, t); - const A0 = evalBezier(ac, t); + if (cfg.mode === 'resonator') { + if (t < fc.t0 || t > fc.t3) { cfg.y1 = 0.0; cfg.y2 = 0.0; continue; } - for (let r = 0; r < replicaData.length; ++r) { - const rep = replicaData[r]; - const f = f0 * rep.ratio * (1.0 + rep.spread); - const A = A0 * Math.exp(-decay_alpha * Math.abs(f - f0)); + const f0 = evalBezier(fc, t); + const A = evalBezier(ac, t); + const omega = 2.0 * Math.PI * f0 / sampleRate; + const b1 = 2.0 * cfg.r * Math.cos(omega); - let phase; - if (integratePhase) { - rep.phase += 2.0 * Math.PI * f / sampleRate; - phase = rep.phase; - } else { - phase = 2.0 * Math.PI * f * t + rep.phase; - } + // LCG noise excitation (deterministic per-partial) + cfg.noiseSeed = (Math.imul(1664525, cfg.noiseSeed) + 1013904223) >>> 0; + const noise = cfg.noiseSeed / 0x100000000 * 2.0 - 1.0; + + const x = A * cfg.gainNorm * noise; + const y = b1 * cfg.y1 - cfg.r * cfg.r * cfg.y2 + x; + cfg.y2 = cfg.y1; + cfg.y1 = y; + sample += y * cfg.gainComp; - sample += A * Math.sin(phase); + } else { + if (t < fc.t0 || t > fc.t3) continue; + + const f0 = evalBezier(fc, t); + const A0 = evalBezier(ac, t); + const {decay_alpha, replicaData} = cfg; + + for (let r = 0; r < replicaData.length; ++r) { + const rep = replicaData[r]; + const f = f0 * rep.ratio * (1.0 + rep.spread); + const A = A0 * Math.exp(-decay_alpha * Math.abs(f - f0)); + + let phase; + if (integratePhase) { + rep.phase += 2.0 * Math.PI * f / sampleRate; + phase = rep.phase; + } else { + phase = 2.0 * Math.PI * f * t + rep.phase; + } + + sample += A * Math.sin(phase); + } } } |
