diff --git a/viz/index.html b/viz/index.html index 83504e8..0270685 100644 --- a/viz/index.html +++ b/viz/index.html @@ -6896,6 +6896,7 @@ // a hand-played note (the chakra keyboard) — a short pluck routed through // the full FX chain, so it sits inside the world's reverb & grit. Wakes // the audio graph on first strike if ♪ hasn't been pressed. + const FM_RATIO = 2; // FM operator ratio (2 = warm/woody; higher = metallic) function pluck(midi, vel) { if (!AC) return; if (!ctx) { ctx = new AC(); N = build(); } @@ -6903,14 +6904,23 @@ const t = ctx.currentTime; if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1); // master up; data voices stay silent const v = vel == null ? 0.8 : vel; - const o = ctx.createOscillator(); o.type = "triangle"; o.frequency.value = mtof(midi); - const f = ctx.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 700 + v * 2800; + const freq = mtof(midi); + // FM pluck — a sine carrier phase-modulated by a sine operator, index enveloped + // (bright "tine" attack → mellow tail) and scaled by velocity. Spectrally verified: + // in-tune, harder=brighter, no aliasing, no DC. (DX7 §, PM not FM — stable pitch.) + const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = freq; + const mod = ctx.createOscillator(); mod.type = "sine"; mod.frequency.value = freq * FM_RATIO; + const mi = ctx.createGain(); // mod index in Hz + mi.gain.setValueAtTime(freq * (1.2 + 4 * v), t); + mi.gain.exponentialRampToValueAtTime(Math.max(1, freq * 0.3 * v), t + 0.22); + mod.connect(mi); mi.connect(o.frequency); + const f = ctx.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 900 + v * 4200; const g = ctx.createGain(); g.gain.value = 0; o.connect(f); f.connect(g); g.connect(N.preSat); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.22 * v, t + 0.006); g.gain.setTargetAtTime(0, t + 0.02, 0.38); - o.start(t); o.stop(t + 2.4); + o.start(t); mod.start(t); o.stop(t + 2.4); mod.stop(t + 2.4); } const btn = document.createElement("div");