summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_synth.js
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 /tools/mq_editor/mq_synth.js
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>
Diffstat (limited to 'tools/mq_editor/mq_synth.js')
-rw-r--r--tools/mq_editor/mq_synth.js116
1 files changed, 78 insertions, 38 deletions
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);
+ }
}
}