diff --git a/viz/index.html b/viz/index.html
index f79ea61..5643e37 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -257,6 +257,7 @@
#beat .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; }
#beat .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
+ #beat .midibtn.rec-on { background: rgba(230,90,90,0.28); color: #ffd2d2; border-color: rgba(240,110,110,0.6); }
#beat .bmini { padding: 2px 7px; font-size: 11px; line-height: 1.1; }
#beat .grow { gap: 12px; flex-wrap: wrap; align-items: center; margin: 4px 0 2px; }
#beat .gctl { display: inline-flex; align-items: center; gap: 5px; }
@@ -1806,8 +1807,33 @@
bytes.push(0, 0xff, 0x2f, 0x00);
trks.push([bytes]);
}
+ // channel-9 GM drum track β every lit drum step, the beat looped across the arrangement's bars
+ {
+ const dev = [];
+ for (const v of DRUM_VOICES) {
+ const sq = seqs["drum." + v];
+ if (!sq) continue;
+ const gm = DRUM_GM[v];
+ for (let b = 0; b < arr.bars; b++) {
+ for (let s2 = 0; s2 < 16; s2++) {
+ if (!sq.steps[s2]) continue;
+ const dvel = Math.round(clamp(sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127);
+ const t0 = (b * 16 + s2) * TICKS16, t1 = Math.max(t0 + 1, t0 + TICKS16 - 2); // one 16th long
+ dev.push([t0, 0x99, gm, dvel], [t1, 0x89, gm, 0]); // ratchet sub-hits omitted in v1
+ }
+ }
+ }
+ if (dev.length) {
+ dev.sort((a, b2) => a[0] - b2[0]);
+ const bytes = [];
+ let last = 0;
+ for (const e of dev) { bytes.push(...midVarlen(e[0] - last), ...e.slice(1)); last = e[0]; }
+ bytes.push(0, 0xff, 0x2f, 0x00);
+ trks.push([bytes]);
+ }
+ }
if (trks.length === 1) { // only the tempo track β nothing to hear yet
- eventTicker.unshift({ text: "no note clips in the arrangement yet β place some A/B/C cells on lead or bass first", tAdded: now(), src: "sys" });
+ eventTicker.unshift({ text: "nothing to export yet β place A/B/C clips on a note lane, or draw a beat in π₯ first", tAdded: now(), src: "sys" });
return;
}
const flat = (t2) => t2.length === 1 ? t2[0] : t2.reduce((a, e) => a.concat(e), []);
@@ -1842,7 +1868,7 @@
document.body.appendChild(beatEl);
let arrGrooveRefresh = null; // grooves-tab playhead painter
let beatRefresh = null; // π₯ beat-panel playhead painter (mirrors arrGrooveRefresh)
- const beat = { open: false, sel: "kick", exprMode: "velocity", kitOpen: false };
+ const beat = { open: false, sel: "kick", exprMode: "velocity", kitOpen: false, rec: false };
let arrTab = "clips", arrSelKey = null, arrSelLetter = "A";
function closeArranger() {
arr.open = false; arrEl.classList.remove("open");
@@ -2584,6 +2610,11 @@
const play = document.createElement("button"); play.className = "midibtn";
const paintPlay = () => { const p = anyOn(); play.textContent = p ? "β stop" : "βΆ play"; play.classList.toggle("on", p); };
paintPlay();
+ const rec = document.createElement("button"); rec.className = "midibtn"; rec.textContent = "βΊ rec";
+ rec.title = "record β finger-drum hits (keys 1β5) tap into the grid, quantized to the nearest 16th";
+ const paintRec = () => rec.classList.toggle("rec-on", beat.rec);
+ paintRec();
+ rec.onclick = () => { beat.rec = !beat.rec; paintRec(); };
const bpmL = document.createElement("span"); bpmL.className = "glbl"; bpmL.style.minWidth = "auto"; bpmL.textContent = "bpm";
const bpm = document.createElement("input"); bpm.type = "number"; bpm.className = "eunum"; bpm.min = "40"; bpm.max = "180";
bpm.value = String(Math.round(godtime.bpm));
@@ -2593,8 +2624,8 @@
bpm.value = String(Math.round(godtime.bpm)); };
const x2 = document.createElement("span"); x2.className = "aclose"; x2.textContent = "Γ"; x2.onclick = closeBeat;
const hint = document.createElement("span"); hint.className = "albl";
- hint.textContent = "βΆ starts all five lanes Β· click a step to draw Β· the feel is shared with tracks";
- top.appendChild(title); top.appendChild(play); top.appendChild(bpmL); top.appendChild(bpm);
+ hint.textContent = "keys 1β5 play the kit Β· βΊ rec taps them into the grid Β· the feel is shared with tracks";
+ top.appendChild(title); top.appendChild(play); top.appendChild(rec); top.appendChild(bpmL); top.appendChild(bpm);
top.appendChild(hint); top.appendChild(x2);
beatEl.appendChild(top);
@@ -2877,6 +2908,13 @@
amplerEl.appendChild(h);
}
+ // the one place a drum voice sounds β shared by seqTick AND the finger-drum keys (E.1),
+ // so the sequenced path and the played path can never drift: same guard, same GM MIDI.
+ function fireDrum(name, vel, durMs) {
+ if (Synth.drum) Synth.drum(name, vel); // guarded β silent if the engine isn't present
+ const gm = DRUM_GM[name];
+ if (gm != null) midiPluck(9, gm, vel, durMs); // GM drums out on channel 9 (0x99)
+ }
function seqTick() {
for (const k in seqs) {
const sq = seqs[k];
@@ -2886,15 +2924,14 @@
if (sq.steps[cur]) { // 0/1 gate
const chc = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire?
if (chc >= 1 || Math.random() < chc) {
- const name = k.slice(5), gm = DRUM_GM[name];
+ const name = k.slice(5);
const rat = clamp(Math.round((sq.rat && sq.rat[cur]) || 1), 1, 4); // ratchet: sub-hits per step
const sub = (60000 / Math.max(40, godtime.bpm) / 4) / rat; // ms between sub-hits
const fireHit = () => {
let vel = sq.vel ? sq.vel[cur] : 0.8;
if (groove.velo) // shared Velocity Deviation (Β§10.5.11)
vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1);
- if (Synth.drum) Synth.drum(name, vel); // guarded β silent until Agent A merges
- if (gm != null) midiPluck(9, gm, vel, sub * 0.9); // GM drums out on channel 9 (0x99)
+ fireDrum(name, vel, sub * 0.9); // the shared fire path (finger-keys use it too)
};
fireHit();
for (let h = 1; h < rat; h++) setTimeout(fireHit, h * sub);
@@ -6865,6 +6902,21 @@
if (ev.key === "b" || ev.key === "B") {
beat.open ? closeBeat() : openBeat(); // π₯ GODRUM β make a beat
}
+ if (beat.open && !ev.repeat && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { // finger-drum the kit (1β5)
+ const byCode = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].indexOf(ev.code); // ev.code keeps β§+digit mapped
+ const di = byCode >= 0 ? byCode : "12345".indexOf(ev.key); // β¦with an ev.key fallback for code-less envs
+ if (di >= 0) {
+ const name = DRUM_VOICES[di], vel = ev.shiftKey ? 0.5 : 0.9; // β§ = the ghost note
+ fireDrum(name, vel, 60000 / Math.max(40, godtime.bpm) / 4 * 0.9);
+ if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step
+ const step = Math.round(seqPhase) % 16; // frac > .5 rounds to the upcoming 16th (raw grid; swing rides at playback)
+ const sq = drumSeqFor("drum." + name);
+ sq.steps[step] = 1; sq.vel[step] = vel;
+ if (beatRefresh) beatRefresh();
+ }
+ return;
+ }
+ }
});
// ---------------------------------------------------------------------------