🥁 godrum: 2.1 — choke anchor, euclid guard, proto-pollution, zero-mode beat

Four defects from the independent 4-lens adversarial review of Stage 2.

R8  choke same-task noise pop — DEVIATION from the brief's fix, verified by
    measurement. Read-before-cancel does NOT work: reading gain.value right after
    setValueAtTime(0,t) in the same task returns the 1.0 default (the envelope
    hasn't rendered), both offline AND live — so the anchor is 1.0 and it still
    pops (measured 0.45 vs a 0.15 ohat). Correct primitive is cancelAndHoldAtTime,
    which holds the timeline value at t (0 same-task, decaying level mid-ring);
    read-value kept as the legacy fallback. Same idiom applied to fireKick's duck.
    Verified live: same-task double-ohat 0.152 ~= single 0.149 (no pop); normal
    different-task choke still cuts the tail 70%.

R9  euclid junk input — trim, split /[\s,]+/, drop empty tokens, Number(), bail
    unless the first token is finite. " 4"/",4" -> E(4,16); "abc"/"" -> untouched;
    "0" -> cleared; "5,2" -> E(5,16) rot 2. (Empty-token filter also fixes the ""
    vs "0" distinction a bare Number() can't.)

R10 prototype pollution via kit data (MAJOR — patches/vibes are cross-user) —
    own-property guard (hasOwnProperty) everywhere a caller-supplied name indexes
    KIT / N.drums / DRUM_DEFS: drum, drumParam, drumParamList, drumKit, drumKitLoad,
    drumSync. Verified: drumKitLoad({"__proto__":{toString:0.5}}) and
    drumParam("__proto__",…) leave Object.prototype intact; legit keys still apply;
    normal round-trip works.

R11 a beat is a build — hoist the canonical seed into DRUM_SEED (one source of
    truth for seedBeat + zeroHasBuild); zeroHasBuild() now returns true when any
    drum lane is on or its gates differ from the seed. Untouched seed still exits
    quietly; ▶ or a drawn step now shows the keep/weave card so the beat survives.

Temporary verify hooks removed (grep-clean). No merge to main, no deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-12 23:57:31 +10:00
parent fa5aa434d1
commit b0fad08556

View File

@ -1628,6 +1628,9 @@
// EMPTY (a fresh drum lane makes no sound; seqFor's all-1s default would be an
// instant four-on-everything). Fired straight from seqTick, so swing/humanize ride free.
const DRUM_VOICES = ["kick", "snare", "clap", "hat", "ohat"];
// the canonical first-open seed — ONE source of truth for seedBeat() and zeroHasBuild() (they must not drift)
const DRUM_SEED = { kick: [0, 7, 10], snare: [4, 12], clap: [],
hat: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], ohat: [14] };
const DRUM_GM = { kick: 36, snare: 38, clap: 39, hat: 42, ohat: 46 }; // General MIDI drum notes (channel 9)
function drumSeqFor(key) {
return seqs[key] || (seqs[key] = {
@ -2508,14 +2511,8 @@
// arranger edits — one groove for the whole machine.
function seedBeat() { // first open: a pattern so ▶ makes music at once
if (DRUM_VOICES.some(v => seqs["drum." + v])) return;
const K = drumSeqFor("drum.kick"), S = drumSeqFor("drum.snare"),
H = drumSeqFor("drum.hat"), O = drumSeqFor("drum.ohat");
drumSeqFor("drum.clap"); // clap lane exists but stays empty
[0, 7, 10].forEach(i => K.steps[i] = 1);
[4, 12].forEach(i => S.steps[i] = 1);
for (let i = 0; i < 16; i++) { H.steps[i] = 1; H.vel[i] = (i % 2) ? 0.4 : 0.8; }
H.steps[14] = 0; H.steps[15] = 0; // clear the closed hats around the open hat so it rings into the downbeat
O.steps[14] = 1;
for (const v of DRUM_VOICES) { const sq = drumSeqFor("drum." + v); for (const i of DRUM_SEED[v]) sq.steps[i] = 1; }
const H = seqs["drum.hat"]; for (let i = 0; i < 16; i++) H.vel[i] = (i % 2) ? 0.4 : 0.8; // seed hat dynamics (step gates come from DRUM_SEED hat 013, open hat rings into the downbeat)
// all lanes stay on:false — the user's first ▶ starts the beat
}
function closeBeat() {
@ -2601,8 +2598,10 @@
const euclidPrompt = (sq, v) => {
const ans = prompt("⬢ euclid " + v + " — pulses across 16 steps (or pulses,rotate ):", "4");
if (ans == null) return;
const parts = String(ans).split(/[ ,]+/).map(x => parseInt(x));
const pulses = clamp(Math.round(parts[0] || 0), 0, 16), rot = clamp(Math.round(parts[1] || 0), 0, 15);
const parts = String(ans).trim().split(/[\s,]+/).filter(x => x !== "").map(Number); // drop leading empties from " 4" / ",4"
if (!parts.length || !Number.isFinite(parts[0])) return; // junk / empty confirm → no-op; an explicit "0" still clears
const pulses = clamp(Math.round(parts[0]), 0, 16),
rot = clamp(Math.round(Number.isFinite(parts[1]) ? parts[1] : 0), 0, 15);
euclidFill(sq.steps, pulses, 16, rot, false);
};
const lanes = [];
@ -3050,6 +3049,12 @@
if (zeroUI.nodes && Object.keys(zeroUI.nodes).length) return true;
for (const k in tweaks) { const t = tweaks[k];
if (t && ((t.offset && Math.abs(t.offset) > 0.0015) || (t.gain != null && Math.abs(t.gain - 1) > 0.01) || t.mute >= 0.5 || t.freeze >= 0.5)) return true; }
for (const v of DRUM_VOICES) { // a beat is a build too (R11): any lane playing, or its gates edited off the seed
const sq = seqs["drum." + v]; if (!sq) continue;
if (sq.on) return true;
const seed = DRUM_SEED[v];
for (let i = 0; i < 16; i++) if ((sq.steps[i] ? 1 : 0) !== (seed.indexOf(i) >= 0 ? 1 : 0)) return true;
}
return false;
}
function finishZeroExit(mode, name) { // mode: keep | old | weave (C1)
@ -8175,6 +8180,9 @@
};
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]; }); }
// caller-supplied names (from patch/vibe JSON — cross-user data) must be OWN keys, never reached through
// the prototype chain — a "__proto__" key would otherwise write onto Object.prototype and corrupt the runtime.
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);
const c01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
@ -8375,14 +8383,19 @@
// 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;
if (!N || !N.drums || !own(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);
}
// cancel pending automation but HOLD the value the timeline actually has at t — 0 for a same-task
// hit whose envelope hasn't rendered (reading .value there returns the 1.0 default → a pop), the
// decaying level mid-ring. cancelAndHoldAtTime is the right primitive; read-value is the legacy fallback.
const cancelHold = (p, t) => { if (p.cancelAndHoldAtTime) p.cancelAndHoldAtTime(t);
else { p.cancelScheduledValues(t); p.setValueAtTime(p.value, t); } };
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 choke(t) { if (ohatEnv) { cancelHold(ohatEnv.gain, 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;
@ -8401,7 +8414,7 @@
cs.connect(hp); hp.connect(cg); cg.connect(dst); cs.start(t); cs.stop(t + 0.05);
}
if (K.duck > 0 && N.duckG) { // the kick parts the sea — dip pad+drone, then let them flood back
const dg = N.duckG.gain; dg.cancelScheduledValues(t); dg.setValueAtTime(dg.value, t);
const dg = N.duckG.gain; cancelHold(dg, t); // same cancel-and-hold idiom as choke() (robust when two kicks land close)
dg.linearRampToValueAtTime(1 - K.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15);
}
}
@ -8444,7 +8457,7 @@
// 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 (!own(DRUM_DEFS, name)) return; // unknown / non-own voice → silent, no side effects
if (!AC) return;
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
ctx.resume();
@ -8457,17 +8470,18 @@
else if (name === "ohat") fireHat(v, true);
}
function drumParam(name, param, value) {
const kv = KIT[name]; if (!kv || !(param in kv)) return undefined;
if (!own(KIT, name) || !own(KIT[name], param)) return undefined; // own-key only — no proto-chain reach
const kv = KIT[name];
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
function drumParamList(name) { const def = own(DRUM_DEFS, name) ? DRUM_DEFS[name] : null; return def ? def.params.map((p) => ({ key: p, label: p })) : []; }
function drumKit() { const o = {}; for (const v in KIT) if (own(KIT, v)) { o[v] = {}; for (const p in KIT[v]) if (own(KIT[v], p)) o[v][p] = KIT[v][p]; } return o; } // deep, JSON-safe, own-keys only
function drumKitLoad(obj) { // merge a snapshot; missing/foreign/proto 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); }
for (const v in obj) if (own(obj, v) && own(KIT, v) && obj[v]) for (const p in obj[v])
if (own(obj[v], p) && own(KIT[v], p) && Number.isFinite(obj[v][p])) { KIT[v][p] = c01(obj[v][p]); drumSync(v, p); }
}
function params() {