diff --git a/viz/index.html b/viz/index.html
index 4bc1278..58f401d 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -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 0–13, 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() {