From 28be1de3b84dba48d0102d8804f0a6e79ab466f6 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Sun, 12 Jul 2026 21:34:19 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=81=20godrum:=20five=20synthesized=20d?= =?UTF-8?q?rum=20voices=20in=20the=20Synth=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kick / snare / clap / closed-hat / open-hat as one-shot node graphs summed onto a drumBus → preSat, so drums ride the world's saturation → crush → chorus/reverb/echo like every other voice. Each voice keeps a persistent level→pan chain; hits connect into it. TR-style choke: a closed hat OR a new open hat cuts the ringing open hat. Frozen API (§0.6): drum(name,vel), drumParam get/set, drumParamList, drumKit / drumKitLoad. All params normalized 0..1 at the boundary and mapped to real units inside each voice; non-finite inputs rejected. Entirely inside the Synth IIFE — no other territory touched. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 134 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/viz/index.html b/viz/index.html index 4dd1edc..7258128 100644 --- a/viz/index.html +++ b/viz/index.html @@ -7798,6 +7798,21 @@ let leadNote = 0, bassNote = 0, step = 0, nextAt = 0; let lastLeadT = 0, lastBassT = 0; // retrigger rate-limiters + // ---- GODRUM — five synthesized drum voices. They sum onto preSat like every + // other voice, so they ride the same saturation → crush → chorus/reverb/echo + // world (the instrument's ethos, kept). Every param is normalized 0..1 at the + // boundary and mapped to real units inside its voice. DRUM_DEFS is the single + // source of truth the panel renders from; KIT holds the live, editable values. + const DRUM_DEFS = { + kick: { params: ["tune", "punch", "decay", "level", "pan"], def: [0.5, 0.5, 0.5, 0.85, 0.5] }, + snare: { params: ["tune", "snap", "decay", "level", "pan"], def: [0.5, 0.6, 0.4, 0.7, 0.5] }, + clap: { params: ["tone", "decay", "level", "pan"], def: [0.5, 0.5, 0.65, 0.5] }, + hat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.35, 0.5, 0.5] }, + ohat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.5, 0.45, 0.5] }, + }; + const KIT = {}; + for (const _v in DRUM_DEFS) { KIT[_v] = {}; DRUM_DEFS[_v].params.forEach((p, i) => { KIT[_v][p] = DRUM_DEFS[_v].def[i]; }); } + const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12); const c01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x; const d = (k, def) => { const v = D[k]; return typeof v === "number" ? v : (def || 0); }; @@ -7950,9 +7965,26 @@ schuDrone.connect(schuDroneG); schuDroneG.connect(droneFilt); schuDrone.start(); lfo.start(); + + // ---- GODRUM bus: the five voices sum here, then into preSat so they share + // the world's saturation/crush/chorus/reverb/echo. Each voice keeps a + // persistent level→pan chain; individual hits are one-shot graphs that + // connect into their voice's level gain (like pluck()/noiseHit()). ---- + const drumBus = ctx.createGain(); drumBus.gain.value = 0.8; drumBus.connect(preSat); + const noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); // 1s white noise, looped & reused per hit + { const nch = noiseBuf.getChannelData(0); for (let i = 0; i < nch.length; i++) nch[i] = Math.random() * 2 - 1; } + const drums = {}; + for (const dv in DRUM_DEFS) { + const g = ctx.createGain(), pan = ctx.createStereoPanner(); + g.gain.value = (KIT[dv].level != null ? KIT[dv].level : 1) * 1.2; // level 0..1 → gain 0..1.2 + pan.pan.value = (KIT[dv].pan != null ? KIT[dv].pan : 0.5) * 2 - 1; // pan 0..1 → −1..1 + g.connect(pan); pan.connect(drumBus); drums[dv] = { g, pan }; + } + return { out, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp, bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp, - echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet }; + echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet, + drumBus, drums, noiseBuf }; } function hit(node, peak, dec) { // pluck envelope @@ -7971,6 +8003,103 @@ s.connect(hp); hp.connect(g); g.connect(N.preSat); s.start(at != null ? at : ctx.currentTime); } + // ---- GODRUM voices. Velocity scales amplitude as vel*vel (punchier than + // linear for drums). Envelopes are linear ramps + setTargetAtTime — never + // exponential-to-0 (illegal); noise voices loop the shared buffer and are + // stopped once their tail has rung out. ---- + function dNoise() { const s = ctx.createBufferSource(); s.buffer = N.noiseBuf; s.loop = true; return s; } + function drumSync(name, param) { // level/pan → live nodes on a short glide + if (!N || !N.drums || !N.drums[name]) return; + const t = ctx.currentTime, dv = N.drums[name]; + if (param === "level") dv.g.gain.setTargetAtTime(KIT[name].level * 1.2, t, 0.02); + else if (param === "pan") dv.pan.pan.setTargetAtTime(KIT[name].pan * 2 - 1, t, 0.02); + } + + let ohatEnv = null; // the currently-ringing open hat, for the TR choke + function choke(t) { if (ohatEnv) { ohatEnv.gain.cancelScheduledValues(t); ohatEnv.gain.setTargetAtTime(0, t, 0.012); ohatEnv = null; } } + + function fireKick(vel) { + const t = ctx.currentTime, K = KIT.kick, dst = N.drums.kick.g; + const fB = 35 + K.tune * 45, peak = vel * vel; + const o = ctx.createOscillator(); o.type = "sine"; + o.frequency.setValueAtTime(fB * (2 + K.punch * 6), t); + o.frequency.exponentialRampToValueAtTime(fB, t + 0.045); // the pitch drop IS the thump + const g = ctx.createGain(); + g.gain.setValueAtTime(0, t); + g.gain.linearRampToValueAtTime(peak, t + 0.003); + g.gain.setTargetAtTime(0, t + 0.01, 0.05 + K.decay * 0.30); + o.connect(g); g.connect(dst); o.start(t); o.stop(t + 1.2); + if (K.punch > 0) { // a 2ms highpassed noise click for attack + const cs = dNoise(), hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 1000; + const cg = ctx.createGain(); cg.gain.setValueAtTime(vel * K.punch * 0.3, t); cg.gain.setTargetAtTime(0, t, 0.002); + cs.connect(hp); hp.connect(cg); cg.connect(dst); cs.start(t); cs.stop(t + 0.05); + } + } + function fireSnare(vel) { + const t = ctx.currentTime, K = KIT.snare, dst = N.drums.snare.g; + const f = 140 + K.tune * 120, peak = vel * vel, tau = 0.05 + K.decay * 0.30; + const toneG = ctx.createGain(); // body: two oscs, fixed ~80ms decay (snap 0 = all tone) + toneG.gain.setValueAtTime(0, t); toneG.gain.linearRampToValueAtTime(peak * (1 - K.snap) * 0.85, t + 0.002); + toneG.gain.setTargetAtTime(0, t + 0.004, 0.028); toneG.connect(dst); + [f, f * 1.5].forEach((fr) => { const o = ctx.createOscillator(); o.type = "triangle"; o.frequency.value = fr; o.connect(toneG); o.start(t); o.stop(t + 0.2); }); + const ns = dNoise(); // rattle: bandpassed + highpassed noise (snap 1 = all noise) + const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1800; bp.Q.value = 0.8; + const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 400; + const ng = ctx.createGain(); ng.gain.setValueAtTime(0, t); ng.gain.linearRampToValueAtTime(peak * K.snap * 1.1, t + 0.002); + ng.gain.setTargetAtTime(0, t + 0.004, tau); + ns.connect(bp); bp.connect(hp); hp.connect(ng); ng.connect(dst); ns.start(t); ns.stop(t + Math.max(0.2, tau * 6)); + } + function fireClap(vel) { + const t = ctx.currentTime, K = KIT.clap, dst = N.drums.clap.g, peak = vel * vel * 0.9; + const ns = dNoise(), bp = ctx.createBiquadFilter(); + bp.type = "bandpass"; bp.frequency.value = 700 + K.tone * 1500; bp.Q.value = 1.5; + const g = ctx.createGain(); g.gain.setValueAtTime(0, t); + const jit = () => (Math.random() * 2 - 1) * 0.002; // ±2ms per hit → the three claps breathe + for (const off of [0, 0.011, 0.023]) { const st = t + Math.max(0, off + jit()); g.gain.setValueAtTime(peak, st); g.gain.setTargetAtTime(0, st, 0.004); } + const body = t + 0.030 + jit(), tau = 0.04 + K.decay * 0.25; // then the ringing body (909 spikes → tail) + g.gain.setValueAtTime(peak, body); g.gain.setTargetAtTime(0, body, tau); + ns.connect(bp); bp.connect(g); g.connect(dst); ns.start(t); ns.stop(body + Math.max(0.2, tau * 6)); + } + function fireHat(vel, open) { + const t = ctx.currentTime, K = open ? KIT.ohat : KIT.hat, dst = N.drums[open ? "ohat" : "hat"].g; + choke(t); // a closed OR a new open hat chokes the ringing open hat — the TR rule + const ns = dNoise(), hp = ctx.createBiquadFilter(); + hp.type = "highpass"; hp.frequency.value = 5500 + K.tone * 4000; + const g = ctx.createGain(), peak = vel * vel * (open ? 0.8 : 0.7); + const tau = open ? (0.10 + K.decay * 0.5) : (0.012 + K.decay * 0.06); + g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + 0.001); g.gain.setTargetAtTime(0, t + 0.002, tau); + ns.connect(hp); hp.connect(g); g.connect(dst); ns.start(t); ns.stop(t + Math.max(0.1, tau * 6)); + if (open) ohatEnv = g; // this open hat can now be choked + } + + // fire a drum — mirrors pluck()'s wake-up, so a drum can be the first sound the page makes + function drum(name, vel) { + if (!DRUM_DEFS[name]) return; // unknown voice → silent, no side effects + if (!AC) return; + if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); } + ctx.resume(); + if (!on) N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.1); + const v = vel == null ? 0.8 : c01(vel); + if (name === "kick") fireKick(v); + else if (name === "snare") fireSnare(v); + else if (name === "clap") fireClap(v); + else if (name === "hat") fireHat(v, false); + else if (name === "ohat") fireHat(v, true); + } + function drumParam(name, param, value) { + const kv = KIT[name]; if (!kv || !(param in kv)) return undefined; + if (value === undefined) return kv[param]; // get + if (Number.isFinite(value)) { kv[param] = c01(value); drumSync(name, param); } // set (finite, clamped 0..1) → live nodes; ignore junk + return kv[param]; + } + function drumParamList(name) { const def = DRUM_DEFS[name]; return def ? def.params.map((p) => ({ key: p, label: p })) : []; } + function drumKit() { const o = {}; for (const v in KIT) { o[v] = {}; for (const p in KIT[v]) o[v][p] = KIT[v][p]; } return o; } // deep, JSON-safe copy + function drumKitLoad(obj) { // merge a snapshot; missing/foreign keys keep defaults + if (!obj) return; + for (const v in obj) if (KIT[v] && obj[v]) for (const p in obj[v]) + if ((p in KIT[v]) && Number.isFinite(obj[v][p])) { KIT[v][p] = c01(obj[v][p]); drumSync(v, p); } + } + function params() { const t = ctx.currentTime, S = (p, v, tc) => p.setTargetAtTime(v, t, tc || 0.1); S(N.leadFilt.frequency, 250 + d("filter.cutoff", 0.3) * 5000, 0.05); @@ -8176,7 +8305,8 @@ toggle() { on ? stop() : start(); paint(); return on; }, playing() { return on; }, stringVoice(v) { if (v === undefined) return ksMode; if (!ctx) start(); ksMode = !!v; sampleMode = false; return ksMode; }, godsoniq(secs) { return godsoniq(secs); }, sampling() { return sampleMode && !!sampleBuf; }, - feedTab() { return feedTab(); }, feeding() { return !!extStream; } }; + feedTab() { return feedTab(); }, feeding() { return !!extStream; }, + drum, drumParam, drumParamList, drumKit, drumKitLoad }; })(); // ---------------------------------------------------------------------------