Godstrument/GODRUM_BRIEF.md
2026-07-13 12:15:38 +10:00

46 KiB
Raw Blame History

GODRUM — build brief · Stage 1

You are an execution agent (Opus). You have been assigned a lane: Agent A (drum engine) or Agent B (beat panel + sequencer). Read Part 0 fully, then execute ONLY your own part. Do not do the other agent's work, even if it looks easy. Your output will be reviewed by the planning model before merge; end your run with the report described in §0.8.


Part 0 — shared context (both agents read this)

0.1 What we're building

GODRUM: a "make a beat" feature for the Godstrument. Godstrument already has a complete rhythmic backbone — a master clock (godtime), a 16-step sequencer with per-step velocity/chance/ratchet/p-lock, swing + humanize with groove presets, Euclidean fills, and an 8-bar arranger — but no drums. The only percussion today is one synthesized noise hi-hat. Stage 1 adds:

  • Agent A: five synthesized drum voices (kick, snare, clap, closed hat, open hat) inside the Synth engine, with a small frozen public API.
  • Agent B: drum lanes in the step sequencer + a 🥁 beat panel to program them, plus persistence of the kit.

Later stages (not yours): integration + kick-ducking (C), GODSONIQ sample-slicing (D), finger-drumming/live record (E), grimoire docs (F).

0.2 The codebase in 60 seconds

  • Everything is one file: viz/index.html (~8,470 lines, ~600 KB). Lines ~4251200 are the in-app manual ("grimoire", HTML prose). Line 1213 opens one giant IIFE containing the whole app. Vanilla JS + raw Web Audio, no libraries.
  • The synth is the Synth IIFE at lines 77948180. All voices sum into N.preSat → saturation → post → glitch → bitcrush worklet → N.out → limiter → destination, with reverb/delay/chorus/Earth-Echo as parallel sends built in build() (7817+).
  • The groove clock: frame() advances seqPhase (line 4066); swing/humanize shift each 16th's onset (40684074); on each new step seqTick() (line 2413) fires every enabled seq. Swing is applied at the clock level, so anything fired from seqTick() inherits the groove for free.
  • Seqs live in the seqs object (line 1488), keyed by destination key, created by seqFor(key, isNote) (1556): {on, steps[16], vel[16], chance[16], rat[16], lock[16]}.
  • Patches serialize via serializePatch() (2621) / loadPatch() (2634); seqs is deep-copied wholesale, and migrateSeq() (2560) normalizes each on load — any 16-step seq round-trips, so drum patterns saved under seqs persist with zero extra work. Kit knob state does need explicit plumbing (Agent B).
  • UI: full-screen canvas + DOM overlay panels. Launcher buttons 🎛 tracks / 🎹 OMNI are created at lines 23992411 (#arrbtn, #omnibtn — CSS in the <style> block, search for those ids). The right-click sky menu is built with ctxItem(label, fn, kbd) around lines 65536631. The arranger panel openArranger() (1729) with its "grooves" tab (1783+) is the house style for grid editors — study it before building anything.
  • Keyboard keys already taken (handler ~63406362): P C O W Z T Escape + arrows. B is free — GODRUM claims it.

0.3 Territory rules — this is what makes parallel work safe

  • Agent A edits ONLY inside the Synth IIFE: between const Synth = (function () { (line 7794) and its closing })(); (line 8180). Not one character outside it.
  • Agent B edits anywhere EXCEPT inside that IIFE (and B must not redefine or monkey-patch Synth).
  • Both branches will be merged by git; disjoint regions merge clean. If you believe you must cross the boundary, STOP, note it in your report as an open question, and work around it instead.

0.4 Git workflow

Work in your own worktree so the two lanes never collide:

cd /Users/m3ultra/Documents/godstrument
# Agent A:
git worktree add ../godstrument-A -b godrum/a-engine
# Agent B:
git worktree add ../godstrument-B -b godrum/b-beat-panel

Do all work in your worktree. Commit style matches the repo: lowercase, emoji-prefixed, e.g. 🥁 godrum: five drum voices in the Synth engine. Small logical commits are fine; one commit is also fine.

0.5 Hard rules

  • Never deploy. No rsync, no ssh, no systemctl. Stage 1 is local-only.
  • Never commit godstrument.db, godstrument_users.db, auth_secret, anything in patches/, or viz/manual.html changes (it's generated).
  • Don't touch the grimoire prose (lines ~4251200) or GODSTRUMENT_MANUAL_SOURCE.md — docs are Stage 4, and viz/index.html's grimoire is canon when they disagree.
  • Surgical edits only. The file is huge: read it in targeted chunks, never rewrite whole sections, never reformat code you didn't write, match the surrounding style exactly (2-space indent, const, terse inline comments in the house voice — poetic but precise).
  • No new dependencies, no external assets. Drums are synthesized from oscillators and noise buffers, like everything else in this engine.
  • All new numeric params are normalized 0..1 at the API boundary and mapped to real units inside the engine (this is the house convention — see d("filter.cutoff", 0.3) * 5000 at 7976).

0.6 The frozen interface contract

Agent A implements exactly this; Agent B calls exactly this and nothing else. Neither agent changes a signature without flagging it as an open question.

// Fire a drum. name ∈ "kick"|"snare"|"clap"|"hat"|"ohat". vel 0..1 (default 0.8).
// Wakes the audio graph if it isn't running (same pattern as pluck()).
Synth.drum(name, vel)

// Kit parameters, all normalized 0..1 (pan: 0=hard left, 0.5=center, 1=hard right).
Synth.drumParam(name, param, value)   // set
Synth.drumParam(name, param)          // get → number

// Ordered knob metadata for the panel, e.g. drumParamList("kick") →
// [{key:"tune",label:"tune"},{key:"punch",label:"punch"},{key:"decay",label:"decay"},
//  {key:"level",label:"level"},{key:"pan",label:"pan"}]
Synth.drumParamList(name)

// Whole-kit snapshot for persistence: plain JSON-safe object of {voice:{param:0..1}}.
Synth.drumKit()          // → deep copy
Synth.drumKitLoad(obj)   // merge a snapshot back in (missing keys keep defaults)

Voice/param schema (A implements; B renders from drumParamList, never hardcodes):

voice params (in order) defaults
kick tune, punch, decay, duck, level, pan .5, .5, .5, .35, .85, .5
snare tune, snap, decay, level, pan .5, .6, .4, .7, .5
clap tone, decay, level, pan .5, .5, .65, .5
hat tone, decay, level, pan .6, .35, .5, .5
ohat tone, decay, level, pan .6, .5, .45, .5

(duck was added to the kick in Stage 2 (R6) — it dips pad+drone via duckG on every kick hit. This table is the current truth.)

Seq keys for drum lanes: "drum.kick", "drum.snare", "drum.clap", "drum.hat", "drum.ohat" (Agent B's territory; named here so both sides agree).

Merge-safety guard (Agent B): every call into the new API must be guarded — if (Synth.drum) Synth.drum("kick", v); — so branch B runs standalone (silent drums) before the merge.

0.7 Running & verifying locally

From your worktree, the quickest loop is the static server (the app boots without the Python hub; world feeds just stay silent, which is fine for drums):

python3 -m http.server 8901 --directory viz   # A uses 8901, B uses 8902
# open http://localhost:8901 in a browser

If you need the full hub (you shouldn't for Stage 1), the main checkout's venv works: /Users/m3ultra/Documents/godstrument/.venv/bin/python run.py --no-browser --profile minimal (serves on 8088 — don't run it from both worktrees at once).

After edits, sanity-check you didn't break the file: load the page, open devtools, confirm zero console errors on boot, click , confirm the world still sounds, press T and O, confirm the arranger and OMNI still open. That regression check is mandatory for both agents.

0.8 Your report (end your run with exactly this)

  1. Branch name + worktree path + commit hash(es).
  2. What was built — bullets, with decisions you made and why (especially anywhere the brief left room).
  3. The diffgit diff main --stat plus the full diff (paste it; it will be reviewed line by line).
  4. How you verified — exact commands/clicks, what you heard/saw, plus the §0.7 regression check results.
  5. Deviations & open questions — anything you changed vs. this brief, anything you're unsure about, anything you want the reviewer to look hard at.
  6. Territory confirmation — state plainly that you touched nothing outside your region (or explain precisely what and why).

Part A — Agent A: the GODRUM engine

Territory: inside the Synth IIFE only (viz/index.html lines 77948180). Goal: five synthesized drum voices + the frozen API of §0.6. No UI, no sequencer wiring, no persistence — that's B and later stages.

A.1 Kit state

Near the top of the IIFE (by the other lets around 77967799), add a KIT object holding the normalized params of §0.6 with the listed defaults, plus a DRUM_DEFS structure that gives each voice its ordered param list (this backs drumParamList). Keep it data-driven — the panel renders whatever you declare.

A.2 Audio routing

In build() (7817+), alongside the other buses, create:

  • drumBus = GainNode, gain ~0.8, connected to preSat (so drums ride the same saturation → crush → chorus/reverb/echo world as every other voice — that's the instrument's ethos, keep it).
  • Per voice: a persistent gain (level) → StereoPanner (pan) → drumBus. Store them on N (e.g. N.drums.kick = {g, pan}) like the other nodes. Individual hits are one-shot node graphs (like pluck() / noiseHit()) that connect into their voice's gain node.
  • In params() or in the drumParam setter (your choice — setter is simpler and cheaper), keep node values in sync with KIT via setTargetAtTime with a short time-constant. Level maps 0..1 → gain 0..1.2; pan maps 0..1 → -1..1.

A.3 Voice recipes

All velocities scale amplitude (vel*vel feels better than linear for drums — your call, note it). All ramps: remember exponentialRampToValueAtTime can't hit 0 — use small floors or setTargetAtTime, matching the hit() / pluck() idioms.

kick — sine osc. Base freq fB = 35 + tune*45 Hz. Pitch envelope: start at fB * (2 + punch*6), exponential-ramp down to fB over ~45 ms. Amp: linear ramp 0 → peak in ~3 ms, then setTargetAtTime(0, t+0.01, τ) with τ = 0.05 + decay*0.30. Add a tiny click for punch: a 2 ms noise burst, highpassed ~1 kHz, level vel*punch*0.3. Stop the osc after ~1.2 s.

snare — two layers, mixed by snap (0 = all tone, 1 = all noise):

  • tone: two oscs (sine or triangle) at f and f*1.5, f = 140 + tune*120 Hz, fixed short decay ~80 ms;
  • noise: buffer noise → bandpass ~1.8 kHz (Q ≈ 0.8) → highpass 400 Hz, decay τ = 0.05 + decay*0.30.

clap — buffer noise → bandpass centered 700 + tone*1500 Hz (Q ≈ 1.5). Envelope = the classic 909 spikes: three short bursts at ≈ 0/11/23 ms (each with ±2 ms random jitter per hit, so claps breathe) then the main body at ~30 ms with tail τ = 0.04 + decay*0.25. One noise source + a stepped gain envelope (setValueAtTime spikes + setTargetAtTime tail) is the cheap clean way.

hat (closed) — follow the noiseHit() idiom (7965): noise → highpass 5500 + tone*4000 Hz, decay τ = 0.012 + decay*0.06. Short and dry.

ohat (open) — same topology, decay τ = 0.10 + decay*0.5.

Choke: track the currently-ringing ohat's envelope gain; when drum("hat") OR a new drum("ohat") fires, kill the previous ohat with setTargetAtTime(0, t, 0.012). Closed chokes open — the TR rule.

Relative levels at default params and vel 0.8 should sit right in the existing mix: kick clearly audible under the pad wall, hats below the existing perc.density hat. Tune by ear against the factory world (press , set a mood, fire drums from console).

A.4 drum() entry point

Mirror pluck()'s wake-up exactly (81168122): bail if no AC; lazily create ctx/build()/loadWorklets(); ctx.resume(); if !on, bring N.out.gain up so a drum can be the first sound the page makes. Unknown name → return silently. Clamp vel 0..1.

A.5 Export

Extend the return object (81758179) with drum, drumParam, drumParamList, drumKit, drumKitLoad. Keep the existing style — compact, one object literal.

A.6 Verify

Synth isn't on window, so for console testing add a TEMPORARY line window._gsSynth = Synth; — and remove it before your final commit (checklist item; state in your report that it's gone). Then from devtools:

_gsSynth.drum("kick"); _gsSynth.drum("snare",1); _gsSynth.drum("clap",0.6);
_gsSynth.drum("ohat"); setTimeout(()=>_gsSynth.drum("hat"), 300);   // choke audible?
// four-on-the-floor sanity loop:
let i=0; const h=setInterval(()=>{ _gsSynth.drum("kick"); if(i%2)_gsSynth.drum("hat"); if(i%4===2)_gsSynth.drum("snare"); if(++i>=16)clearInterval(h); }, 150);
_gsSynth.drumParam("kick","tune",0.9); _gsSynth.drum("kick");   // hear the change
JSON.stringify(_gsSynth.drumKit());                              // JSON-safe?
_gsSynth.drumKitLoad({kick:{tune:0.1}}); _gsSynth.drumParam("kick","tune"); // → 0.1

Check: drums audible with OFF (graph wakes), drums sit in the mix with ON, choke works, params audibly change the sound, kit round-trips, §0.7 regression passes.


Part B — Agent B: drum lanes + the 🥁 beat panel

Territory: anywhere in viz/index.html EXCEPT inside the Synth IIFE (77948180). Goal: drum seq lanes fired from seqTick(), a beat panel to program them, kit persistence. Every Synth.drum* call guarded (Synth.drum && ...) so your branch runs standalone before merge.

Before writing anything, read openArranger() (1729+) and especially its grooves tab (1783+), the seq helpers (14881565), seqTick() (24132443), the launcher buttons (23992411), ctxItem usage (65536631), and the #arrbtn/#omnibtn/#arranger CSS in the <style> block. Your panel should look and feel like it grew here.

B.1 Drum lanes in the sequencer

  • Add a drumSeqFor(key) helper next to seqFor() (1556): identical shape but steps fill 0 (a fresh drum lane is EMPTY — seqFor's gate default of all-1s would be an instant four-on-everything). Steps are 0/1 gates.
  • In seqTick() (2413), the existing loop does const n = dests.get(k); if (n && n.isNote) {...} — drum keys aren't in dests, so add a branch before that lookup:
if (k.slice(0, 5) === "drum.") {
  // gate + chance + ratchet + velocity, then Synth.drum — swing came free from the clock
  ...
  continue;
}

Reuse the existing idioms exactly: chance[cur] roll, ratchet via sub = (60000/bpm/4)/rat with setTimeout sub-hits, vel[cur] plus the groove.velo velocity-deviation line (24302432). Voice name = k.slice(5). Guard: if (Synth.drum) Synth.drum(name, vel);

  • MIDI out (cheap, in-idiom win): alongside the synth hit, send GM drums via the existing midiPluck(ch, note, vel, durMs) on channel 9: kick 36, snare 38, clap 39, hat 42, ohat 46. Follow how 2434 computes duration.
  • Give the beat panel its own repaint hook mirroring arrGrooveRefresh (declared 1722, called at 2442): declare beatRefresh, call it at the end of seqTick().

B.2 The 🥁 beat panel

A new DOM panel, id #beat, opened by:

  • a launcher button 🥁 beat (#beatbtn), sibling of #arrbtn/#omnibtn (23992411) with matching CSS;
  • keyboard B (add beside the T handler ~6361 — B is verified free);
  • a ctxItem("🥁 beat — make a beat", () => openBeat(), "B") in the sky menu near the 🎛 tracks entry (6566).

Layout, top to bottom (model the DOM/CSS on openArranger; dark translucent, resizable is nice-to-have, remember state like the arranger does if cheap):

  1. Header: 🥁 beat · master ▶/■ toggle (flips on for all five drum.* seqs at once) · BPM readout bound to godtime.bpm (editable number or drag, clamp 40180, respect godtime.locked) · close ✕.
  2. Groove row: the four GROOVE_PRESETS as one-click buttons (mirror 18451848) + swing / humanize / amount sliders bound to the existing global groove object. This is the same shared groove the arranger edits — that's correct, not a bug; label it "the feel (shared with tracks)".
  3. The grid: five lanes (kick / snare / clap / hat / ohat) × 16 steps. Click toggles a step. Playhead column follows seqCurStep via your beatRefresh. Beat-1/5/9/13 columns visually marked (the arranger grid will show you the house pattern). Per-lane left-edge controls: name, mute (toggles that seq's on), and a ⬢ Euclid button (prompt for pulses/rotate or reuse the grooves-tab euclid row idiom; call euclidFill(sq.steps, pulses, 16, rot, false)).
  4. Expression lane (one shared strip below the grid, arranger-style): selector for velocity / chance / ratchet, editing the selected lane's per-step arrays by click-drag — copy the grooves-tab interaction (1783+) rather than inventing one.
  5. Kit drawer (collapsible row): for each voice, render knobs/sliders from Synth.drumParamList(name) (guard its absence: render nothing pre-merge), read/write via Synth.drumParam. Simple styled range inputs in the house look are fine — don't rebuild OMNI's canvas knobs.

First-open seeding (arranger precedent: "pre-seeded so it makes music immediately"): if no drum.* seqs exist, seed — kick steps 0, 7, 10 · snare 4, 12 · hat all 16 with vel alternating 0.8/0.4 · ohat step 14 · clap empty. All lanes on: false; the user's first ▶ press starts the beat. Then a tap on ▶ must produce a beat with zero further setup.

B.3 Persistence

  • Drum patterns: free (they live in seqs; migrateSeq at 2560 passes any 16-step seq through — verify once by eye, note it in your report).
  • Kit: in serializePatch() (2621) add kit: (Synth.drumKit ? Synth.drumKit() : undefined), and in loadPatch() (2634) add if (p.kit && Synth.drumKitLoad) Synth.drumKitLoad(p.kit); plus a panel rebuild if #beat is open (mirror how loadPatch reopens the arranger at 2653).
  • Extend gsGrooveSelfCheck() (2569) with one drum check: migrateSeq round-trips a gate seq with a 0-filled steps array (fresh drum lane shape) unchanged.

B.4 Verify

Static-serve (§0.7, port 8902). Pre-merge, Synth.drum is undefined — that's expected; you're verifying UI + sequencing logic:

  • open panel via button, B key, and sky menu; seeded pattern visible; ▶ toggles lanes; playhead sweeps in time and swings when you pick "mpc 8-4";
  • steps toggle; velocity/chance/ratchet edit and visibly persist; Euclid fills fill; mute mutes;
  • with an IAC/virtual MIDI out selected (if available — otherwise note as untested), channel-9 notes appear;
  • save/load round-trip: run serializePatch()loadPatch(that) from console (find how the console can reach them, or verify via the account-less localStorage paths like zero-mode's gs_prezero); drum lanes survive;
  • §0.7 regression: no console errors, /T/O all still work, the arranger's grooves tab still edits normally (you share groove and seqs with it — don't break it).

Stage roadmap

stage agents scope status
1 A ∥ B engine + panel, on separate branches APPROVED
2 C merge, R1R7, audible verify, mix pass, kick-duck APPROVED @ fa5aa43
2.1 C hotfix R8R11 (R8 shipped as cancelAndHoldAtTime — reviewer-endorsed deviation) APPROVED @ b0fad08
3 D ∥ E D: AMPLER @ d2ddec1 · E: performance @ bd3340d both APPROVED — one known merge conflict, resolution verified (see Stage 4)
4 F merge D+E, cross-feature QA, grimoire chapter, build_manual.py, deploy checklist APPROVED — merge 9bfa0ee + docs 5314090

BUILD COMPLETE. godrum/integrate @ 5314090 holds the whole feature (9 commits, +809 app / +106 manual). Remaining: the human by-ear pass, then merge to main, then deploy per F.4 — human-gated.

Backlog (noted, deliberately not scheduled): duck on the echo return · pan/level as world-modulatable matrix dests · metallic osc-bank hats · MIDI ch-9 device-verification on real hardware · GODSPEAK.

Briefs for stage 4 are written by the planning model after reviewing Stage 3's reports. Do not start future-stage work.


Stage 2 — Agent C: integration, review fixes, and the audible verify

You are Agent C (Opus). Stage 1 is complete: godrum/a-engine (commit 28be1de, +132/2) and godrum/b-beat-panel (commit 9435c78, +358/0) both passed review. The reviewer has already trial-merged them: zero conflicts, merged file parses (node --check on the extracted app script). Your job: do the real merge, apply the seven review findings below, then run the end-to-end verification Stage 1 couldn't — the audible one. Read Part 0 of this brief first; §0.5 hard rules and §0.8 report format still bind you. The Stage-1 territory split is lifted — you're a single agent and may edit anywhere in viz/index.html — but stay surgical.

C.1 Setup & merge

cd /Users/m3ultra/Documents/godstrument
git worktree add ../godstrument-C -b godrum/integrate main
cd ../godstrument-C
git merge godrum/a-engine --no-edit && git merge godrum/b-beat-panel --no-edit

Confirm the merged app script parses (extract the <script> body, node --check). Never touch main, never deploy. Your work stays on godrum/integrate for review.

C.2 Review findings to fix (R1R7)

R1 — kick tail clipped at max decay (engine). fireKick stops its osc at t + 1.2, but max amp τ = 0.35 s → only ~3.4τ elapsed, ~3% residual → possible click at high decay. Stop at t + 0.01 + tau * 8 (compute tau once, reuse).

R2 — the seeded open hat gets choked instantly (panel seed). seedBeat puts closed hats on all 16 steps and the open hat on 14 — so the very next closed hat (step 15) chokes it after one 16th. Clear seed steps 14 and 15 on the closed-hat lane (H.steps[14] = 0; H.steps[15] = 0) so the open hat breathes into the downbeat — the classic move.

R3 — stale paint (panel, cosmetic). (a) The expression lane's lit bars don't repaint when grid steps are toggled or euclid-filled — call paintExpr from paintGrid (store the ref where both can see it). (b) Dragging a groove slider sets groove.preset = "custom" but the preset buttons keep their .on highlight — repaint them on slider input. Keep both cheap; don't rebuild the whole panel per input event.

R4 — groove velocity slider parity (panel). The beat panel exposes swing/humanize/amount but not groove.velo, which is applied to drum velocity. Add the fourth slider (max 1), matching the arranger's grooves tab.

R5 — moods must not kill the beat (decided by review — implement as stated). applyMood (~line 2458 pre-merge) sets seqs[k].on = false for ALL seqs in both the m.storm branch and the m.clearGrooves branch — switching moods silences the user's playing beat. The mood governs the world's voices; the beat belongs to the player. In both loops, skip drum lanes: if (k.slice(0, 5) === "drum.") continue;. Verify: play the beat → apply "first light" → apply a storm mood → the beat keeps playing throughout.

R6 — kick-duck: "the kick parts the sea" (engine). The Jonwayne low-end move, now yours to build:

  • Add a duck param to the kick's DRUM_DEFS entry (order: tune, punch, decay, duck, level, pan; default 0.35). Because the panel renders from drumParamList and persistence flows through drumKit, the knob and its save/load come free — verify they do.
  • In build(): create duckG (gain 1). Reroute padAmp and droneAmp through it (padAmp → duckG → preSat, same for drone) instead of straight to preSat. Leave the echo return alone for v1 (note it as a possible later extension).
  • In fireKick(): if KIT.kick.duck > 0: const dg = N.duckG.gain; dg.cancelScheduledValues(t); dg.setValueAtTime(dg.value, t); dg.linearRampToValueAtTime(1 - KIT.kick.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15);
  • Verify by offline render (Agent A's method): render a sustained pad + a kick, measure the pad-band RMS dip and recovery.

R7 — mix pass (engine internals only). Agent A flagged that the closed hat's default peak (~0.28 into preSat) sits above the ambient perc.density hat (~0.060.13), inverting §A.3's intent. Adjust the internal peak multipliers (e.g. hat 0.7, ohat 0.8 scalars downward) — do not change the normalized §0.6 contract defaults. Then balance the whole kit at defaults against the factory world: kick must read clearly under the pad wall, snare/clap sit between kick and hats. Use offline-render measurements plus your ears if audio output exists; document the multipliers you chose and why. Final by-ear judgment is reserved for the human — flag anything you're unsure of.

C.3 End-to-end verification (the audible pass)

Static-serve your worktree's viz/ (port 8903). For console access to internals, you may use Agent A's sanctioned pattern — a TEMPORARY window._gsSynth = Synth (and, if needed, window._gsPatch = {serializePatch, loadPatch}) hook, removed before your final commit (grep-verified, stated in your report).

  1. First-sound wake: fresh load, OFF → open 🥁 (B key) → press ▶ → the beat sounds. No console errors.
  2. Kit drawer, post-merge: sliders render for all five voices including the new duck; each audibly/measurably changes its voice (offline-render ZCR/decay deltas are acceptable proof); ▷ hear fires each voice.
  3. Groove: mpc 8-4 audibly swings (or measure onset deltas); ratchet 4 = a roll; chance <1 drops hits probabilistically.
  4. Choke works in the live page (open hat rings; closed hat cuts it).
  5. Persistence round-trip: with the hook, loadPatch(JSON.parse(JSON.stringify(serializePatch()))) → drum lanes, kit values (set a few non-defaults incl. duck), groove all survive; panel rebuilds correctly if open. Also drive the zero-mode enter/exit cycle and confirm the beat + kit survive it.
  6. Moods: R5 verified live (beat plays through mood changes; storm included).
  7. MIDI: if a virtual out port is available, verify ch-9 notes (0x99, GM 36/38/39/42/46); otherwise code-inspect and say so.
  8. Full §0.7 regression: zero console errors throughout; T O B all work; arranger grooves tab edits fine; beat↔arranger groove stays bidirectional; mutual exclusion of the two drawers works both ways.

C.4 Deliverables

Commit(s) on godrum/integrate (house style: 🥁 godrum: integrate — <what>). Do NOT merge to main. End with the §0.8 report, plus: the R1R7 fix list each marked done/deviated, your chosen R7 multipliers, and the offline-render measurement table (per voice + duck dip).


Stage 2.1 — Agent C: hotfix R8R11

You are Agent C again, back in your existing worktree (/Users/m3ultra/Documents/godstrument-C, branch godrum/integrate). An independent 4-lens adversarial review of your integration (16 agents; every finding confirmed by a 3-refuter majority against the actual code) surfaced four genuine defects your behavioral verification couldn't see — same-task timing, hostile input, hostile data, and a zero-mode interplay. Fix exactly these four, verify as specified, commit on the same branch. §0.5 and §0.8 still bind.

R8 — choke: anchor before you cancel (engine; the same-task noise pop). (CORRECTION, post-execution: the fix specified below was WRONG and Agent C proved it by measurement — AudioParam.value only updates at render-quantum boundaries, so read-before-cancel reads the 1.0 default for a never-rendered envelope and still pops. The shipped fix is cancelAndHoldAtTime via a cancelHold() helper (read-value kept only as legacy fallback), applied to both choke() and the duck. The reviewer endorses the deviation; the text below is preserved as history — do not re-implement it.) choke(t) does cancelScheduledValues(t) then setTargetAtTime(0, t, 0.012) with no value anchor. When the choked ohat was fired at the SAME currentTime (two hat-family hits in one task — real under ratchet-roll timers coalescing on main-thread jank), the cancel wipes the just-scheduled envelope including its setValueAtTime(0, t) anchor; the never-rendered gain falls back to the GainNode default 1.0 and a full-scale looped-noise burst (~+7 dB over a max ohat) decays over 12 ms instead of a silent choke. Fix — read the value BEFORE cancelling (read-after-cancel re-creates the bug: with the events wiped, .value reads the 1.0 default):

function choke(t) { if (ohatEnv) { const g = ohatEnv.gain, cur = g.value;
  g.cancelScheduledValues(t); g.setValueAtTime(cur, t); g.setTargetAtTime(0, t, 0.012); ohatEnv = null; } }

(In the same-task case cur reads 0 — the pre-cancel timeline still holds the 0-anchor — so the un-sounded hit stays silent; mid-ring it chokes smoothly from the current level.) Apply the same read-before-cancel ordering to the duckG anchor in fireKick for consistency (benign there today, same latent pattern). Verify: Synth.drum("ohat"); Synth.drum("ohat") synchronously in one task reproduces the pop deterministically pre-fix — offline-render it, show peak ≤ the intended ohat peak post-fix, and confirm the normal (different-task) choke still cuts the tail as before.

R9 — euclid prompt: junk input must not erase the pattern (panel). String(ans).split(/[ ,]+/) on " 4" or ",4" yields a leading empty token → parseInt("") = NaN → || 0 → 0 pulses → euclidFill zeroes all 16 steps: a mistyped confirm destroys the lane. Fix: trim first, split on /[\s,]+/, use Number(...), and bail (no-op) unless Number.isFinite(parts[0]). An explicit "0" still clears (that's a legitimate request); junk does nothing. Verify: " 4" → E(4,16) with rot 0; "abc" / "" → pattern untouched; "0" → cleared; "5,2" → E(5,16) rot 2.

R10 — prototype pollution via kit data (engine; MAJOR — patches and vibes are cross-user data). drumKitLoad guards voices with truthy KIT[v] and params with p in KIT[v] — both resolve through the prototype chain, so a kit object with a "__proto__" key (which JSON.parse happily creates as an own property) reaches KIT[v][p] = number and writes onto Object.prototype, corrupting the page runtime. drumParam has the identical gap (const kv = KIT[name]), and drumSync would throw on dv.g.gain. Fix: own-property checks everywhere a caller-supplied name indexes KIT or N.drums — e.g. const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k); then if (!own(KIT, name)) return; / if (!own(KIT[v], p)) continue; (also in drum()'s name check and drumParamList). Verify: drumKitLoad(JSON.parse('{"__proto__":{"toString":0.5}}')) and drumParam("__proto__","toString",0.5) leave Object.prototype untouched (({}).toString still a function) and return/no-op cleanly; a normal kit round-trip still works.

R11 — a beat built in zero mode must count as a build (app scope). zeroHasBuild() checks routes/orbs/tweaks only. A player who enters zero and builds only a beat exits via a mood → no keep/weave card → finishZeroExit("old", …)loadPatch(gs_prezero) wipes their playing beat silently. Fix: hoist the canonical seed pattern into one shared structure (so seedBeat and this check can't drift), and extend zeroHasBuild() to also return true when any drum.* lane is on, or any lane's step gates differ from the canonical seed (an untouched, never-played seed is not a build — don't nag someone who merely opened the panel). Verify: in zero — (a) open panel, do nothing, exit → no card (as today); (b) draw one step OR press ▶, exit via a mood → the keep/weave card appears and "keep" preserves the beat; (c) the normal non-zero flow unchanged.

Deliverables: one commit on godrum/integrate (🥁 godrum: 2.1 — choke anchor, euclid guard, proto-pollution, zero-mode beat), §0.8 report with each R marked done + the R8 offline measurement + R10 negative tests. Temporary hooks removed, grep-verified. No merge to main, no deploy.


Stage 3 — Agents D ∥ E: AMPLER and the performance layer

Stage 2 is APPROVED and the Stage 2.1 hotfix (R8R11) must already be on godrum/integrate before you branch — confirm its commit is present (git log --oneline godrum/integrate should show the 2.1 commit above fa5aa43). Stage 3 runs two parallel lanes again, with a territory split like Stage 1. Both agents: read Part 0 of this brief first — §0.5 hard rules and §0.8 report format still bind you. Branch from godrum/integrate (or from main if GODRUM has already landed there when you start):

cd /Users/m3ultra/Documents/godstrument
# Agent D:
git worktree add ../godstrument-D -b godrum/d-ampler godrum/integrate
# Agent E:
git worktree add ../godstrument-E -b godrum/e-performance godrum/integrate

Line numbers have shifted since Stage 1 — the Synth IIFE now spans roughly 81508690. Grep for names, don't trust raw line numbers.

Territory (the merge-safety mechanism — hold to it)

  • Agent D owns: (1) the inside of the Synth IIFE; (2) a NEW self-contained AMPLER UI block (its own functions, inserted directly AFTER the openBeat function's closing brace); (3) ONE ctxItem line placed directly after the GODSONIQ entry in the sky menu; (4) a NEW #ampler CSS block placed directly after the #beatbtn rules. D must NOT touch openBeat/seedBeat/seqTick/the keydown handler/exportMid/serializePatch.
  • Agent E owns: openBeat (header additions), the keydown handler, seqTick, exportMid. E must NOT touch the Synth IIFE, must add no ctx-menu entries, and should reuse existing CSS classes (if a new rule is truly needed, add it inside the existing #beat block).
  • Every Synth.ampler* call in UI code is D's own (inside D's block); E never calls the AMPLER API in this stage.

The frozen AMPLER contract (D implements; the panel may consume it in Stage 4)

Synth.amplerArm(secs)          // → bool. Capture `secs` (default 3) of the live master mix.
                               //   Reuses the Recorder worklet; wakes/starts the graph like godsoniq().
Synth.amplerStatus()           // → {has, secs, slices}
Synth.amplerChop(n)            // → bool. Slice the capture into n equal slices (8 or 16).
Synth.amplerPlay(idx, vel)     // audition slice idx (vel default 0.9)
Synth.amplerReverse(idx, on)   // toggle (on omitted) or set; → new boolean state
Synth.amplerAssign(voice, idx) // voice ∈ kick|snare|clap|hat|ohat — that drum now PLAYS this slice
Synth.amplerClear(voice)       // back to the synth recipe
Synth.amplerMap()              // → {voice: {slice, reverse}} — session-only, NOT persisted in v1

Part D — Agent D: 🌾 AMPLER — "there is ample, for god is abundant"

The SP-404 move, Godstrument-style: GODSONIQ already captures ~3s of the live master mix (which can itself be a YouTube tab via the tab feed) — AMPLER chops that capture into slices and seats them onto the existing drum voices, so the beat panel's lanes suddenly play the world instead of synthesis. Sample anything playing on the machine, chop it, and it's a kit.

D.1 Engine (inside the Synth IIFE):

  • Capture without hijacking GODSONIQ. The Recorder worklet's port.onmessage currently always lands in sampleBuf/sampleMode (the keys-replay path). Add a routing flag (e.g. recTarget = "godsoniq" | "ampler") set by whoever armed last; AMPLER captures land in a separate amplerBuf and must NOT set sampleMode or touch sampleBuf. GODSONIQ's user-visible behavior stays byte-identical — regression-test it (arm GODSONIQ, keys replay the capture; arm AMPLER, keys do NOT change).
  • Slices = equal divisions of amplerBuf. Reverse = per-slice reversed copy, computed lazily on first toggle and cached.
  • Assigned playback: in drum(), after clamping vel, check the assignment map first: if the voice has a slice, play the (possibly reversed) buffer segment through that voice's existing N.drums[voice].g → pan → drumBus chain (so kit level/pan knobs and patch persistence of those knobs keep working), gain scaled vel*vel, playbackRate 1.0 (no pitching in v1). THEN:
    • a slice-assigned kick still runs the duck block (the duck is a property of the kick hit, not of the synth recipe — factor it so both paths share it);
    • a slice-assigned ohat still registers its envelope gain as ohatEnv, and hat/ohat (synth OR slice) still calls choke() — the TR rule is source-agnostic.
  • Export the eight ampler* methods on the Synth return object, exactly per the contract.

D.2 UI (D's own new block):

  • ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler()) directly after the GODSONIQ entry.
  • #ampler panel: fixed, RIGHT side of the screen (the bottom is the beat/arranger drawers' turf), styled with the same palette/idioms as #beat. Contents: title · ⏺ capture button with a small length select (1.5 / 3 / 6 s) · a waveform canvas of the capture (simple min/max column render) · chop select (8 / 16) · the slice grid — tap a slice to audition, a per-slice toggles reverse (badge it visibly) — · an assignment strip showing the live map per voice (e.g. kick ← s3ʳ) with per-voice assign (pick a slice) and ✕ clear. Exact interaction pattern is your call — keep it house-style, no drag-and-drop needed.
  • Empty states matter: no capture yet → the ⏺ button and a one-line hint; captured but unchopped → waveform + chop.

D.3 Verify (§0.7 regression mandatory; temporary hooks allowed, removed + grep-verified): live capture of the sounding world → chop 16 → audition (measure RMS per slice) → reverse a slice (verify sample-order inversion) → assign to snare → the snare lane plays the slice through level/pan (knobs still act) → assigned kick still ducks (measure duckG dip) → slice-ohat still choked by hat → GODSONIQ unbroken (its capture still lands on the keys) → tab feed unbroken → zero console errors.

Part E — Agent E: the performance layer

Jonwayne taps a live hi-hat pattern into a quiet pocket — that's this lane: play the kit from the keyboard, record the taps into the grid, and let the beat leave the building as MIDI.

E.1 Finger drumming. While the beat panel is open, number keys 15 fire kick/snare/clap/hat/ohat (vel 0.9; with Shift held, 0.5 — the ghost note). FIRST verify 15 are genuinely free in the keydown handler and anywhere else keys are consumed (mind the INPUT-focus guard; if they collide with something, choose replacements and flag it in your report). Extract the guarded fire logic (Synth.drum + midiPluck(9, gm, …)) from seqTick's drum branch into one shared app-scope helper — e.g. fireDrum(name, vel, durMs) — used by both seqTick and the keys, so the two paths can't drift. (seqTick is your territory; this refactor is in-bounds. Behavior must stay identical — same vel deviation, same MIDI.)

E.2 Live record. A ⏺ rec toggle in the beat panel header (state on beat.rec; red when armed, .midibtn styling). While armed, each finger-drum hit quantizes to the NEAREST 16th — round seqPhase (fraction > .5 → the upcoming step, else the current one), modulo 16 — and writes that lane's steps[n] = 1, vel[n] = the hit velocity, then repaints via the existing beatRefresh. Recording works with the transport playing (overdub into the loop) and also silent (step-programming by touch). Panel hint text: keys 15 play the kit · ⏺ rec taps them into the grid.

E.3 Drums in the MIDI export. Study exportMid() and add a channel-9 drum track to the SMF: for each bar of the arrangement's length, every lit drum step becomes a GM note (36/38/39/42/46), one 16th long, velocity from vel[]. Ratchet sub-hits may be omitted in v1 — say so in your report. Verify by parsing the generated bytes (locate the 0x99 events and check their tick positions), not by eyeballing.

E.4 Verify (§0.7 regression mandatory): keydown logic (note: synthetic keys don't route to hidden tabs — verify via direct handler-function calls plus one real foreground keypress if you can, and document the env caveat honestly, as Agent C did) · record quantization at fraction boundaries (test both sides of .5 by driving seqPhase) · the shared fireDrum helper produces identical seqTick behavior (chance/ratchet/vel-deviation unchanged) · exportMid byte-verified · zero console errors.

Reports

Per §0.8, one each. Merge order for Stage 4 will be D then E (or either — territories are disjoint; the reviewer trial-merges as before). Nothing touches main, nothing deploys.


Stage 4 — Agent F: final integration, the grimoire chapter, and QA

You are Agent F (Opus). All build lanes are done and approved: godrum/integrate @ b0fad08 (Stages 12.1), godrum/d-ampler @ d2ddec1 (🌾 AMPLER), godrum/e-performance @ bd3340d (performance layer). You close the feature: merge, cross-feature QA, documentation, deploy prep. Read Part 0 first — §0.5 hard rules and §0.8 report format still bind, with ONE amendment: in this stage you MUST regenerate and commit viz/manual.html (the earlier "never commit manual.html" rule existed to keep mid-feature noise out; Stage 4 is exactly when it updates). Deploying itself remains forbidden — that happens after the human review.

F.1 Merge (one known conflict, resolution pre-verified)

cd /Users/m3ultra/Documents/godstrument
git worktree add ../godstrument-F godrum/integrate
cd ../godstrument-F
git merge godrum/d-ampler --no-edit        # clean
git merge godrum/e-performance --no-edit   # CONFLICTS — expected, see below

The E merge conflicts in exactly one region: both D and E inserted a block at the seam between openBeat's closing brace and seqTick (D: the AMPLER UI block; E: the fireDrum helper). The reviewer has already trial-merged and verified the resolution: keep BOTH sides — AMPLER block first, then fireDrum — delete only the conflict markers. Nothing else conflicts. After resolving: extract the <script> body, node --check, commit the merge. If you find any OTHER conflict, stop and flag it in your report instead of improvising.

F.2 Cross-feature QA (the interactions no single lane could test)

Static-serve and drive the real page (temporary hooks allowed, removed + grep-verified). The novel surface is where D's and E's features meet each other and the older stages:

  1. Slice + finger drumming: assign an AMPLER slice to the kick → press key 1 → the slice plays (E's fireDrumSynth.drum → D's slice branch). With ⏺ rec armed, the tap records into the grid and playback then fires the slice.
  2. Slice + sequencer + duck/choke: seeded beat playing with a slice-kick → duck still dips (measure duckG); slice-ohat still choked by the closed hat.
  3. Slice + kit knobs: a slice-assigned voice's level/pan knobs still act; duck knob still governs the slice-kick's dip.
  4. exportMid with slices assigned: the ch-9 export is unchanged by assignments (GM notes, not audio) — confirm no throw and byte-sanity (0x99 events present).
  5. Panels: #ampler (right side) coexists with BOTH bottom drawers; beat↔arranger mutual exclusion unchanged; ♪ T O B regression; AMPLER open + beat open simultaneously works.
  6. GODSONIQ + AMPLER together: GODSONIQ capture then AMPLER capture then GODSONIQ again — keys-replay and amplerBuf stay independent throughout.
  7. Persistence: patch save/load with slices assigned — kit + drum lanes round-trip; AMPLER map is session-only (expected: assignments do NOT survive a reload — confirm it degrades to synth, no throw).
  8. Moods/zero: mood change mid-beat with a slice-kick (beat survives, slice keeps playing); zero-mode enter/exit with a drawn beat (keep/weave card appears, beat survives "keep").
  9. Full console-error sweep across all of the above: zero errors.

F.3 The grimoire chapter (canon) + manual

Write the GODRUM chapter into the grimoire prose in viz/index.html (the #grimoire content region, ~lines 5381200 — find the groove-grids/tracks entries and add alongside them, matching the house voice: poetic, precise, second person, no marketing). Cover, briefly but completely:

  • 🥁 the beat — press B (or the 🥁 button): five drum lanes on the godtime clock; click to draw; ▶ starts them; velocity/chance/ratchet lanes; ⬢ euclid; the feel shared with tracks; the kit drawer (tune/punch/decay/duck — "the kick parts the sea" — level/pan); beats save with your patch; moods never silence your beat; a beat built in zero counts as a build.
  • keys 15 & ⏺ rec — finger-drum the kit (⇧ for ghosts); armed rec taps into the grid, quantized to the nearest 16th.
  • 🌾 AMPLER — "there is ample, for god is abundant": right-click the sky → AMPLER; ⏺ capture the sounding world (the tab feed counts), ✂ chop into 8/16, tap to audition, ⇄ to reverse, seat slices onto the drum voices — the beat plays the world. Session-only: slices don't save with the patch (yet).
  • the beat leaves the building — drum lanes ride MIDI out on channel 10 (GM notes) and land in the .mid export.

Then python3 build_manual.py and commit the regenerated viz/manual.html together with the grimoire edit. GODSTRUMENT_MANUAL_SOURCE.md stays untouched (it drifts; viz/index.html is truth).

F.4 Deploy checklist (prepare, do NOT execute)

End your report with a checklist the humans will run after the by-ear pass: merge godrum/integratemain · rsync per CLAUDE.md (git-tracked minus dev docs — note GODRUM_BRIEF.md is already excluded by the .*_BRIEF\.md filter) · sudo -n systemctl restart godstrument · cold-start wait · smoke-test godstrument.pro (B key, ▶, a capture) · manual.html live. Do not run any of it.

F.5 Deliverables

Commits on godrum/integrate (merge commit(s) + one docs commit). §0.8 report + the F.2 matrix each ✓/✗ + anything you had to decide. Nothing to main, nothing deployed.