summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 11:45:34 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 11:45:34 +0100
commit91d546c3ff52ac30daf3e3e0fe90bbeab4a366ac (patch)
treedd3ead0fbe380fec0b0b563c818c9f58a9148a20
parent48d8a9fe8af83fd1c8ef029a3c5fb8d87421a46e (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>
-rw-r--r--tools/mq_editor/README.md44
-rw-r--r--tools/mq_editor/editor.js178
-rw-r--r--tools/mq_editor/index.html11
-rw-r--r--tools/mq_editor/mq_synth.js116
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);
+ }
}
}