diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 06:29:13 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 06:29:13 +0100 |
| commit | 65cd99553cd688c5ad2cfd64d79c6434fe694a33 (patch) | |
| tree | 4981b41f7dee72cdd6bf60789f1fa8383c5190e2 /tools/mq_editor/mq_synth.js | |
| parent | bf3929220be7eddf32cebe12573b870fc9b54997 (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.js | 122 |
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; |
