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");