summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_synth.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 06:29:13 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 06:29:13 +0100
commit65cd99553cd688c5ad2cfd64d79c6434fe694a33 (patch)
tree4981b41f7dee72cdd6bf60789f1fa8383c5190e2 /tools/mq_editor/mq_synth.js
parentbf3929220be7eddf32cebe12573b870fc9b54997 (diff)
feat(mq_editor): validated dual-sine synthesis pipeline, clean code for real audio
- Fix incoherent per-sample jitter/spread: seed by partial index only - Fix || fallback for zero-valued params (use != null checks) - Phase integration: accumulator (2π*f/SR per sample) replaces 2π*f*t - Add 'Integrate phase' checkbox to toggle between modes - Revert Catmull-Rom back to simple bezier (1/3, 2/3 sample points) - Remove all debug logging, clean up trackPartials, fitBezier handoff(Gemini): dual-sine test validates full MQ pipeline (extract→track→synth). Next: real audio loading and partial detection improvements. 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.js122
1 files changed, 59 insertions, 63 deletions
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index 8dcb4bd..6fa2a09 100644
--- a/tools/mq_editor/mq_synth.js
+++ b/tools/mq_editor/mq_synth.js
@@ -3,89 +3,89 @@
// Evaluate cubic bezier curve at time t
function evalBezier(curve, t) {
- // Normalize t to [0, 1]
const dt = curve.t3 - curve.t0;
if (dt <= 0) return curve.v0;
let u = (t - curve.t0) / dt;
u = Math.max(0, Math.min(1, u));
-
- // Cubic interpolation
const u1 = 1.0 - u;
return u1*u1*u1 * curve.v0 +
3*u1*u1*u * curve.v1 +
- 3*u1*u*u * curve.v2 +
- u*u*u * curve.v3;
+ 3*u1*u*u * curve.v2 +
+ u*u*u * curve.v3;
}
-// Simple deterministic PRNG (for frequency spread and jitter)
+// Deterministic LCG PRNG
function randFloat(seed, min, max) {
- // LCG parameters
- const a = 1664525;
- const c = 1013904223;
- const m = 0x100000000; // 2^32
-
- seed = (a * seed + c) % m;
- const normalized = seed / m;
- return min + normalized * (max - min);
+ seed = (1664525 * seed + 1013904223) % 0x100000000;
+ return min + (seed / 0x100000000) * (max - min);
}
// Synthesize audio from MQ partials
-// partials: array of {freqCurve, ampCurve, replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}}
-// sampleRate: output sample rate (Hz)
-// duration: output duration (seconds)
-// Returns: Float32Array of PCM samples
-function synthesizeMQ(partials, sampleRate, duration) {
+// partials: array of {freqCurve, ampCurve, replicas?}
+// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// 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) {
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
- // Default replica config
const defaultReplicas = {
- offsets: [1.0], // Just fundamental
- decay_alpha: 0.1,
- jitter: 0.05,
- spread_above: 0.02,
- spread_below: 0.02
+ offsets: [1.0],
+ decay_alpha: 0.1,
+ jitter: 0.05,
+ spread_above: 0.02,
+ spread_below: 0.02
};
- for (let i = 0; i < numSamples; ++i) {
- const t = i / sampleRate;
- let sample = 0.0;
-
- for (let p = 0; p < partials.length; ++p) {
- const partial = partials[p];
- const freqCurve = partial.freqCurve;
- const ampCurve = partial.ampCurve;
+ // 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;
- // Skip if outside curve time range
- if (t < freqCurve.t0 || t > freqCurve.t3) continue;
+ const replicaData = [];
+ for (let r = 0; r < offsets.length; ++r) {
+ // Fixed per-replica spread (frequency detuning) and initial phase (jitter)
+ const spread = randFloat(p * 67890 + r * 999, -spread_below, spread_above);
+ const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * jitter * 2.0 * Math.PI;
+ replicaData.push({ratio: offsets[r], spread, phase: initPhase});
+ }
- const f0 = evalBezier(freqCurve, t);
- const A0 = evalBezier(ampCurve, t);
+ configs.push({
+ fc: partials[p].freqCurve,
+ ac: partials[p].ampCurve,
+ decay_alpha,
+ replicaData
+ });
+ }
- // Use default replicas if not specified
- const replicas = partial.replicas || defaultReplicas;
- const offsets = replicas.offsets || [1.0];
- const decay_alpha = replicas.decay_alpha || 0.1;
- const jitter = replicas.jitter || 0.05;
- const spread_above = replicas.spread_above || 0.02;
- const spread_below = replicas.spread_below || 0.02;
+ for (let i = 0; i < numSamples; ++i) {
+ const t = i / sampleRate;
+ let sample = 0.0;
- // For each replica offset
- for (let r = 0; r < offsets.length; ++r) {
- const ratio = offsets[r];
+ for (let p = 0; p < configs.length; ++p) {
+ const {fc, ac, decay_alpha, replicaData} = configs[p];
+ if (t < fc.t0 || t > fc.t3) continue;
- // Frequency spread (asymmetric randomization)
- const seed1 = i * 12345 + p * 67890 + r;
- const spread = randFloat(seed1, -spread_below, spread_above);
- const f = f0 * ratio * (1.0 + spread);
+ const f0 = evalBezier(fc, t);
+ const A0 = evalBezier(ac, t);
- // Amplitude decay
+ 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));
- // Phase with jitter
- const seed2 = seed1 + 1;
- const jitterPhase = randFloat(seed2, 0.0, 1.0) * jitter * 2.0 * Math.PI;
- const phase = 2.0 * Math.PI * f * t + jitterPhase;
+ 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);
}
@@ -94,15 +94,11 @@ function synthesizeMQ(partials, sampleRate, duration) {
pcm[i] = sample;
}
- // Normalize to prevent clipping
+ // Normalize
let maxAbs = 0;
- for (let i = 0; i < numSamples; ++i) {
- maxAbs = Math.max(maxAbs, Math.abs(pcm[i]));
- }
+ for (let i = 0; i < numSamples; ++i) maxAbs = Math.max(maxAbs, Math.abs(pcm[i]));
if (maxAbs > 1.0) {
- for (let i = 0; i < numSamples; ++i) {
- pcm[i] /= maxAbs;
- }
+ for (let i = 0; i < numSamples; ++i) pcm[i] /= maxAbs;
}
return pcm;