🥁 godrum: five synthesized drum voices in the Synth engine

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 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-12 21:34:19 +10:00
parent 4e72592cd3
commit 28be1de3b8

View File

@ -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 };
})();
// ---------------------------------------------------------------------------