summaryrefslogtreecommitdiff
path: root/tools/mq_editor/mq_synth.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 20:54:15 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 20:54:15 +0100
commitcfcd238044c7ce06dfdf1f9e08c3842bfa07979b (patch)
tree12cd6da868c43af9c054bc44705326c1dae5f6de /tools/mq_editor/mq_synth.js
parentd151eb48b2c55d16a1d9caa6a7affb3e0793c3e7 (diff)
feat(mq_editor): Complete Phase 2 - JS synthesizer with STFT cache
Phase 2 - JS Synthesizer: - Created mq_synth.js with replica oscillator bank - Bezier curve evaluation (cubic De Casteljau algorithm) - Replica synthesis: frequency spread, amplitude decay, phase jitter - PCM buffer generation from extracted MQ partials - Normalization to prevent clipping - Key '1' plays synthesized audio, key '2' plays original - Playback comparison with animated playhead STFT Cache Optimization: - Created STFTCache class in fft.js for pre-computed windowed FFT frames - Clean interface: getFFT(t), getMagnitudeDB(t, freq), setHopSize() - Pre-computes all frames on WAV load (eliminates redundant FFT calls) - Dynamic cache update when hop size changes - Shared across spectrogram, tooltip, and mini-spectrum viewer - Significant performance improvement Mini-Spectrum Viewer: - Bottom-right overlay (200x100) matching spectral_editor style - Real-time FFT display at playhead or mouse position - 100-bar visualization with cyan-to-yellow gradient - Updates during playback or mouse hover Files: - tools/mq_editor/mq_synth.js (new) - tools/mq_editor/fft.js (STFTCache class added) - tools/mq_editor/index.html (synthesis playback, cache integration) - tools/mq_editor/viewer.js (cache-based rendering, spectrum viewer) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor/mq_synth.js')
-rw-r--r--tools/mq_editor/mq_synth.js107
1 files changed, 107 insertions, 0 deletions
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
new file mode 100644
index 0000000..f1c7f73
--- /dev/null
+++ b/tools/mq_editor/mq_synth.js
@@ -0,0 +1,107 @@
+// MQ Synthesizer
+// Replica oscillator bank for sinusoidal synthesis
+
+// Evaluate cubic bezier curve at time t
+function evalBezier(curve, t) {
+ // Normalize t to [0, 1]
+ let u = (t - curve.t0) / (curve.t3 - curve.t0);
+ 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;
+}
+
+// Simple deterministic PRNG (for frequency spread and jitter)
+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);
+}
+
+// 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) {
+ 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
+ };
+
+ 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;
+
+ // Skip if outside curve time range
+ if (t < freqCurve.t0 || t > freqCurve.t3) continue;
+
+ const f0 = evalBezier(freqCurve, t);
+ const A0 = evalBezier(ampCurve, t);
+
+ // 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 each replica offset
+ for (let r = 0; r < offsets.length; ++r) {
+ const ratio = offsets[r];
+
+ // 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);
+
+ // Amplitude decay
+ 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;
+
+ sample += A * Math.sin(phase);
+ }
+ }
+
+ pcm[i] = sample;
+ }
+
+ // Normalize to prevent clipping
+ let maxAbs = 0;
+ 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;
+ }
+ }
+
+ return pcm;
+}