summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_synth.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/mq_synth.js')
-rw-r--r--tools/mq_editor/mq_synth.js132
1 files changed, 85 insertions, 47 deletions
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index 00867a9..1029626 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, plus two-pole resonator mode
+// Harmonic oscillator bank for sinusoidal synthesis, plus two-pole resonator mode
// Deterministic LCG PRNG
function randFloat(seed, min, max) {
@@ -7,27 +7,41 @@ function randFloat(seed, min, max) {
return min + (seed / 0x100000000) * (max - min);
}
+// Build harmonic list from harmonics config.
+// Fundamental (ratio=1.0, ampMult=1.0) is always first.
+// Then harmonics at n*freq_mult for n=1,2,... with ampMult=decay^n (added on top).
+function buildHarmonics(harmonics) {
+ const decay = Math.min(harmonics.decay ?? 0.0, 0.90);
+ const freqMult = harmonics.freq_mult ?? 2.0;
+ const result = [{ ratio: 1.0, ampMult: 1.0 }]; // fundamental always
+ if (decay > 0) {
+ for (let n = 1; ; ++n) {
+ const ampMult = Math.pow(decay, n);
+ if (ampMult < 0.001) break;
+ result.push({ ratio: n * freqMult, ampMult });
+ }
+ }
+ return result;
+}
+
// Synthesize audio from MQ partials
-// partials: array of {freqCurve (with a0-a3 for amp), replicas?, resonator?}
-// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// partials: array of {freqCurve (with a0-a3 for amp), harmonics?, resonator?}
+// 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)
// options.k1: LP coefficient in (0,1] — omit to bypass
// options.k2: HP coefficient in (0,1] — omit to bypass
+// options.disableJitter: true = suppress per-sample frequency jitter
function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) {
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
- const jitterMult = options.disableJitter ? 0 : 1;
- const spreadMult = options.disableSpread ? 0 : 1;
-
- const defaultReplicas = {
- offsets: [1.0],
- decay_alpha: 0.1,
- jitter: 0.05,
- spread_above: 0.02,
- spread_below: 0.02
+ const defaultHarmonics = {
+ 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
@@ -42,34 +56,44 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
// 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 = options.forceRGain ? Math.min(0.9999, Math.max(0, options.globalR))
- : (res.r != null ? Math.min(0.9999, Math.max(0, res.r)) : 0.995);
+ const r = options.forceRGain ? clamp(options.globalR, 0, 0.9999)
+ : (res.r != null ? clamp(res.r, 0, 0.9999) : 0.995);
const gainComp = options.forceRGain ? options.globalGain
: (res.gainComp != null ? res.gainComp : 1.0);
const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r));
+
+ // Build harmonic list (spread not applied to resonator; jitter modulates center freq)
+ const harm = partial.harmonics || defaultHarmonics;
+ const harmonicList = buildHarmonics(harm);
+ const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0);
+
configs.push({
mode: 'resonator',
fc,
r, gainComp, gainNorm,
- y1: 0.0, y2: 0.0,
- noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0
+ harmonicList,
+ jitter,
+ y1: new Float64Array(harmonicList.length),
+ y2: new Float64Array(harmonicList.length),
+ noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0,
+ jitterSeed: ((p * 6364136223 + 1442695040) & 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;
+ // --- Sinusoidal (harmonic) mode ---
+ const harm = partial.harmonics || defaultHarmonics;
+ const spread = harm.spread ?? 0.0;
+ const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0);
+ const harmonicList = buildHarmonics(harm);
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});
+ for (let h = 0; h < harmonicList.length; ++h) {
+ const hc = harmonicList[h];
+ const spreadVal = randFloat(p * 67890 + h * 999, -spread, spread);
+ const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * 2.0 * Math.PI;
+ const jitterSeed = ((p * 12345 + h * 67890 + 999) & 0xFFFFFFFF) >>> 0;
+ replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread: spreadVal, phase: initPhase, jitterSeed });
}
- configs.push({ mode: 'sinusoid', fc, decay_alpha, replicaData });
+ configs.push({ mode: 'sinusoid', fc, replicaData, jitter });
}
}
@@ -82,38 +106,52 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
const {fc} = cfg;
if (cfg.mode === 'resonator') {
- if (t < fc.t0 || t > fc.t3) { cfg.y1 = 0.0; cfg.y2 = 0.0; continue; }
+ if (t < fc.t0 || t > fc.t3) {
+ cfg.y1.fill(0.0); cfg.y2.fill(0.0); continue;
+ }
- const f0 = evalBezier(fc, t);
- const A = evalBezierAmp(fc, t);
- const omega = 2.0 * Math.PI * f0 / sampleRate;
- const b1 = 2.0 * cfg.r * Math.cos(omega);
+ const f0 = evalBezier(fc, t);
+ const A = evalBezierAmp(fc, t);
- // LCG noise excitation (deterministic per-partial)
+ // LCG noise excitation (deterministic per-partial, shared across harmonics)
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;
+ // Per-sample frequency jitter on resonator center freq
+ cfg.jitterSeed = (Math.imul(1664525, cfg.jitterSeed) + 1013904223) >>> 0;
+ const jNoise = cfg.jitterSeed / 0x100000000 * 2.0 - 1.0;
+ const f0j = f0 * (1.0 + jNoise * cfg.jitter);
+
+ for (let h = 0; h < cfg.harmonicList.length; ++h) {
+ const hc = cfg.harmonicList[h];
+ const fh = f0j * hc.ratio;
+ const omega = 2.0 * Math.PI * fh / sampleRate;
+ const b1 = 2.0 * cfg.r * Math.cos(omega);
+
+ const x = A * cfg.gainNorm * noise * hc.ampMult;
+ const y = b1 * cfg.y1[h] - cfg.r * cfg.r * cfg.y2[h] + x;
+ cfg.y2[h] = cfg.y1[h];
+ cfg.y1[h] = y;
+ sample += y * cfg.gainComp;
+ }
} else {
if (t < fc.t0 || t > fc.t3) continue;
const f0 = evalBezier(fc, t);
const A0 = evalBezierAmp(fc, 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));
+ for (let h = 0; h < cfg.replicaData.length; ++h) {
+ const rep = cfg.replicaData[h];
+ const f = f0 * rep.ratio * (1.0 + rep.spread);
+ const A = A0 * rep.ampMult;
let phase;
if (integratePhase) {
- rep.phase += 2.0 * Math.PI * f / sampleRate;
+ // Per-sample frequency jitter: ±jitter fraction of instantaneous freq
+ rep.jitterSeed = (Math.imul(1664525, rep.jitterSeed) + 1013904223) >>> 0;
+ const jNoise = rep.jitterSeed / 0x100000000 * 2.0 - 1.0;
+ rep.phase += 2.0 * Math.PI * f / sampleRate * (1.0 + jNoise * cfg.jitter);
phase = rep.phase;
} else {
phase = 2.0 * Math.PI * f * t + rep.phase;
@@ -131,7 +169,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
// LP: y[n] = k1*x[n] + (1-k1)*y[n-1] — options.k1 in (0,1], omit to bypass
// HP: y[n] = k2*(y[n-1] + x[n] - x[n-1]) — options.k2 in (0,1], omit to bypass
if (options.k1 != null) {
- const k1 = Math.max(0, Math.min(1, options.k1));
+ const k1 = clamp(options.k1, 0, 1);
let y = 0.0;
for (let i = 0; i < numSamples; ++i) {
y = k1 * pcm[i] + (1.0 - k1) * y;
@@ -139,7 +177,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
}
}
if (options.k2 != null) {
- const k2 = Math.max(0, Math.min(1, options.k2));
+ const k2 = clamp(options.k2, 0, 1);
let y = 0.0, xp = 0.0;
for (let i = 0; i < numSamples; ++i) {
const x = pcm[i];