46 KiB
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
Synthengine, with a small frozen public API. - Agent B: drum lanes in the step sequencer + a
🥁 beatpanel 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 ~425–1200 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
SynthIIFE at lines 7794–8180. All voices sum intoN.preSat→ saturation → post → glitch → bitcrush worklet →N.out→ limiter → destination, with reverb/delay/chorus/Earth-Echo as parallel sends built inbuild()(7817+). - The groove clock:
frame()advancesseqPhase(line 4066); swing/humanize shift each 16th's onset (4068–4074); on each new stepseqTick()(line 2413) fires every enabled seq. Swing is applied at the clock level, so anything fired fromseqTick()inherits the groove for free. - Seqs live in the
seqsobject (line 1488), keyed by destination key, created byseqFor(key, isNote)(1556):{on, steps[16], vel[16], chance[16], rat[16], lock[16]}. - Patches serialize via
serializePatch()(2621) /loadPatch()(2634);seqsis deep-copied wholesale, andmigrateSeq()(2560) normalizes each on load — any 16-step seq round-trips, so drum patterns saved underseqspersist with zero extra work. Kit knob state does need explicit plumbing (Agent B). - UI: full-screen canvas + DOM overlay panels. Launcher buttons
🎛 tracks/🎹 OMNIare created at lines 2399–2411 (#arrbtn,#omnibtn— CSS in the<style>block, search for those ids). The right-click sky menu is built withctxItem(label, fn, kbd)around lines 6553–6631. The arranger panelopenArranger()(1729) with its "grooves" tab (1783+) is the house style for grid editors — study it before building anything. - Keyboard keys already taken (handler ~6340–6362):
P C O W Z T Escape+ arrows.Bis free — GODRUM claims it.
0.3 Territory rules — this is what makes parallel work safe
- Agent A edits ONLY inside the
SynthIIFE: betweenconst 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 inpatches/, orviz/manual.htmlchanges (it's generated). - Don't touch the grimoire prose (lines ~425–1200) or
GODSTRUMENT_MANUAL_SOURCE.md— docs are Stage 4, andviz/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) * 5000at 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)
- Branch name + worktree path + commit hash(es).
- What was built — bullets, with decisions you made and why (especially anywhere the brief left room).
- The diff —
git diff main --statplus the full diff (paste it; it will be reviewed line by line). - How you verified — exact commands/clicks, what you heard/saw, plus the §0.7 regression check results.
- Deviations & open questions — anything you changed vs. this brief, anything you're unsure about, anything you want the reviewer to look hard at.
- 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 7794–8180).
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 7796–7799), 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 topreSat(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 onN(e.g.N.drums.kick = {g, pan}) like the other nodes. Individual hits are one-shot node graphs (likepluck()/noiseHit()) that connect into their voice's gain node. - In
params()or in thedrumParamsetter (your choice — setter is simpler and cheaper), keep node values in sync withKITviasetTargetAtTimewith 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
fandf*1.5,f = 140 + tune*120Hz, 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 (8116–8122): 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 (8175–8179) 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 (7794–8180).
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 (1488–1565), seqTick() (2413–2443), the launcher buttons (2399–2411), ctxItem usage (6553–6631), 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 toseqFor()(1556): identical shape butstepsfill 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 doesconst n = dests.get(k); if (n && n.isNote) {...}— drum keys aren't indests, 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 (2430–2432). 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): declarebeatRefresh, call it at the end ofseqTick().
B.2 The 🥁 beat panel
A new DOM panel, id #beat, opened by:
- a launcher button
🥁 beat(#beatbtn), sibling of#arrbtn/#omnibtn(2399–2411) with matching CSS; - keyboard
B(add beside theThandler ~6361 —Bis 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):
- Header:
🥁 beat· master ▶/■ toggle (flipsonfor all fivedrum.*seqs at once) · BPM readout bound togodtime.bpm(editable number or drag, clamp 40–180, respectgodtime.locked) · close ✕. - Groove row: the four
GROOVE_PRESETSas one-click buttons (mirror 1845–1848) + swing / humanize / amount sliders bound to the existing globalgrooveobject. This is the same shared groove the arranger edits — that's correct, not a bug; label it "the feel (shared with tracks)". - The grid: five lanes (kick / snare / clap / hat / ohat) × 16 steps. Click toggles a step. Playhead column follows
seqCurStepvia yourbeatRefresh. 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'son), and a ⬢ Euclid button (prompt for pulses/rotate or reuse the grooves-tab euclid row idiom; calleuclidFill(sq.steps, pulses, 16, rot, false)). - 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.
- Kit drawer (collapsible row): for each voice, render knobs/sliders from
Synth.drumParamList(name)(guard its absence: render nothing pre-merge), read/write viaSynth.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;migrateSeqat 2560 passes any 16-step seq through — verify once by eye, note it in your report). - Kit: in
serializePatch()(2621) addkit: (Synth.drumKit ? Synth.drumKit() : undefined),and inloadPatch()(2634) addif (p.kit && Synth.drumKitLoad) Synth.drumKitLoad(p.kit);plus a panel rebuild if#beatis open (mirror how loadPatch reopens the arranger at 2653). - Extend
gsGrooveSelfCheck()(2569) with one drum check:migrateSeqround-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,
Bkey, 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'sgs_prezero); drum lanes survive; - §0.7 regression: no console errors,
♪/T/Oall still work, the arranger's grooves tab still edits normally (you sharegrooveandseqswith it — don't break it).
Stage roadmap
| stage | agents | scope | status |
|---|---|---|---|
| 1 | A ∥ B | engine + panel, on separate branches | ✅ APPROVED |
| 2 | C | merge, R1–R7, audible verify, mix pass, kick-duck | ✅ APPROVED @ fa5aa43 |
| 2.1 | C | hotfix R8–R11 (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(commit28be1de, +132/−2) andgodrum/b-beat-panel(commit9435c78, +358/−0) both passed review. The reviewer has already trial-merged them: zero conflicts, merged file parses (node --checkon 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 (R1–R7)
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
duckparam to the kick'sDRUM_DEFSentry (order:tune, punch, decay, duck, level, pan; default 0.35). Because the panel renders fromdrumParamListand persistence flows throughdrumKit, the knob and its save/load come free — verify they do. - In
build(): createduckG(gain 1). Reroute padAmp and droneAmp through it (padAmp → duckG → preSat, same for drone) instead of straight topreSat. Leave the echo return alone for v1 (note it as a possible later extension). - In
fireKick(): ifKIT.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.06–0.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).
- First-sound wake: fresh load,
♪OFF → open 🥁 (B key) → press ▶ → the beat sounds. No console errors. - 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);▷ hearfires each voice. - Groove: mpc 8-4 audibly swings (or measure onset deltas); ratchet 4 = a roll; chance <1 drops hits probabilistically.
- Choke works in the live page (open hat rings; closed hat cuts it).
- 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. - Moods: R5 verified live (beat plays through mood changes; storm included).
- 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.
- Full §0.7 regression: zero console errors throughout;
♪TOBall 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 R1–R7 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 R8–R11
You are Agent C again, back in your existing worktree (
/Users/m3ultra/Documents/godstrument-C, branchgodrum/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 (R8–R11) must already be on
godrum/integratebefore you branch — confirm its commit is present (git log --oneline godrum/integrateshould show the 2.1 commit abovefa5aa43). 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 fromgodrum/integrate(or frommainif 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/integrateLine numbers have shifted since Stage 1 — the Synth IIFE now spans roughly 8150–8690. 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
SynthIIFE; (2) a NEW self-contained AMPLER UI block (its own functions, inserted directly AFTER theopenBeatfunction's closing brace); (3) ONEctxItemline placed directly after the GODSONIQ entry in the sky menu; (4) a NEW#amplerCSS block placed directly after the#beatbtnrules. D must NOT touchopenBeat/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#beatblock). - 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.onmessagecurrently always lands insampleBuf/sampleMode(the keys-replay path). Add a routing flag (e.g.recTarget = "godsoniq" | "ampler") set by whoever armed last; AMPLER captures land in a separateamplerBufand must NOT setsampleModeor touchsampleBuf. 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 clampingvel, check the assignment map first: if the voice has a slice, play the (possibly reversed) buffer segment through that voice's existingN.drums[voice].g → pan → drumBuschain (so kitlevel/panknobs and patch persistence of those knobs keep working), gain scaledvel*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 callschoke()— 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.#amplerpanel: 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 1–5 fire kick/snare/clap/hat/ohat (vel 0.9; with Shift held, 0.5 — the ghost note). FIRST verify 1–5 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 1–5 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 1–2.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 commitviz/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:
- Slice + finger drumming: assign an AMPLER slice to the kick → press key
1→ the slice plays (E'sfireDrum→Synth.drum→ D's slice branch). With ⏺ rec armed, the tap records into the grid and playback then fires the slice. - Slice + sequencer + duck/choke: seeded beat playing with a slice-kick → duck still dips (measure
duckG); slice-ohat still choked by the closed hat. - Slice + kit knobs: a slice-assigned voice's
level/panknobs still act;duckknob still governs the slice-kick's dip. - 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).
- Panels:
#ampler(right side) coexists with BOTH bottom drawers; beat↔arranger mutual exclusion unchanged;♪ T O Bregression; AMPLER open + beat open simultaneously works. - GODSONIQ + AMPLER together: GODSONIQ capture then AMPLER capture then GODSONIQ again — keys-replay and
amplerBufstay independent throughout. - 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).
- 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").
- 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 538–1200 — 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 1–5 & ⏺ 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/integrate → main · 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.