Compare commits
10 Commits
4e72592cd3
...
0fb6410be7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb6410be7 | |||
| 5314090e84 | |||
| 9bfa0eec30 | |||
| bd3340d4a2 | |||
| d2ddec1521 | |||
| b0fad08556 | |||
| fa5aa434d1 | |||
| 4990e9244f | |||
| 9435c78feb | |||
| 28be1de3b8 |
@ -1,6 +1,6 @@
|
||||
# Godstrument — Claude context
|
||||
|
||||
Music-instrument web app; hosted at godstrument.pro on the **botchat** VPS (`humanjing@100.71.119.27`, exposed via Cloudflare Tunnel — NOT dealgod, despite earlier notes). App dir `/home/humanjing/godstrument` (not a git checkout). Deploy = rsync git-tracked files only, **minus dev docs/scripts** (`--files-from=<(git ls-files | grep -vE '^(CLAUDE\.md|README\.md|ZERO_OMNI_BRIEF\.md|LIFESTRUMENT_BRIEF\.md|GODSTRUMENT_MANUAL_SOURCE\.md|godstrument\.txt|build_manual\.py|test_.*\.py)$')`, never `--delete`, never push `godstrument_users.db`/`auth_secret`/`patches/`) then `sudo -n systemctl restart godstrument`. The **user manual** ships as `viz/manual.html` (served at godstrument.pro/manual.html), generated from the in-app grimoire by `python3 build_manual.py` — re-run it after editing the grimoire. Working notes/planning docs stay OUT of the deploy. Origin is Gitea `ssh://git@100.71.119.27:222/monster/Godstrument.git`. See [[godstrument-pro-deploy]].
|
||||
Music-instrument web app; hosted at godstrument.pro on the **botchat** VPS (`humanjing@100.71.119.27`, exposed via Cloudflare Tunnel — NOT dealgod, despite earlier notes). App dir `/home/humanjing/godstrument` (not a git checkout). Deploy = rsync git-tracked files only, **minus dev docs/scripts** (`--files-from=<(git ls-files | grep -vE '^(CLAUDE\.md|README\.md|.*_BRIEF\.md|GODSTRUMENT_MANUAL_SOURCE\.md|godstrument\.txt|build_manual\.py|test_.*\.py)$')`, never `--delete`, never push `godstrument_users.db`/`auth_secret`/`patches/`) then `sudo -n systemctl restart godstrument`. The **user manual** ships as `viz/manual.html` (served at godstrument.pro/manual.html), generated from the in-app grimoire by `python3 build_manual.py` — re-run it after editing the grimoire. Working notes/planning docs stay OUT of the deploy. Origin is Gitea `ssh://git@100.71.119.27:222/monster/Godstrument.git`. See [[godstrument-pro-deploy]].
|
||||
|
||||
- **Canon gotcha:** the real spec prose lives in `viz/index.html` (grimoire text around line ~950); `GODSTRUMENT_MANUAL_SOURCE.md` claims to be exhaustive but has drifted before (Earth Echo / Schumann layer was missing). When they disagree, **viz/index.html is truth** — update the manual to match, not the other way.
|
||||
- Secrets (Cloudflare token etc.): read from `.env`/local files, never from chat. A CF token was pasted in chat once and rolled — don't repeat that.
|
||||
|
||||
462
GODRUM_BRIEF.md
Normal file
462
GODRUM_BRIEF.md
Normal file
@ -0,0 +1,462 @@
|
||||
# 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 ~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 `Synth` IIFE at **lines 7794–8180**. 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 (4068–4074); 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 2399–2411 (`#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 6553–6631. 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 ~6340–6362): `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:
|
||||
|
||||
```sh
|
||||
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 ~425–1200) 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.
|
||||
|
||||
```js
|
||||
// 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):
|
||||
|
||||
```sh
|
||||
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 diff** — `git 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 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 `let`s 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 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 (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:
|
||||
|
||||
```js
|
||||
_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 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:
|
||||
|
||||
```js
|
||||
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): 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` (2399–2411) 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 40–180, respect `godtime.locked`) · close ✕.
|
||||
2. **Groove row:** the four `GROOVE_PRESETS` as one-click buttons (mirror 1845–1848) + 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, 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` (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
|
||||
|
||||
```sh
|
||||
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 `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.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).
|
||||
|
||||
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 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`, 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):
|
||||
```js
|
||||
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/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):
|
||||
>
|
||||
> ```sh
|
||||
> 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 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 `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)
|
||||
|
||||
```js
|
||||
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 **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 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)
|
||||
|
||||
```sh
|
||||
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 `fireDrum` → `Synth.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 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.
|
||||
809
viz/index.html
809
viz/index.html
@ -235,6 +235,105 @@
|
||||
.seqcell.on { background: #78a0ff; box-shadow: 0 0 6px rgba(120,160,255,0.6); }
|
||||
.seqcell.cur { outline: 1px solid rgba(255,238,150,0.85); }
|
||||
|
||||
/* 🥁 GODRUM — the beat panel: a sibling of the arranger, sharing its groove & clock */
|
||||
#beat {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 54px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 46vh;
|
||||
}
|
||||
#beat.open { display: flex; flex-direction: column; }
|
||||
#beat .agrip { height: 8px; margin: -8px -10px 2px; cursor: ns-resize; border-radius: 12px 12px 0 0; }
|
||||
#beat .agrip:hover { background: rgba(150,180,230,0.25); }
|
||||
#beat .atop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
#beat .abody { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
#beat .arow { display: flex; align-items: center; gap: 3px; margin: 2px 0; }
|
||||
#beat .albl { color: rgba(150,170,200,0.65); font-size: 10px; flex: 1; }
|
||||
#beat .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#beat .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 3px 8px; font-size: 11px; }
|
||||
#beat .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 0 0 auto; }
|
||||
#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; }
|
||||
#beat .gctl .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 48px; text-align: right; }
|
||||
#beat .gctl input[type=range] { width: 74px; accent-color: #7fa0e6; height: 3px; }
|
||||
#beat .gctl .gval { color: #aebbdc; font-size: 10px; min-width: 30px; }
|
||||
#beat .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||||
#beat .gpreset { font-size: 10px; padding: 2px 7px; opacity: 0.85; }
|
||||
#beat .eunum { width: 52px; background: rgba(20,26,40,0.8); color: #cfe0f5;
|
||||
border: 1px solid rgba(120,150,220,0.3); border-radius: 4px; font-size: 11px; padding: 1px 4px; }
|
||||
#beat .emrow { gap: 6px; align-items: center; margin-top: 6px; }
|
||||
#beat .blane { display: flex; align-items: center; gap: 4px; margin: 3px 0; }
|
||||
#beat .blead { display: flex; align-items: center; gap: 4px; width: 104px; min-width: 104px; }
|
||||
#beat .bname { flex: 1; color: #aebfda; font-size: 10px; text-align: right; overflow: hidden;
|
||||
white-space: nowrap; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
#beat .bsteps { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px; }
|
||||
#beat .seqcell { width: 26px; height: 20px; border-radius: 4px; }
|
||||
#beat .exprlane { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px;
|
||||
height: 46px; align-items: end; margin: 4px 0 8px 108px; }
|
||||
#beat .exprcell { width: 26px; height: 46px; display: flex; align-items: flex-end;
|
||||
background: rgba(30,36,54,0.6); border-radius: 4px; cursor: ns-resize; }
|
||||
#beat .exprcell.bar { background: rgba(40,48,72,0.7); }
|
||||
#beat .exprbar { width: 100%; background: rgba(120,150,230,0.35); border-radius: 4px; min-height: 2px; }
|
||||
#beat .exprbar.lit { background: rgba(140,175,255,0.85); }
|
||||
#beat .bkit { gap: 8px; align-items: flex-start; }
|
||||
#beat .bvoice { display: inline-flex; flex-direction: column; gap: 2px; padding: 4px 6px;
|
||||
background: rgba(20,26,40,0.5); border-radius: 6px; }
|
||||
#beat .bvn { color: #9fb6dc; font-size: 10px; text-align: center; margin-bottom: 1px;
|
||||
text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
#beatbtn {
|
||||
position: fixed; right: 14px; bottom: 128px; z-index: 12; padding: 6px 12px;
|
||||
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
|
||||
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
|
||||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||||
}
|
||||
#beatbtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
|
||||
#beatbtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
|
||||
#beatbtn.hidden { display: none; }
|
||||
|
||||
/* 🌾 AMPLER — right-side sample-slicing panel (the bottom is the drawers' turf) */
|
||||
#ampler {
|
||||
position: fixed; right: 12px; top: 64px; width: 300px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 82vh; overflow-y: auto;
|
||||
}
|
||||
#ampler.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#ampler .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#ampler .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#ampler .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#ampler .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#ampler .albl { color: rgba(150,170,200,0.7); font-size: 10px; }
|
||||
#ampler .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#ampler .midibtn.on { background: rgba(230,120,120,0.22); color: #ffc9c9; border-color: rgba(230,130,130,0.55); }
|
||||
#ampler .midibtn:disabled { opacity: 0.6; cursor: default; }
|
||||
#ampler .bmini { padding: 2px 7px; }
|
||||
#ampler .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||||
#ampler canvas { width: 100%; height: 54px; background: rgba(20,26,40,0.7); border-radius: 6px; display: block; }
|
||||
#ampler .sgrid { display: grid; gap: 3px; }
|
||||
#ampler .scell { position: relative; height: 30px; border-radius: 4px; background: rgba(40,52,74,0.7);
|
||||
border: 1px solid rgba(120,150,200,0.25); cursor: pointer; font-size: 9px; color: #9fb6dc;
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
#ampler .scell:hover { border-color: rgba(150,180,230,0.6); }
|
||||
#ampler .scell.sel { border-color: rgba(150,200,255,0.85); background: rgba(50,66,96,0.85); }
|
||||
#ampler .scell.rev { background: rgba(120,150,230,0.32); }
|
||||
#ampler .scell .rv { position: absolute; top: 0; right: 2px; font-size: 9px; color: #bcd; }
|
||||
#ampler .scell .rv:hover { color: #eaf2ff; }
|
||||
#ampler .amap { display: flex; flex-direction: column; gap: 3px; }
|
||||
#ampler .maprow { display: flex; align-items: center; gap: 5px; }
|
||||
#ampler .mvn { width: 40px; color: #aebfda; font-size: 10px; }
|
||||
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||||
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* right-click context menu — every setting, on everything */
|
||||
#ctxmenu {
|
||||
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
|
||||
@ -1038,6 +1137,10 @@
|
||||
<li><b>The Groove — one feel for the whole pattern.</b> The grooves tab carries Ableton's Groove Pool, reduced to the 16-step grid: <b>swing</b> lays the off-beats back, <b>humanize</b> is a touch of random timing so nothing is machine-stiff, <b>velocity</b> lets each hit breathe in loudness, and an <b>amount</b> master scales the lot — with one-click presets (<i>straight, swing 16, MPC 8-4, drunk</i>). Under the roll sit three expression lanes: <b>velocity</b> (how hard each step hits), <b>chance</b> (how likely each step fires at all — drop a step to 40% and it plays four times in ten, so the loop varies itself), and <b>ratchet</b> (how many times a step re-fires, 1–4 — an instant roll or 32nd-note glitch from a single step, and if that step is a Play-One stack each sub-hit re-rolls the note). The feel and the per-step shaping <b>save with your patch</b>. And the <b>texture voices</b> (the filter, the glitch, the reverb, the drive) get their own lane: a <b>⬗ p-lock</b>, the Pocket-Operator move — pin that voice to a blocky value on any step, so the filter jumps to a different cutoff on every hit, disjointed and alive; drag a step below the lane to hand it back to the world. And any lane takes a <b>⬢ Euclidean fill</b> — say how many <i>pulses</i> to spread across the steps and they distribute themselves as evenly as possible (E(3,8) is the tresillo, E(5,8) the cinquillo, E(4,16) four-on-the-floor), with <i>steps</i> and <i>rotate</i> to taste: the oldest rhythms of the world fall straight out of the arithmetic.</li>
|
||||
<li><b>♪ One scale for the whole planet, and ⬛ Play One for the glitch.</b> Set a <b>root and scale</b> at the top of the grooves tab and every melody you draw is in that key — the piano roll is only ever the scale's own notes, so nothing lands wrong; change the scale and your patterns <i>re-harmonise</i> in place. And a column isn't limited to one note: <b>stack several</b> and it turns blue — a <b>Play One</b> group, from which the sequencer picks a single note at random each time it fires. Stack a handful of in-key notes under a fast Euclidean fill and the line glitches itself into something new on every pass, always in tune. (Adapted from Ableton's <i>Group Notes → Play One</i>.)</li>
|
||||
<li><b>⤳ Follow actions — the song composes itself.</b> Give any clip (A/B/C) a <b>follow action</b> in the grooves tab — <i>play again, next, any, any other, stop</i> — with a <b>chance</b>, and once the arrangement is playing that clip can hand off to another at the bar line instead of just repeating. Set two clips to "any other" and they trade the lane back and forth; sprinkle a low-chance divert and the piece drifts. As Ableton's manual puts it, "structures that repeat but can also be surprising… never exactly repeat." A ⤳ marks any clip that can divert. Nothing changes for arrangements without follow actions — they loop exactly as written.</li>
|
||||
<li><b>🥁 The beat — five drums on the godtime clock.</b> Press <kbd>B</kbd> (or the <b>🥁 button</b>) and the beat drawer opens: five lanes — <i>kick, snare, clap, closed hat, open hat</i> — synthesised from oscillators and noise like every other voice, so they ride the world's saturation, crush and reverb too. Click a step to draw; <b>▶</b> starts all five at once. They live on the same godtime clock as the grooves, and the feel — <b>swing, humanize, velocity, amount</b> — is <i>the very same</i> the tracks use (shared, not copied). The same three expression lanes shape each step (<b>velocity</b>, <b>chance</b>, <b>ratchet</b>), and <b>⬢ euclid</b> spreads N pulses across the sixteen. Open the <b>🎛 kit</b> drawer to voice each drum: <i>tune, punch, decay, level, pan</i> — and on the kick, <b>duck</b>: every kick <i>parts the sea</i>, dipping the pads and drone out from under itself so the thump lands in its own air. The beat <b>saves with your patch</b>; a <b>mood never silences it</b> — the world's hands change, but the beat belongs to you; and a beat drawn in <b>zero</b> counts as a build, so leaving zero offers to keep it.</li>
|
||||
<li><b>Keys <kbd>1</kbd>–<kbd>5</kbd> & ⏺ rec — finger-drum the kit.</b> With the beat open the number row <i>is</i> the kit — <kbd>1</kbd> kick, <kbd>2</kbd> snare, <kbd>3</kbd> clap, <kbd>4</kbd> hat, <kbd>5</kbd> open hat — played by hand, live, straight into the world's FX. Hold <b>⇧</b> for the ghost note, softer. Arm <b>⏺ rec</b> and every tap writes itself into the grid, <b>quantized to the nearest sixteenth</b> — tap a hi-hat pattern into a quiet pocket and it's a lane — so you play the beat in rather than clicking it out.</li>
|
||||
<li><b>🌾 AMPLER — there is ample, for god is abundant.</b> The SP-404 move, made of the planet. Right-click the sky → <b>🌾 AMPLER</b>: <b>⏺ capture</b> a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab <i>is</i> the world, that YouTube loop counts), then <b>✂ chop</b> it into 8 or 16 slices. Tap a slice to audition it, <b>⇄</b> to play it backwards, then <b>seat a slice onto a drum voice</b> — and that lane stops synthesising and starts <i>playing the world</i>. The slice rides the voice's own <i>level</i> and <i>pan</i> and, on the kick, its <b>duck</b>; a slice on the open hat still chokes under a closed one — the machine's rules don't care whether a hit is oscillator or sample. Sample anything playing on the machine, chop it, and it's a kit. Session-only for now: the slices don't travel with your patch (yet).</li>
|
||||
<li><b>The beat leaves the building.</b> The drum lanes ride <b>MIDI out on channel 10</b> — General MIDI percussion, <i>kick 36, snare 38, clap 39, closed hat 42, open hat 46</i> — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in <b>export .mid</b>: draw a beat, hit export, and the Standard MIDI File carries a channel-10 drum track (looped across the arrangement's bars) alongside your lead and bass — drag it into Ableton and the planet's rhythm is already programmed.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
@ -1563,6 +1666,24 @@
|
||||
lock: new Array(16).fill(-1), // per-step parameter lock (-1 = unlocked)
|
||||
});
|
||||
}
|
||||
// GODRUM drum lanes — same seq shape as seqFor, but steps are 0/1 gates that fill
|
||||
// EMPTY (a fresh drum lane makes no sound; seqFor's all-1s default would be an
|
||||
// instant four-on-everything). Fired straight from seqTick, so swing/humanize ride free.
|
||||
const DRUM_VOICES = ["kick", "snare", "clap", "hat", "ohat"];
|
||||
// the canonical first-open seed — ONE source of truth for seedBeat() and zeroHasBuild() (they must not drift)
|
||||
const DRUM_SEED = { kick: [0, 7, 10], snare: [4, 12], clap: [],
|
||||
hat: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], ohat: [14] };
|
||||
const DRUM_GM = { kick: 36, snare: 38, clap: 39, hat: 42, ohat: 46 }; // General MIDI drum notes (channel 9)
|
||||
function drumSeqFor(key) {
|
||||
return seqs[key] || (seqs[key] = {
|
||||
on: false,
|
||||
steps: new Array(16).fill(0), // 0/1 gate — empty until you draw a beat
|
||||
vel: new Array(16).fill(0.8),
|
||||
chance: new Array(16).fill(1),
|
||||
rat: new Array(16).fill(1),
|
||||
lock: new Array(16).fill(-1), // unused by drums, kept for seq-shape parity (migrateSeq)
|
||||
});
|
||||
}
|
||||
// ---- the arranger — tracks & clips for the casuals -----------------------
|
||||
// Ableton-shaped, not Ableton-sized: tracks are the voices, a clip is a bar
|
||||
// of groove (A/B/C patterns on the existing grids), "live" means the world
|
||||
@ -1690,8 +1811,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), []);
|
||||
@ -1719,7 +1865,14 @@
|
||||
const arrEl = document.createElement("div");
|
||||
arrEl.id = "arranger";
|
||||
document.body.appendChild(arrEl);
|
||||
|
||||
// the 🥁 beat panel — GODRUM's drum lanes, a sibling of the arranger
|
||||
const beatEl = document.createElement("div");
|
||||
beatEl.id = "beat";
|
||||
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, rec: false };
|
||||
let arrTab = "clips", arrSelKey = null, arrSelLetter = "A";
|
||||
function closeArranger() {
|
||||
arr.open = false; arrEl.classList.remove("open");
|
||||
@ -1728,6 +1881,7 @@
|
||||
}
|
||||
function openArranger() {
|
||||
arr.open = true;
|
||||
if (beat.open) closeBeat(); // the two bottom drawers share the screen — only one at a time
|
||||
arrBtn.classList.add("hidden");
|
||||
arrEl.innerHTML = "";
|
||||
|
||||
@ -2377,12 +2531,12 @@
|
||||
try { localStorage.setItem("gs_mood", name); } catch (e) {} // your vibe survives a reload
|
||||
for (const k of MOOD_KEYS) { const t2 = tw(k); t2.mute = 0; t2.gain = 1; t2.offset = 0; }
|
||||
if (m.storm) {
|
||||
for (const k in seqs) seqs[k].on = false;
|
||||
for (const k in seqs) { if (k.slice(0, 5) === "drum.") continue; seqs[k].on = false; } // the beat belongs to the player — a mood never silences it
|
||||
godtime.locked = false;
|
||||
} else {
|
||||
for (const k in (m.mutes || {})) tw(k).mute = m.mutes[k];
|
||||
for (const k in (m.gains || {})) tw(k).gain = m.gains[k];
|
||||
if (m.clearGrooves) for (const k in seqs) seqs[k].on = false;
|
||||
if (m.clearGrooves) for (const k in seqs) { if (k.slice(0, 5) === "drum.") continue; seqs[k].on = false; } // drum lanes survive a mood change
|
||||
for (const k in (m.grooves || {})) {
|
||||
const n = dests.get(k);
|
||||
const sq = seqFor(k, n ? n.isNote : /note/.test(k));
|
||||
@ -2410,10 +2564,385 @@
|
||||
omniBtn.onclick = () => OMNI.toggle();
|
||||
document.body.appendChild(omniBtn);
|
||||
|
||||
const beatBtn = document.createElement("div"); // 🥁 beat — the GODRUM panel (B), sibling of 🎹 OMNI
|
||||
beatBtn.id = "beatbtn";
|
||||
beatBtn.textContent = "🥁 beat";
|
||||
beatBtn.title = "GODRUM — make a beat: drum lanes, groove & kit (B)";
|
||||
beatBtn.onclick = () => { beat.open ? closeBeat() : openBeat(); };
|
||||
document.body.appendChild(beatBtn);
|
||||
|
||||
// ---- 🥁 GODRUM — the beat panel ------------------------------------------
|
||||
// Drum lanes live in `seqs` under "drum.*" keys and fire from seqTick, so they
|
||||
// inherit swing / humanize / chance / ratchet for free. The panel is modelled on
|
||||
// the arranger's grooves tab; the feel (`groove`) is the SAME shared object the
|
||||
// arranger edits — one groove for the whole machine.
|
||||
function seedBeat() { // first open: a pattern so ▶ makes music at once
|
||||
if (DRUM_VOICES.some(v => seqs["drum." + v])) return;
|
||||
for (const v of DRUM_VOICES) { const sq = drumSeqFor("drum." + v); for (const i of DRUM_SEED[v]) sq.steps[i] = 1; }
|
||||
const H = seqs["drum.hat"]; for (let i = 0; i < 16; i++) H.vel[i] = (i % 2) ? 0.4 : 0.8; // seed hat dynamics (step gates come from DRUM_SEED — hat 0–13, open hat rings into the downbeat)
|
||||
// all lanes stay on:false — the user's first ▶ starts the beat
|
||||
}
|
||||
function closeBeat() {
|
||||
beat.open = false; beatEl.classList.remove("open");
|
||||
beatRefresh = null;
|
||||
beatBtn.classList.remove("hidden");
|
||||
}
|
||||
function openBeat() {
|
||||
beat.open = true;
|
||||
if (arr.open) closeArranger(); // the two bottom drawers share the screen — only one at a time
|
||||
beatBtn.classList.add("hidden");
|
||||
seedBeat();
|
||||
beatEl.innerHTML = "";
|
||||
|
||||
// grab-handle — drag to resize, remembered (mirrors the arranger)
|
||||
const grip = document.createElement("div"); grip.className = "agrip"; grip.title = "drag to resize";
|
||||
grip.addEventListener("mousedown", (e0) => {
|
||||
e0.preventDefault();
|
||||
const startY = e0.clientY, startH = beatEl.getBoundingClientRect().height;
|
||||
const move = (e2) => { beatEl.style.height = clamp(startH + (startY - e2.clientY), 140, window.innerHeight * 0.8) + "px"; };
|
||||
const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up);
|
||||
try { localStorage.setItem("gs_beath", parseInt(beatEl.style.height) || ""); } catch (e) {} };
|
||||
window.addEventListener("mousemove", move); window.addEventListener("mouseup", up);
|
||||
});
|
||||
beatEl.appendChild(grip);
|
||||
try { const h0 = parseInt(localStorage.getItem("gs_beath")); if (h0) beatEl.style.height = h0 + "px"; } catch (e) {}
|
||||
|
||||
// ---- header: title · master ▶/■ · bpm · close ----
|
||||
const top = document.createElement("div"); top.className = "atop";
|
||||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🥁 beat";
|
||||
const anyOn = () => DRUM_VOICES.some(v => seqs["drum." + v] && seqs["drum." + v].on);
|
||||
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));
|
||||
bpm.disabled = godtime.locked;
|
||||
bpm.title = godtime.locked ? "godtime is locked (🔒) — unlock it on the dial" : "beats per minute (40–180)";
|
||||
bpm.onchange = () => { if (!godtime.locked) godtime.bpm = clamp(Math.round(+bpm.value || godtime.bpm), 40, 180);
|
||||
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 = "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);
|
||||
|
||||
const body = document.createElement("div"); body.className = "abody";
|
||||
beatEl.appendChild(body);
|
||||
|
||||
// ---- the feel: GROOVE presets + swing / humanize / amount (the shared `groove`) ----
|
||||
const gr = document.createElement("div"); gr.className = "arow grow";
|
||||
const gLbl = document.createElement("span"); gLbl.className = "glbl"; gLbl.style.minWidth = "auto";
|
||||
gLbl.textContent = "the feel (shared with tracks)";
|
||||
gr.appendChild(gLbl);
|
||||
const presetBtns = [];
|
||||
for (const pn of Object.keys(GROOVE_PRESETS)) {
|
||||
const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn;
|
||||
pb.classList.toggle("on", groove.preset === pn);
|
||||
pb.onclick = () => { Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); };
|
||||
gr.appendChild(pb); presetBtns.push({ pb, pn });
|
||||
}
|
||||
const paintPresets = () => presetBtns.forEach(({ pb, pn }) => pb.classList.toggle("on", groove.preset === pn));
|
||||
const mkGS = (label, get, set, max) => {
|
||||
const w = document.createElement("span"); w.className = "gctl";
|
||||
const l = document.createElement("span"); l.className = "glbl"; l.textContent = label;
|
||||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||||
const vv = document.createElement("span"); vv.className = "gval";
|
||||
const show = () => vv.textContent = Math.round(get() / max * 100) + "%";
|
||||
sl.value = String(Math.round(get() / max * 1000)); show();
|
||||
sl.oninput = () => { set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match
|
||||
w.appendChild(l); w.appendChild(sl); w.appendChild(vv); return w;
|
||||
};
|
||||
gr.appendChild(mkGS("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1));
|
||||
gr.appendChild(mkGS("humanize", () => groove.humanize, v => { groove.humanize = v; groove.preset = "custom"; }, 1));
|
||||
gr.appendChild(mkGS("velocity", () => groove.velo, v => { groove.velo = v; groove.preset = "custom"; }, 1)); // the fourth feel dial — velocity deviation, which drum lanes obey
|
||||
gr.appendChild(mkGS("amount", () => groove.amount, v => { groove.amount = v; }, 1.3));
|
||||
body.appendChild(gr);
|
||||
|
||||
// ---- the grid: five lanes × 16 steps ----
|
||||
let paintGrid, paintExpr; // both defined below; paintGrid also drives paintExpr so lit-bars track the steps
|
||||
const euclidPrompt = (sq, v) => {
|
||||
const ans = prompt("⬢ euclid " + v + " — pulses across 16 steps (or pulses,rotate ):", "4");
|
||||
if (ans == null) return;
|
||||
const parts = String(ans).trim().split(/[\s,]+/).filter(x => x !== "").map(Number); // drop leading empties from " 4" / ",4"
|
||||
if (!parts.length || !Number.isFinite(parts[0])) return; // junk / empty confirm → no-op; an explicit "0" still clears
|
||||
const pulses = clamp(Math.round(parts[0]), 0, 16),
|
||||
rot = clamp(Math.round(Number.isFinite(parts[1]) ? parts[1] : 0), 0, 15);
|
||||
euclidFill(sq.steps, pulses, 16, rot, false);
|
||||
};
|
||||
const lanes = [];
|
||||
for (const v of DRUM_VOICES) {
|
||||
const sq = drumSeqFor("drum." + v);
|
||||
const lane = document.createElement("div"); lane.className = "blane";
|
||||
const lead = document.createElement("div"); lead.className = "blead";
|
||||
const name = document.createElement("span"); name.className = "bname"; name.textContent = v;
|
||||
const mute = document.createElement("button"); mute.className = "midibtn bmini";
|
||||
const paintMute = () => { const on = sq.on; if (mute._on === on) return;
|
||||
mute._on = on; mute.classList.toggle("on", on); mute.textContent = on ? "●" : "○"; };
|
||||
mute._on = null; paintMute();
|
||||
mute.title = "mute / unmute this lane";
|
||||
mute.onclick = () => { sq.on = !sq.on; paintMute(); paintPlay(); };
|
||||
const eu = document.createElement("button"); eu.className = "midibtn bmini"; eu.textContent = "⬢";
|
||||
eu.title = "euclid — spread N pulses evenly across the 16 steps";
|
||||
eu.onclick = () => { euclidPrompt(sq, v); paintGrid(); };
|
||||
lead.appendChild(name); lead.appendChild(mute); lead.appendChild(eu);
|
||||
lane.appendChild(lead);
|
||||
const steps = document.createElement("div"); steps.className = "bsteps";
|
||||
const cells = [];
|
||||
for (let stp = 0; stp < 16; stp++) {
|
||||
const cell = document.createElement("div"); cell.className = "seqcell";
|
||||
if (stp % 4 === 0) cell.classList.add("bar"); // beats 1 / 5 / 9 / 13
|
||||
cell.title = v + " · step " + (stp + 1);
|
||||
cell.onclick = () => { sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); };
|
||||
cells.push(cell); steps.appendChild(cell);
|
||||
}
|
||||
lane.appendChild(steps); body.appendChild(lane);
|
||||
lanes.push({ v, sq, cells, paintMute });
|
||||
}
|
||||
paintGrid = () => {
|
||||
const playing = anyOn();
|
||||
paintPlay();
|
||||
if (bpm.disabled !== godtime.locked) bpm.disabled = godtime.locked;
|
||||
const bv = String(Math.round(godtime.bpm));
|
||||
if (document.activeElement !== bpm && bpm.value !== bv) bpm.value = bv;
|
||||
for (const L of lanes) { L.paintMute();
|
||||
for (let s = 0; s < 16; s++) {
|
||||
L.cells[s].classList.toggle("on", !!L.sq.steps[s]);
|
||||
L.cells[s].classList.toggle("cur", playing && s === seqCurStep);
|
||||
}
|
||||
}
|
||||
if (paintExpr) paintExpr(); // keep the expression lane's lit-bars in step with the grid
|
||||
};
|
||||
paintGrid();
|
||||
beatRefresh = paintGrid;
|
||||
|
||||
play.onclick = () => { // master ▶ — flip all five lanes at once
|
||||
const turnOn = !anyOn();
|
||||
lanes.forEach(L => { L.sq.on = turnOn; L.paintMute(); });
|
||||
paintGrid();
|
||||
};
|
||||
|
||||
// ---- expression: velocity / chance / ratchet for one selected lane (arranger-style drag) ----
|
||||
const em = document.createElement("div"); em.className = "arow emrow";
|
||||
const emL = document.createElement("span"); emL.className = "glbl"; emL.style.minWidth = "auto"; emL.textContent = "shape";
|
||||
em.appendChild(emL);
|
||||
const laneSel = document.createElement("select"); laneSel.className = "asel";
|
||||
DRUM_VOICES.forEach(v => { const o = document.createElement("option"); o.value = v; o.textContent = v;
|
||||
if (v === beat.sel) o.selected = true; laneSel.appendChild(o); });
|
||||
laneSel.onchange = () => { beat.sel = laneSel.value; openBeat(); };
|
||||
em.appendChild(laneSel);
|
||||
for (const mode of ["velocity", "chance", "ratchet"]) {
|
||||
const b = document.createElement("button"); b.className = "midibtn"; b.textContent = mode;
|
||||
b.classList.toggle("on", beat.exprMode === mode);
|
||||
b.onclick = () => { beat.exprMode = mode; openBeat(); };
|
||||
em.appendChild(b);
|
||||
}
|
||||
const eh = document.createElement("span"); eh.className = "albl";
|
||||
eh.textContent = beat.exprMode === "chance" ? "drag a bar — how likely each " + beat.sel + " step fires"
|
||||
: beat.exprMode === "ratchet" ? "drag a bar — how many times each " + beat.sel + " step re-fires (1–4): rolls"
|
||||
: "drag a bar — how hard each " + beat.sel + " step hits";
|
||||
em.appendChild(eh);
|
||||
body.appendChild(em);
|
||||
|
||||
const selSq = drumSeqFor("drum." + beat.sel);
|
||||
const isRat = beat.exprMode === "ratchet";
|
||||
const arrv = isRat ? selSq.rat : (beat.exprMode === "chance" ? selSq.chance : selSq.vel);
|
||||
const exlane = document.createElement("div"); exlane.className = "exprlane";
|
||||
const bars = [];
|
||||
paintExpr = () => { for (const b of bars) {
|
||||
const h = isRat ? (clamp(arrv[b.stp], 1, 4) - 1) / 3 : clamp(arrv[b.stp], 0, 1);
|
||||
b.fill.style.height = Math.round(h * 100) + "%";
|
||||
b.fill.classList.toggle("lit", !!selSq.steps[b.stp]);
|
||||
} };
|
||||
for (let stp = 0; stp < 16; stp++) {
|
||||
const cell = document.createElement("div"); cell.className = "exprcell";
|
||||
if (stp % 4 === 0) cell.classList.add("bar");
|
||||
const fill = document.createElement("div"); fill.className = "exprbar"; cell.appendChild(fill);
|
||||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||||
const frac = clamp(1 - (cy - r.top) / r.height, 0, 1);
|
||||
arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||||
const mv = (e2) => setY(e2.clientY);
|
||||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||||
window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); });
|
||||
bars.push({ fill, stp }); exlane.appendChild(cell);
|
||||
}
|
||||
paintExpr();
|
||||
body.appendChild(exlane);
|
||||
|
||||
// ---- kit drawer: knobs rendered from Synth.drumParamList (guarded — nothing pre-merge) ----
|
||||
const kitRow = document.createElement("div"); kitRow.className = "arow bkit";
|
||||
const kitTgl = document.createElement("button"); kitTgl.className = "midibtn";
|
||||
kitTgl.textContent = "🎛 kit " + (beat.kitOpen ? "▾" : "▸");
|
||||
kitTgl.onclick = () => { beat.kitOpen = !beat.kitOpen; openBeat(); };
|
||||
kitRow.appendChild(kitTgl);
|
||||
if (beat.kitOpen) {
|
||||
if (Synth.drumParamList && Synth.drumParam) {
|
||||
for (const v of DRUM_VOICES) {
|
||||
const box = document.createElement("div"); box.className = "bvoice";
|
||||
const vn = document.createElement("div"); vn.className = "bvn"; vn.textContent = v; box.appendChild(vn);
|
||||
for (const p of (Synth.drumParamList(v) || [])) {
|
||||
const w = document.createElement("span"); w.className = "gctl";
|
||||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "44px"; l.textContent = p.label;
|
||||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||||
sl.value = String(Math.round(clamp(Synth.drumParam(v, p.key), 0, 1) * 1000));
|
||||
sl.oninput = () => Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1));
|
||||
w.appendChild(l); w.appendChild(sl); box.appendChild(w);
|
||||
}
|
||||
const au = document.createElement("button"); au.className = "midibtn bmini"; au.textContent = "▷ hear";
|
||||
au.onclick = () => { if (Synth.drum) Synth.drum(v, 0.9); };
|
||||
box.appendChild(au);
|
||||
kitRow.appendChild(box);
|
||||
}
|
||||
} else {
|
||||
const note = document.createElement("span"); note.className = "albl";
|
||||
note.textContent = "kit knobs appear once the drum engine (Agent A) is merged in";
|
||||
kitRow.appendChild(note);
|
||||
}
|
||||
}
|
||||
body.appendChild(kitRow);
|
||||
|
||||
beatEl.classList.add("open");
|
||||
}
|
||||
|
||||
// ---- 🌾 AMPLER — sample the world into the kit ---------------------------
|
||||
// GODSONIQ captures ~3s of the live master; AMPLER chops that capture into
|
||||
// slices and seats them onto the drum voices, so the beat's lanes play the
|
||||
// world instead of synthesis (the SP-404 move). A right-side panel — the
|
||||
// bottom belongs to the beat/arranger drawers. All AMPLER state is session-only.
|
||||
let amplerEl = null;
|
||||
const ampler = { open: false, capLen: 3, chop: 16, sel: -1, capturing: false, rev: [] };
|
||||
function amplerEnsure() {
|
||||
if (!amplerEl) { amplerEl = document.createElement("div"); amplerEl.id = "ampler"; document.body.appendChild(amplerEl); }
|
||||
return amplerEl;
|
||||
}
|
||||
function closeAmpler() { ampler.open = false; if (amplerEl) amplerEl.classList.remove("open"); }
|
||||
function openAmpler() { ampler.open = true; amplerEnsure().classList.add("open"); amplerRender(); }
|
||||
function amplerCapture() {
|
||||
const ok = Synth.amplerArm ? Synth.amplerArm(ampler.capLen) : false;
|
||||
if (!ok) { eventTicker.unshift({ text: "🌾 AMPLER needs the audio worklet — press ♪ first, then ⏺ capture", tAdded: now(), src: "sys" }); return; }
|
||||
ampler.capturing = true; ampler.sel = -1; ampler.rev = [];
|
||||
eventTicker.unshift({ text: "🌾 AMPLER — sampling " + ampler.capLen + "s of the world… then ✂ chop it into a kit", tAdded: now(), src: "sys" });
|
||||
amplerRender();
|
||||
setTimeout(() => { ampler.capturing = false; if (ampler.open) amplerRender(); }, ampler.capLen * 1000 + 250); // capture runs real-time; re-render once it has landed
|
||||
}
|
||||
function amplerDrawWave(cv) {
|
||||
const g = cv.getContext("2d"); if (!g) return;
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
const peaks = Synth.amplerPeaks ? Synth.amplerPeaks(cv.width) : [], mid = cv.height / 2;
|
||||
g.strokeStyle = "rgba(140,175,255,0.75)"; g.beginPath();
|
||||
for (let px = 0; px < peaks.length; px++) { const p = peaks[px]; g.moveTo(px + 0.5, mid - p[1] * mid); g.lineTo(px + 0.5, mid - p[0] * mid); }
|
||||
g.stroke();
|
||||
const st = Synth.amplerStatus ? Synth.amplerStatus() : { slices: 0 };
|
||||
if (st.slices) { g.strokeStyle = "rgba(150,200,255,0.35)";
|
||||
for (let i = 1; i < st.slices; i++) { const xx = Math.floor(cv.width * i / st.slices) + 0.5; g.beginPath(); g.moveTo(xx, 0); g.lineTo(xx, cv.height); g.stroke(); } }
|
||||
}
|
||||
function amplerRender() {
|
||||
if (!amplerEl) return;
|
||||
const st = Synth.amplerStatus ? Synth.amplerStatus() : { has: false, secs: 0, slices: 0 };
|
||||
amplerEl.innerHTML = "";
|
||||
const top = document.createElement("div"); top.className = "atop";
|
||||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🌾 AMPLER";
|
||||
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeAmpler;
|
||||
top.append(title, x); amplerEl.appendChild(top);
|
||||
|
||||
const cap = document.createElement("div"); cap.className = "row";
|
||||
const rec = document.createElement("button"); rec.className = "midibtn"; rec.textContent = ampler.capturing ? "…capturing" : "⏺ capture";
|
||||
rec.classList.toggle("on", ampler.capturing); rec.disabled = ampler.capturing; rec.onclick = amplerCapture;
|
||||
const lenSel = document.createElement("select"); lenSel.className = "asel"; lenSel.title = "capture length";
|
||||
for (const s of [1.5, 3, 6]) { const o = document.createElement("option"); o.value = String(s); o.textContent = s + "s"; if (s === ampler.capLen) o.selected = true; lenSel.appendChild(o); }
|
||||
lenSel.onchange = () => { ampler.capLen = parseFloat(lenSel.value) || 3; };
|
||||
cap.append(rec, lenSel); amplerEl.appendChild(cap);
|
||||
|
||||
if (!st.has) {
|
||||
const h = document.createElement("div"); h.className = "hint";
|
||||
h.textContent = ampler.capturing ? "listening to the world…" : "press ♪, let the world play, then ⏺ capture — you'll chop it into a kit.";
|
||||
amplerEl.appendChild(h); return;
|
||||
}
|
||||
|
||||
const cv = document.createElement("canvas"); cv.width = 280; cv.height = 54; amplerEl.appendChild(cv); amplerDrawWave(cv);
|
||||
|
||||
const chopRow = document.createElement("div"); chopRow.className = "row";
|
||||
const chopL = document.createElement("span"); chopL.className = "albl"; chopL.textContent = "chop";
|
||||
const chopSel = document.createElement("select"); chopSel.className = "asel";
|
||||
for (const n of [8, 16]) { const o = document.createElement("option"); o.value = String(n); o.textContent = n + " slices"; if (n === ampler.chop) o.selected = true; chopSel.appendChild(o); }
|
||||
chopSel.onchange = () => { ampler.chop = parseInt(chopSel.value) || 16; };
|
||||
const chopBtn = document.createElement("button"); chopBtn.className = "midibtn bmini"; chopBtn.textContent = "✂ chop";
|
||||
chopBtn.onclick = () => { if (Synth.amplerChop(ampler.chop)) { ampler.sel = -1; ampler.rev = []; amplerRender(); } };
|
||||
chopRow.append(chopL, chopSel, chopBtn); amplerEl.appendChild(chopRow);
|
||||
|
||||
if (!st.slices) { const h = document.createElement("div"); h.className = "hint"; h.textContent = "✂ chop the capture into slices, then tap to audition and assign."; amplerEl.appendChild(h); return; }
|
||||
|
||||
const grid = document.createElement("div"); grid.className = "sgrid";
|
||||
grid.style.gridTemplateColumns = "repeat(" + (st.slices > 8 ? 8 : st.slices) + ", 1fr)";
|
||||
for (let i = 0; i < st.slices; i++) {
|
||||
const cell = document.createElement("div"); cell.className = "scell"; cell.textContent = String(i);
|
||||
if (i === ampler.sel) cell.classList.add("sel");
|
||||
if (ampler.rev[i]) cell.classList.add("rev");
|
||||
cell.title = "tap to audition slice " + i;
|
||||
cell.onclick = () => { ampler.sel = i; Synth.amplerPlay(i, 0.95); amplerRender(); };
|
||||
const rv = document.createElement("span"); rv.className = "rv"; rv.textContent = "⇄"; rv.title = "reverse this slice";
|
||||
rv.onclick = (ev) => { ev.stopPropagation(); ampler.rev[i] = Synth.amplerReverse(i); amplerRender(); };
|
||||
cell.appendChild(rv); grid.appendChild(cell);
|
||||
}
|
||||
amplerEl.appendChild(grid);
|
||||
|
||||
const map = Synth.amplerMap ? Synth.amplerMap() : {};
|
||||
const strip = document.createElement("div"); strip.className = "amap";
|
||||
for (const v of DRUM_VOICES) {
|
||||
const row = document.createElement("div"); row.className = "maprow";
|
||||
const nm = document.createElement("span"); nm.className = "mvn"; nm.textContent = v;
|
||||
const sel = document.createElement("select"); sel.className = "asel"; sel.title = "assign a slice to " + v;
|
||||
const syn = document.createElement("option"); syn.value = "-1"; syn.textContent = "synth"; sel.appendChild(syn);
|
||||
for (let i = 0; i < st.slices; i++) { const o = document.createElement("option"); o.value = String(i); o.textContent = "s" + i + (ampler.rev[i] ? "ʳ" : ""); sel.appendChild(o); }
|
||||
sel.value = map[v] ? String(map[v].slice) : "-1";
|
||||
sel.onchange = () => { const idx = parseInt(sel.value); if (idx < 0) Synth.amplerClear(v); else Synth.amplerAssign(v, idx); amplerRender(); };
|
||||
const cur = document.createElement("span"); cur.className = "albl"; cur.textContent = map[v] ? ("← s" + map[v].slice + (map[v].reverse ? "ʳ" : "")) : "← synth";
|
||||
const clr = document.createElement("span"); clr.className = "x"; clr.textContent = "✕"; clr.title = "back to synth"; clr.onclick = () => { Synth.amplerClear(v); amplerRender(); };
|
||||
row.append(nm, sel, cur, clr); strip.appendChild(row);
|
||||
}
|
||||
amplerEl.appendChild(strip);
|
||||
const h = document.createElement("div"); h.className = "hint"; h.textContent = "tap a slice to audition · ⇄ reverses it · assign a slice to a voice and the beat plays the world";
|
||||
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];
|
||||
if (!sq.on) continue;
|
||||
if (k.slice(0, 5) === "drum.") { // a GODRUM lane — fire a synth drum
|
||||
const cur = seqCurStep;
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
continue; // swing came free from the clock
|
||||
}
|
||||
const n = dests.get(k);
|
||||
if (n && n.isNote) {
|
||||
const cur = seqCurStep, sv = sq.steps[cur];
|
||||
@ -2440,6 +2969,7 @@
|
||||
}
|
||||
if (seqGridRefresh) seqGridRefresh();
|
||||
if (arrGrooveRefresh) arrGrooveRefresh();
|
||||
if (beatRefresh) beatRefresh();
|
||||
}
|
||||
function groupsOf(key) {
|
||||
const out = [];
|
||||
@ -2614,6 +3144,9 @@
|
||||
if (applyGroove(lsq, 0, 0.7) !== 0.7) f.push("plock-lit-unlocked"); // lit + unlocked → world
|
||||
if (applyGroove(lsq, 1, 0.7) !== 0) f.push("plock-dark"); // dark step → 0
|
||||
if (applyGroove(lsq, 2, 0.7) !== 0.25) f.push("plock-locked"); // lit + locked → the lock value
|
||||
// GODRUM — a fresh drum lane (0-filled gates) round-trips through migrateSeq unchanged
|
||||
const drm = migrateSeq({ on: false, steps: new Array(16).fill(0) });
|
||||
if (!drm || drm.on !== false || drm.steps.length !== 16 || drm.steps.some(x => x !== 0)) f.push("drum-lane-shape");
|
||||
if (f.length) console.error("⚠ groove self-check FAILED:", f); else console.log("✓ groove self-check passed");
|
||||
return f.length === 0;
|
||||
}
|
||||
@ -2629,6 +3162,7 @@
|
||||
bpm: godtime.bpm,
|
||||
groove: Object.assign({}, groove), // the feel travels with the patch
|
||||
scale: Object.assign({}, gScale), // the key travels too
|
||||
kit: (Synth.drumKit ? Synth.drumKit() : undefined), // the GODRUM kit travels too (undefined pre-merge)
|
||||
};
|
||||
}
|
||||
function loadPatch(p) {
|
||||
@ -2646,6 +3180,7 @@
|
||||
}
|
||||
if (p.groove) Object.assign(groove, p.groove);
|
||||
if (p.scale) { Object.assign(gScale, p.scale); SEQ_ROWS = scaleRows(gScale.name); }
|
||||
if (p.kit && Synth.drumKitLoad) Synth.drumKitLoad(p.kit); // the GODRUM kit travels with the patch
|
||||
if (p.arr && p.arr.grid && p.arr.banks) { // the arrangement travels too
|
||||
arr.grid = JSON.parse(JSON.stringify(p.arr.grid));
|
||||
arr.banks = JSON.parse(JSON.stringify(p.arr.banks));
|
||||
@ -2653,6 +3188,7 @@
|
||||
if (arr.open) openArranger(); // rebuild the panel on the new song
|
||||
}
|
||||
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
|
||||
if (beat.open) openBeat(); // rebuild the beat panel on the new song
|
||||
layoutDirty = true;
|
||||
}
|
||||
|
||||
@ -2692,6 +3228,12 @@
|
||||
if (zeroUI.nodes && Object.keys(zeroUI.nodes).length) return true;
|
||||
for (const k in tweaks) { const t = tweaks[k];
|
||||
if (t && ((t.offset && Math.abs(t.offset) > 0.0015) || (t.gain != null && Math.abs(t.gain - 1) > 0.01) || t.mute >= 0.5 || t.freeze >= 0.5)) return true; }
|
||||
for (const v of DRUM_VOICES) { // a beat is a build too (R11): any lane playing, or its gates edited off the seed
|
||||
const sq = seqs["drum." + v]; if (!sq) continue;
|
||||
if (sq.on) return true;
|
||||
const seed = DRUM_SEED[v];
|
||||
for (let i = 0; i < 16; i++) if ((sq.steps[i] ? 1 : 0) !== (seed.indexOf(i) >= 0 ? 1 : 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function finishZeroExit(mode, name) { // mode: keep | old | weave (C1)
|
||||
@ -6361,6 +6903,24 @@
|
||||
if (ev.key === "t" || ev.key === "T") {
|
||||
arr.open ? closeArranger() : openArranger(); // tracks, for the casuals
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -6565,6 +7125,8 @@
|
||||
ctxItem(zenMode ? "leave zen" : "☾ zen — only the visuals", () => setZen(!zenMode), "Z");
|
||||
ctxItem(arr.open ? "close tracks" : "🎛 tracks — arrange clips",
|
||||
() => { arr.open ? closeArranger() : openArranger(); }, "T");
|
||||
ctxItem(beat.open ? "close beat" : "🥁 beat — make a beat",
|
||||
() => { beat.open ? closeBeat() : openBeat(); }, "B");
|
||||
const moodSub = ctxSub("🌗 mood — how much world");
|
||||
const z0 = document.createElement("div"); z0.className = "it";
|
||||
z0.textContent = zeroMode ? "0 zero — you're building it (a mood leaves)" : "0 zero — build from nothing";
|
||||
@ -6609,6 +7171,7 @@
|
||||
() => { const ok = Synth.godsoniq(3);
|
||||
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling the world for 3s… then every pluck replays it, pitched across the keys (🎸 string voice to exit)"
|
||||
: "🎛 GODSONIQ needs the audio worklet — press ♪ first, then try again", tAdded: now(), src: "sys" }); });
|
||||
ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler());
|
||||
ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"),
|
||||
async () => { const r = await Synth.feedTab();
|
||||
const msg = r.stopped ? "🌐 tab feed stopped"
|
||||
@ -7798,6 +8361,24 @@
|
||||
let leadNote = 0, bassNote = 0, step = 0, nextAt = 0;
|
||||
let lastLeadT = 0, lastBassT = 0; // retrigger rate-limiters
|
||||
|
||||
// ---- GODRUM — five synthesized drum voices. They sum onto preSat like every
|
||||
// other voice, so they ride the same saturation → crush → chorus/reverb/echo
|
||||
// world (the instrument's ethos, kept). Every param is normalized 0..1 at the
|
||||
// boundary and mapped to real units inside its voice. DRUM_DEFS is the single
|
||||
// source of truth the panel renders from; KIT holds the live, editable values.
|
||||
const DRUM_DEFS = {
|
||||
kick: { params: ["tune", "punch", "decay", "duck", "level", "pan"], def: [0.5, 0.5, 0.5, 0.35, 0.85, 0.5] },
|
||||
snare: { params: ["tune", "snap", "decay", "level", "pan"], def: [0.5, 0.6, 0.4, 0.7, 0.5] },
|
||||
clap: { params: ["tone", "decay", "level", "pan"], def: [0.5, 0.5, 0.65, 0.5] },
|
||||
hat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.35, 0.5, 0.5] },
|
||||
ohat: { params: ["tone", "decay", "level", "pan"], def: [0.6, 0.5, 0.45, 0.5] },
|
||||
};
|
||||
const KIT = {};
|
||||
for (const _v in DRUM_DEFS) { KIT[_v] = {}; DRUM_DEFS[_v].params.forEach((p, i) => { KIT[_v][p] = DRUM_DEFS[_v].def[i]; }); }
|
||||
// caller-supplied names (from patch/vibe JSON — cross-user data) must be OWN keys, never reached through
|
||||
// the prototype chain — a "__proto__" key would otherwise write onto Object.prototype and corrupt the runtime.
|
||||
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
||||
|
||||
const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);
|
||||
const c01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
||||
const d = (k, def) => { const v = D[k]; return typeof v === "number" ? v : (def || 0); };
|
||||
@ -7909,9 +8490,12 @@
|
||||
bassOsc.connect(bassFilt); bassFilt.connect(bassAmp); bassAmp.connect(preSat);
|
||||
bassOsc.frequency.value = mtof(38); bassOsc.start();
|
||||
|
||||
// GODRUM kick-duck bus: pad + drone route through duckG, so a kick can dip the
|
||||
// low-mid wall out from under itself ("the kick parts the sea"). Rides at 1.
|
||||
const duckG = ctx.createGain(); duckG.gain.value = 1; duckG.connect(preSat);
|
||||
const padFilt = ctx.createBiquadFilter(); padFilt.type = "lowpass"; padFilt.frequency.value = 800;
|
||||
const padAmp = ctx.createGain(); padAmp.gain.value = 0;
|
||||
padFilt.connect(padAmp); padAmp.connect(preSat); lfoAmt.connect(padFilt.frequency);
|
||||
padFilt.connect(padAmp); padAmp.connect(duckG); lfoAmt.connect(padFilt.frequency);
|
||||
// lush supersaw pad — each chord tone is a cluster of detuned saws (JP-8000 style).
|
||||
// Spectrally verified: lush (133 partials), no clip, no aliasing, no DC.
|
||||
const padOscs = [];
|
||||
@ -7923,7 +8507,7 @@
|
||||
|
||||
const droneFilt = ctx.createBiquadFilter(); droneFilt.type = "lowpass"; droneFilt.frequency.value = 380;
|
||||
const droneAmp = ctx.createGain(); droneAmp.gain.value = 0;
|
||||
droneFilt.connect(droneAmp); droneAmp.connect(preSat);
|
||||
droneFilt.connect(droneAmp); droneAmp.connect(duckG);
|
||||
const droneOscs = [0, 7].map((iv) => {
|
||||
const o = ctx.createOscillator(); o.type = "sawtooth"; o.frequency.value = mtof(26 + iv);
|
||||
o.connect(droneFilt); o.start(); return o;
|
||||
@ -7950,9 +8534,26 @@
|
||||
schuDrone.connect(schuDroneG); schuDroneG.connect(droneFilt); schuDrone.start();
|
||||
|
||||
lfo.start();
|
||||
|
||||
// ---- GODRUM bus: the five voices sum here, then into preSat so they share
|
||||
// the world's saturation/crush/chorus/reverb/echo. Each voice keeps a
|
||||
// persistent level→pan chain; individual hits are one-shot graphs that
|
||||
// connect into their voice's level gain (like pluck()/noiseHit()). ----
|
||||
const drumBus = ctx.createGain(); drumBus.gain.value = 0.8; drumBus.connect(preSat);
|
||||
const noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); // 1s white noise, looped & reused per hit
|
||||
{ const nch = noiseBuf.getChannelData(0); for (let i = 0; i < nch.length; i++) nch[i] = Math.random() * 2 - 1; }
|
||||
const drums = {};
|
||||
for (const dv in DRUM_DEFS) {
|
||||
const g = ctx.createGain(), pan = ctx.createStereoPanner();
|
||||
g.gain.value = (KIT[dv].level != null ? KIT[dv].level : 1) * 1.2; // level 0..1 → gain 0..1.2
|
||||
pan.pan.value = (KIT[dv].pan != null ? KIT[dv].pan : 0.5) * 2 - 1; // pan 0..1 → −1..1
|
||||
g.connect(pan); pan.connect(drumBus); drums[dv] = { g, pan };
|
||||
}
|
||||
|
||||
return { out, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp,
|
||||
bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp,
|
||||
echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet };
|
||||
echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet,
|
||||
drumBus, drums, noiseBuf, duckG };
|
||||
}
|
||||
|
||||
function hit(node, peak, dec) { // pluck envelope
|
||||
@ -7971,6 +8572,116 @@
|
||||
s.connect(hp); hp.connect(g); g.connect(N.preSat); s.start(at != null ? at : ctx.currentTime);
|
||||
}
|
||||
|
||||
// ---- GODRUM voices. Velocity scales amplitude as vel*vel (punchier than
|
||||
// linear for drums). Envelopes are linear ramps + setTargetAtTime — never
|
||||
// exponential-to-0 (illegal); noise voices loop the shared buffer and are
|
||||
// stopped once their tail has rung out. ----
|
||||
function dNoise() { const s = ctx.createBufferSource(); s.buffer = N.noiseBuf; s.loop = true; return s; }
|
||||
function drumSync(name, param) { // level/pan → live nodes on a short glide
|
||||
if (!N || !N.drums || !own(N.drums, name)) return;
|
||||
const t = ctx.currentTime, dv = N.drums[name];
|
||||
if (param === "level") dv.g.gain.setTargetAtTime(KIT[name].level * 1.2, t, 0.02);
|
||||
else if (param === "pan") dv.pan.pan.setTargetAtTime(KIT[name].pan * 2 - 1, t, 0.02);
|
||||
}
|
||||
|
||||
// cancel pending automation but HOLD the value the timeline actually has at t — 0 for a same-task
|
||||
// hit whose envelope hasn't rendered (reading .value there returns the 1.0 default → a pop), the
|
||||
// decaying level mid-ring. cancelAndHoldAtTime is the right primitive; read-value is the legacy fallback.
|
||||
const cancelHold = (p, t) => { if (p.cancelAndHoldAtTime) p.cancelAndHoldAtTime(t);
|
||||
else { p.cancelScheduledValues(t); p.setValueAtTime(p.value, t); } };
|
||||
let ohatEnv = null; // the currently-ringing open hat, for the TR choke
|
||||
function choke(t) { if (ohatEnv) { cancelHold(ohatEnv.gain, t); ohatEnv.gain.setTargetAtTime(0, t, 0.012); ohatEnv = null; } }
|
||||
function kickDuck(t) { // the kick parts the sea — dip pad+drone via duckG, then let them flood back (shared by synth- and slice-kick)
|
||||
if (KIT.kick.duck > 0 && N.duckG) { const dg = N.duckG.gain; cancelHold(dg, t);
|
||||
dg.linearRampToValueAtTime(1 - KIT.kick.duck * 0.85, t + 0.012); dg.setTargetAtTime(1, t + 0.05, 0.15); }
|
||||
}
|
||||
|
||||
function fireKick(vel) {
|
||||
const t = ctx.currentTime, K = KIT.kick, dst = N.drums.kick.g;
|
||||
const fB = 35 + K.tune * 45, peak = vel * vel, tau = 0.05 + K.decay * 0.30;
|
||||
const o = ctx.createOscillator(); o.type = "sine";
|
||||
o.frequency.setValueAtTime(fB * (2 + K.punch * 6), t);
|
||||
o.frequency.exponentialRampToValueAtTime(fB, t + 0.045); // the pitch drop IS the thump
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(peak, t + 0.003);
|
||||
g.gain.setTargetAtTime(0, t + 0.01, tau);
|
||||
o.connect(g); g.connect(dst); o.start(t); o.stop(t + 0.01 + tau * 8); // ~8τ: let the tail ring fully out — no click at high decay
|
||||
if (K.punch > 0) { // a 2ms highpassed noise click for attack
|
||||
const cs = dNoise(), hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 1000;
|
||||
const cg = ctx.createGain(); cg.gain.setValueAtTime(vel * K.punch * 0.3, t); cg.gain.setTargetAtTime(0, t, 0.002);
|
||||
cs.connect(hp); hp.connect(cg); cg.connect(dst); cs.start(t); cs.stop(t + 0.05);
|
||||
}
|
||||
kickDuck(t); // dip pad+drone (factored — the slice-kick path shares this exact block)
|
||||
}
|
||||
function fireSnare(vel) {
|
||||
const t = ctx.currentTime, K = KIT.snare, dst = N.drums.snare.g;
|
||||
const f = 140 + K.tune * 120, peak = vel * vel, tau = 0.05 + K.decay * 0.30;
|
||||
const toneG = ctx.createGain(); // body: two oscs, fixed ~80ms decay (snap 0 = all tone)
|
||||
toneG.gain.setValueAtTime(0, t); toneG.gain.linearRampToValueAtTime(peak * (1 - K.snap) * 0.85, t + 0.002);
|
||||
toneG.gain.setTargetAtTime(0, t + 0.004, 0.028); toneG.connect(dst);
|
||||
[f, f * 1.5].forEach((fr) => { const o = ctx.createOscillator(); o.type = "triangle"; o.frequency.value = fr; o.connect(toneG); o.start(t); o.stop(t + 0.2); });
|
||||
const ns = dNoise(); // rattle: bandpassed + highpassed noise (snap 1 = all noise)
|
||||
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1800; bp.Q.value = 0.8;
|
||||
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 400;
|
||||
const ng = ctx.createGain(); ng.gain.setValueAtTime(0, t); ng.gain.linearRampToValueAtTime(peak * K.snap * 1.1, t + 0.002);
|
||||
ng.gain.setTargetAtTime(0, t + 0.004, tau);
|
||||
ns.connect(bp); bp.connect(hp); hp.connect(ng); ng.connect(dst); ns.start(t); ns.stop(t + Math.max(0.2, tau * 6));
|
||||
}
|
||||
function fireClap(vel) {
|
||||
const t = ctx.currentTime, K = KIT.clap, dst = N.drums.clap.g, peak = vel * vel * 0.9;
|
||||
const ns = dNoise(), bp = ctx.createBiquadFilter();
|
||||
bp.type = "bandpass"; bp.frequency.value = 700 + K.tone * 1500; bp.Q.value = 1.5;
|
||||
const g = ctx.createGain(); g.gain.setValueAtTime(0, t);
|
||||
const jit = () => (Math.random() * 2 - 1) * 0.002; // ±2ms per hit → the three claps breathe
|
||||
for (const off of [0, 0.011, 0.023]) { const st = t + Math.max(0, off + jit()); g.gain.setValueAtTime(peak, st); g.gain.setTargetAtTime(0, st, 0.004); }
|
||||
const body = t + 0.030 + jit(), tau = 0.04 + K.decay * 0.25; // then the ringing body (909 spikes → tail)
|
||||
g.gain.setValueAtTime(peak, body); g.gain.setTargetAtTime(0, body, tau);
|
||||
ns.connect(bp); bp.connect(g); g.connect(dst); ns.start(t); ns.stop(body + Math.max(0.2, tau * 6));
|
||||
}
|
||||
function fireHat(vel, open) {
|
||||
const t = ctx.currentTime, K = open ? KIT.ohat : KIT.hat, dst = N.drums[open ? "ohat" : "hat"].g;
|
||||
choke(t); // a closed OR a new open hat chokes the ringing open hat — the TR rule
|
||||
const ns = dNoise(), hp = ctx.createBiquadFilter();
|
||||
hp.type = "highpass"; hp.frequency.value = 5500 + K.tone * 4000;
|
||||
const g = ctx.createGain(), peak = vel * vel * (open ? 0.45 : 0.35); // mix: sit the hats at/just under the ambient perc.density shimmer (§A.3 intent)
|
||||
const tau = open ? (0.10 + K.decay * 0.5) : (0.012 + K.decay * 0.06);
|
||||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + 0.001); g.gain.setTargetAtTime(0, t + 0.002, tau);
|
||||
ns.connect(hp); hp.connect(g); g.connect(dst); ns.start(t); ns.stop(t + Math.max(0.1, tau * 6));
|
||||
if (open) ohatEnv = g; // this open hat can now be choked
|
||||
}
|
||||
|
||||
// fire a drum — mirrors pluck()'s wake-up, so a drum can be the first sound the page makes
|
||||
function drum(name, vel) {
|
||||
if (!own(DRUM_DEFS, name)) return; // unknown / non-own voice → silent, no side effects
|
||||
if (!AC) return;
|
||||
if (!ctx) { ctx = new AC(); N = build(); loadWorklets(); }
|
||||
ctx.resume();
|
||||
if (!on) N.out.gain.setTargetAtTime(0.85, ctx.currentTime, 0.1);
|
||||
const v = vel == null ? 0.8 : c01(vel);
|
||||
const slb = own(aMap, name) ? amplerSliceBuf(aMap[name]) : null; // 🌾 AMPLER — an assigned slice PLAYS the world for this voice
|
||||
if (slb) { fireSlice(name, slb, v); return; }
|
||||
if (name === "kick") fireKick(v);
|
||||
else if (name === "snare") fireSnare(v);
|
||||
else if (name === "clap") fireClap(v);
|
||||
else if (name === "hat") fireHat(v, false);
|
||||
else if (name === "ohat") fireHat(v, true);
|
||||
}
|
||||
function drumParam(name, param, value) {
|
||||
if (!own(KIT, name) || !own(KIT[name], param)) return undefined; // own-key only — no proto-chain reach
|
||||
const kv = KIT[name];
|
||||
if (value === undefined) return kv[param]; // get
|
||||
if (Number.isFinite(value)) { kv[param] = c01(value); drumSync(name, param); } // set (finite, clamped 0..1) → live nodes; ignore junk
|
||||
return kv[param];
|
||||
}
|
||||
function drumParamList(name) { const def = own(DRUM_DEFS, name) ? DRUM_DEFS[name] : null; return def ? def.params.map((p) => ({ key: p, label: p })) : []; }
|
||||
function drumKit() { const o = {}; for (const v in KIT) if (own(KIT, v)) { o[v] = {}; for (const p in KIT[v]) if (own(KIT[v], p)) o[v][p] = KIT[v][p]; } return o; } // deep, JSON-safe, own-keys only
|
||||
function drumKitLoad(obj) { // merge a snapshot; missing/foreign/proto keys keep defaults
|
||||
if (!obj) return;
|
||||
for (const v in obj) if (own(obj, v) && own(KIT, v) && obj[v]) for (const p in obj[v])
|
||||
if (own(obj[v], p) && own(KIT[v], p) && Number.isFinite(obj[v][p])) { KIT[v][p] = c01(obj[v][p]); drumSync(v, p); }
|
||||
}
|
||||
|
||||
function params() {
|
||||
const t = ctx.currentTime, S = (p, v, tc) => p.setTargetAtTime(v, t, tc || 0.1);
|
||||
S(N.leadFilt.frequency, 250 + d("filter.cutoff", 0.3) * 5000, 0.05);
|
||||
@ -8039,9 +8750,12 @@
|
||||
// drivable) and a sample-accurate Karplus-Strong string. Both need a worklet
|
||||
// (native feedback + real sample-rate reduction are unstable/impossible with
|
||||
// stock nodes — the spectral harness proved it). Loaded async from a Blob URL.
|
||||
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);class Recorder extends AudioWorkletProcessor{constructor(){super();this.rec=false;this.buf=null;this.pos=0;this.port.onmessage=(e)=>{if(e.data.rec){this.buf=new Float32Array(e.data.len);this.pos=0;this.rec=true;}};}process(inputs){const inp=inputs[0];if(this.rec&&inp&&inp[0]){const x=inp[0];for(let i=0;i<x.length&&this.pos<this.buf.length;i++)this.buf[this.pos++]=x[i];if(this.pos>=this.buf.length){this.rec=false;const b=this.buf;this.buf=null;this.port.postMessage({done:true,buf:b},[b.buffer]);}}return true;}}registerProcessor('recorder',Recorder);";
|
||||
const WORKLET_SRC = "class Bitcrush extends AudioWorkletProcessor{static get parameterDescriptors(){return[{name:'bits',defaultValue:16,minValue:1,maxValue:16,automationRate:'k-rate'},{name:'reduction',defaultValue:1,minValue:1,maxValue:64,automationRate:'k-rate'}];}constructor(){super();this.phase=0;this.hold=null;}process(inputs,outputs,p){const inp=inputs[0],out=outputs[0];if(!inp||!inp.length)return true;const b=p.bits[0],red=Math.max(1,Math.round(p.reduction[0])),lv=Math.pow(2,b),nC=out.length,nS=out[0].length;if(!this.hold)this.hold=new Float32Array(8);for(let i=0;i<nS;i++){if(this.phase<=0){for(let c=0;c<nC;c++){const xi=(inp[c]||inp[0])[i];this.hold[c]=Math.round(xi*lv)/lv;}this.phase=red;}this.phase--;for(let c=0;c<nC;c++)out[c][i]=this.hold[c];}return true;}}registerProcessor('bitcrush',Bitcrush);class Karplus extends AudioWorkletProcessor{constructor(){super();this.v=[];for(let i=0;i<8;i++)this.v.push({buf:new Float32Array(4096),len:0,idx:0,active:false,fb:0.995,damp:0.5,last:0,amp:0});this.n=0;this.port.onmessage=(e)=>{const vo=this.v[this.n];this.n=(this.n+1)%8;const L=Math.max(2,Math.min(4096,Math.round(sampleRate/e.data.freq)));vo.len=L;vo.idx=0;vo.active=true;vo.amp=e.data.vel;vo.last=0;for(let i=0;i<L;i++)vo.buf[i]=Math.random()*2-1;};}process(inputs,outputs){const out=outputs[0],y=out[0],nS=y.length;for(let i=0;i<nS;i++)y[i]=0;for(const vo of this.v){if(!vo.active)continue;for(let i=0;i<nS;i++){const cur=vo.buf[vo.idx];const f=vo.damp*cur+(1-vo.damp)*vo.last;vo.last=f;vo.buf[vo.idx]=f*vo.fb;y[i]+=cur*vo.amp*0.4;vo.idx++;if(vo.idx>=vo.len)vo.idx=0;}}for(let c=1;c<out.length;c++)out[c].set(y);return true;}}registerProcessor('karplus',Karplus);class Recorder extends AudioWorkletProcessor{constructor(){super();this.rec=false;this.buf=null;this.pos=0;this.port.onmessage=(e)=>{if(e.data.rec){this.buf=new Float32Array(e.data.len);this.pos=0;this.rec=true;this.tag=e.data.tag;}};}process(inputs){const inp=inputs[0];if(this.rec&&inp&&inp[0]){const x=inp[0];for(let i=0;i<x.length&&this.pos<this.buf.length;i++)this.buf[this.pos++]=x[i];if(this.pos>=this.buf.length){this.rec=false;const b=this.buf;this.buf=null;this.port.postMessage({done:true,buf:b,tag:this.tag},[b.buffer]);}}return true;}}registerProcessor('recorder',Recorder);";
|
||||
let crusher = null, ksNode = null, ksMode = false, workletTried = false;
|
||||
let recNode = null, sampleBuf = null, sampleMode = false; // GODSONIQ (cheeky Ensoniq nod): resample the world onto the keys
|
||||
let recTarget = "godsoniq"; // routes a finished Recorder capture: "godsoniq" → keys-replay, "ampler" → amplerBuf
|
||||
let amplerBuf = null, slN = 0, sliceFwd = [], sliceRevBuf = [], sliceRev = []; // 🌾 AMPLER: capture + equal slices (fwd + lazily-cached reversed) + per-slice reverse flags
|
||||
const aMap = {}; // 🌾 AMPLER assignment map: voice → slice idx (session-only, not persisted in v1)
|
||||
function loadWorklets() {
|
||||
if (workletTried || !ctx || !ctx.audioWorklet || !N) return;
|
||||
workletTried = true;
|
||||
@ -8057,17 +8771,88 @@
|
||||
const rsink = ctx.createGain(); rsink.gain.value = 0; recNode.connect(rsink); rsink.connect(ctx.destination);
|
||||
recNode.port.onmessage = (e) => { if (!e.data.done) return;
|
||||
const f = e.data.buf, ab = ctx.createBuffer(1, f.length, ctx.sampleRate); ab.copyToChannel(f, 0);
|
||||
sampleBuf = ab; sampleMode = true; ksMode = false; // captured — plucks now replay it, pitched
|
||||
if (e.data.tag === "ampler") { amplerBuf = ab; slN = 0; sliceFwd = []; sliceRev = []; sliceRevBuf = []; } // 🌾 AMPLER — its own buffer; must (re)chop. Routed by the capture's own tag (race-proof), so GODSONIQ's sampleBuf/sampleMode stay untouched.
|
||||
else { sampleBuf = ab; sampleMode = true; ksMode = false; } // GODSONIQ — plucks now replay it, pitched
|
||||
};
|
||||
}).catch(() => { workletTried = false; });
|
||||
}
|
||||
function godsoniq(secs) { // arm the resampler for `secs` of the live world
|
||||
function armRec(secs) { // shared: arm the Recorder worklet for `secs` of the live master
|
||||
if (!ctx) start();
|
||||
if (!recNode) return false; // worklet not ready
|
||||
if (!on) start(); // world must be sounding to sample it
|
||||
recNode.port.postMessage({ rec: true, len: Math.floor((secs || 3) * ctx.sampleRate) });
|
||||
recNode.port.postMessage({ rec: true, len: Math.floor((secs || 3) * ctx.sampleRate), tag: recTarget }); // tag travels with the capture → the finished buffer routes to its own sink, no shared-state race
|
||||
return true;
|
||||
}
|
||||
function godsoniq(secs) { recTarget = "godsoniq"; return armRec(secs); } // GODSONIQ — capture lands on the keys-replay path (unchanged)
|
||||
|
||||
// ---- 🌾 AMPLER — chop the captured world into slices seated on the drum voices.
|
||||
// Captures land in amplerBuf (recTarget routes them, so GODSONIQ stays byte-identical);
|
||||
// slices are equal divisions; reverse is a lazily-built, cached per-slice copy; an
|
||||
// assigned voice PLAYS its slice through that voice's level→pan→drumBus chain, so the
|
||||
// kit knobs and their patch-persistence keep working, and duck/choke stay source-agnostic. ----
|
||||
function revBuf(fwd) { const n = fwd.length, r = ctx.createBuffer(1, n, fwd.sampleRate), s = fwd.getChannelData(0), o = r.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) o[i] = s[n - 1 - i]; return r; }
|
||||
function amplerSliceBuf(idx) { // the buffer to play for slice idx (reversed copy if flagged, built lazily)
|
||||
if (idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return null;
|
||||
if (sliceRev[idx]) { if (!sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]); return sliceRevBuf[idx]; }
|
||||
return sliceFwd[idx];
|
||||
}
|
||||
function playBuf(buf, dst, vel, t) { // one-shot slice playback with a short anti-click fade; returns the gain (for choke)
|
||||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||
const g = ctx.createGain(), pk = vel * vel, dur = buf.duration;
|
||||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(pk, t + 0.003);
|
||||
if (dur > 0.008) { const fo = t + dur - Math.min(0.006, dur / 3); g.gain.setValueAtTime(pk, fo); g.gain.linearRampToValueAtTime(0, t + dur); } // anti-click tail, fade clamped for short slices
|
||||
src.connect(g); g.connect(dst); src.start(t); src.stop(t + dur + 0.02);
|
||||
return g;
|
||||
}
|
||||
function fireSlice(voice, buf, vel) { // an AMPLER slice seated on a drum voice (playbackRate 1.0 — no pitching in v1)
|
||||
const t = ctx.currentTime;
|
||||
if (voice === "hat" || voice === "ohat") choke(t); // the TR choke is source-agnostic
|
||||
const g = playBuf(buf, N.drums[voice].g, vel, t); // → level → pan → drumBus (kit knobs & persistence still act)
|
||||
if (voice === "kick") kickDuck(t); // a slice-kick still parts the sea
|
||||
if (voice === "ohat") ohatEnv = g; // a slice-ohat can be choked
|
||||
}
|
||||
function amplerArm(secs) { recTarget = "ampler"; return armRec(secs); } // capture `secs` of the live master into amplerBuf
|
||||
function amplerStatus() { return { has: !!amplerBuf, secs: amplerBuf ? amplerBuf.duration : 0, slices: slN }; }
|
||||
function amplerChop(n) { // slice the capture into n equal slices (8 or 16)
|
||||
if (!amplerBuf) return false;
|
||||
n = n === 8 ? 8 : n === 16 ? 16 : 0; if (!n) return false;
|
||||
const total = amplerBuf.length, data = amplerBuf.getChannelData(0), per = Math.floor(total / n);
|
||||
if (per < 1) return false;
|
||||
sliceFwd = []; sliceRev = []; sliceRevBuf = [];
|
||||
for (let i = 0; i < n; i++) { const s0 = i * per, len = (i === n - 1) ? (total - s0) : per;
|
||||
const b = ctx.createBuffer(1, len, amplerBuf.sampleRate); b.copyToChannel(data.subarray(s0, s0 + len), 0);
|
||||
sliceFwd.push(b); sliceRev.push(false); sliceRevBuf.push(null); }
|
||||
slN = n;
|
||||
for (const v in aMap) if (own(aMap, v) && aMap[v] >= n) delete aMap[v]; // drop assignments that fell out of range so map/UI match playback
|
||||
return true;
|
||||
}
|
||||
function amplerPlay(idx, vel) { // audition a slice through the full FX chain (default vel 0.9)
|
||||
const b = amplerSliceBuf(idx); if (!b || !ctx) return false;
|
||||
ctx.resume(); const t = ctx.currentTime; if (!on) N.out.gain.setTargetAtTime(0.85, t, 0.1);
|
||||
playBuf(b, N.preSat, vel == null ? 0.9 : c01(vel), t); return true;
|
||||
}
|
||||
function amplerReverse(idx, on2) { // toggle (on omitted) or set the slice's reverse; → new boolean
|
||||
if (idx == null || idx < 0 || idx >= slN) return false;
|
||||
const nv = on2 === undefined ? !sliceRev[idx] : !!on2;
|
||||
if (nv && !sliceRevBuf[idx]) sliceRevBuf[idx] = revBuf(sliceFwd[idx]);
|
||||
sliceRev[idx] = nv; return nv;
|
||||
}
|
||||
function amplerAssign(voice, idx) { // voice now PLAYS this slice (own-key voice only — proto-safe)
|
||||
if (!own(DRUM_DEFS, voice) || idx == null || idx < 0 || idx >= slN || !sliceFwd[idx]) return false;
|
||||
aMap[voice] = idx; return true;
|
||||
}
|
||||
function amplerClear(voice) { if (own(aMap, voice)) delete aMap[voice]; return true; } // back to the synth recipe
|
||||
function amplerMap() { const m = {}; for (const v in aMap) if (own(aMap, v)) m[v] = { slice: aMap[v], reverse: !!sliceRev[aMap[v]] }; return m; }
|
||||
function amplerPeaks(cols) { // min/max per column for the panel waveform (a D helper beyond the frozen 8)
|
||||
cols = Math.max(1, Math.min(2048, Math.floor(cols || 200))); const out = [];
|
||||
if (!amplerBuf) return out;
|
||||
const data = amplerBuf.getChannelData(0), n = data.length, per = n / cols;
|
||||
for (let c = 0; c < cols; c++) { let mn = 1, mx = -1; const a = Math.floor(c * per), b = Math.min(n, Math.floor((c + 1) * per));
|
||||
for (let i = a; i < b; i++) { const x = data[i]; if (x < mn) mn = x; if (x > mx) mx = x; }
|
||||
if (b <= a) { mn = 0; mx = 0; } out.push([mn, mx]); }
|
||||
return out;
|
||||
}
|
||||
// 🌐 external tab feed — pull another tab's audio (a YouTube tab, a live stream) into the
|
||||
// world via getDisplayMedia. Wired into preSat, so it flows the whole FX chain to the master:
|
||||
// audible live AND resampleable by GODSONIQ (which taps N.out). Chrome/Edge only; on a Mac the
|
||||
@ -8176,7 +8961,9 @@
|
||||
toggle() { on ? stop() : start(); paint(); return on; }, playing() { return on; },
|
||||
stringVoice(v) { if (v === undefined) return ksMode; if (!ctx) start(); ksMode = !!v; sampleMode = false; return ksMode; },
|
||||
godsoniq(secs) { return godsoniq(secs); }, sampling() { return sampleMode && !!sampleBuf; },
|
||||
feedTab() { return feedTab(); }, feeding() { return !!extStream; } };
|
||||
feedTab() { return feedTab(); }, feeding() { return !!extStream; },
|
||||
drum, drumParam, drumParamList, drumKit, drumKitLoad,
|
||||
amplerArm, amplerStatus, amplerChop, amplerPlay, amplerReverse, amplerAssign, amplerClear, amplerMap, amplerPeaks };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
106
viz/manual.html
106
viz/manual.html
@ -239,6 +239,105 @@
|
||||
.seqcell.on { background: #78a0ff; box-shadow: 0 0 6px rgba(120,160,255,0.6); }
|
||||
.seqcell.cur { outline: 1px solid rgba(255,238,150,0.85); }
|
||||
|
||||
/* 🥁 GODRUM — the beat panel: a sibling of the arranger, sharing its groove & clock */
|
||||
#beat {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 54px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 46vh;
|
||||
}
|
||||
#beat.open { display: flex; flex-direction: column; }
|
||||
#beat .agrip { height: 8px; margin: -8px -10px 2px; cursor: ns-resize; border-radius: 12px 12px 0 0; }
|
||||
#beat .agrip:hover { background: rgba(150,180,230,0.25); }
|
||||
#beat .atop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
#beat .abody { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
#beat .arow { display: flex; align-items: center; gap: 3px; margin: 2px 0; }
|
||||
#beat .albl { color: rgba(150,170,200,0.65); font-size: 10px; flex: 1; }
|
||||
#beat .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#beat .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 3px 8px; font-size: 11px; }
|
||||
#beat .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 0 0 auto; }
|
||||
#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; }
|
||||
#beat .gctl .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 48px; text-align: right; }
|
||||
#beat .gctl input[type=range] { width: 74px; accent-color: #7fa0e6; height: 3px; }
|
||||
#beat .gctl .gval { color: #aebbdc; font-size: 10px; min-width: 30px; }
|
||||
#beat .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||||
#beat .gpreset { font-size: 10px; padding: 2px 7px; opacity: 0.85; }
|
||||
#beat .eunum { width: 52px; background: rgba(20,26,40,0.8); color: #cfe0f5;
|
||||
border: 1px solid rgba(120,150,220,0.3); border-radius: 4px; font-size: 11px; padding: 1px 4px; }
|
||||
#beat .emrow { gap: 6px; align-items: center; margin-top: 6px; }
|
||||
#beat .blane { display: flex; align-items: center; gap: 4px; margin: 3px 0; }
|
||||
#beat .blead { display: flex; align-items: center; gap: 4px; width: 104px; min-width: 104px; }
|
||||
#beat .bname { flex: 1; color: #aebfda; font-size: 10px; text-align: right; overflow: hidden;
|
||||
white-space: nowrap; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
#beat .bsteps { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px; }
|
||||
#beat .seqcell { width: 26px; height: 20px; border-radius: 4px; }
|
||||
#beat .exprlane { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px;
|
||||
height: 46px; align-items: end; margin: 4px 0 8px 108px; }
|
||||
#beat .exprcell { width: 26px; height: 46px; display: flex; align-items: flex-end;
|
||||
background: rgba(30,36,54,0.6); border-radius: 4px; cursor: ns-resize; }
|
||||
#beat .exprcell.bar { background: rgba(40,48,72,0.7); }
|
||||
#beat .exprbar { width: 100%; background: rgba(120,150,230,0.35); border-radius: 4px; min-height: 2px; }
|
||||
#beat .exprbar.lit { background: rgba(140,175,255,0.85); }
|
||||
#beat .bkit { gap: 8px; align-items: flex-start; }
|
||||
#beat .bvoice { display: inline-flex; flex-direction: column; gap: 2px; padding: 4px 6px;
|
||||
background: rgba(20,26,40,0.5); border-radius: 6px; }
|
||||
#beat .bvn { color: #9fb6dc; font-size: 10px; text-align: center; margin-bottom: 1px;
|
||||
text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
#beatbtn {
|
||||
position: fixed; right: 14px; bottom: 128px; z-index: 12; padding: 6px 12px;
|
||||
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
|
||||
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
|
||||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
|
||||
}
|
||||
#beatbtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
|
||||
#beatbtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
|
||||
#beatbtn.hidden { display: none; }
|
||||
|
||||
/* 🌾 AMPLER — right-side sample-slicing panel (the bottom is the drawers' turf) */
|
||||
#ampler {
|
||||
position: fixed; right: 12px; top: 64px; width: 300px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 82vh; overflow-y: auto;
|
||||
}
|
||||
#ampler.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#ampler .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#ampler .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#ampler .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#ampler .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#ampler .albl { color: rgba(150,170,200,0.7); font-size: 10px; }
|
||||
#ampler .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#ampler .midibtn.on { background: rgba(230,120,120,0.22); color: #ffc9c9; border-color: rgba(230,130,130,0.55); }
|
||||
#ampler .midibtn:disabled { opacity: 0.6; cursor: default; }
|
||||
#ampler .bmini { padding: 2px 7px; }
|
||||
#ampler .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||||
#ampler canvas { width: 100%; height: 54px; background: rgba(20,26,40,0.7); border-radius: 6px; display: block; }
|
||||
#ampler .sgrid { display: grid; gap: 3px; }
|
||||
#ampler .scell { position: relative; height: 30px; border-radius: 4px; background: rgba(40,52,74,0.7);
|
||||
border: 1px solid rgba(120,150,200,0.25); cursor: pointer; font-size: 9px; color: #9fb6dc;
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
#ampler .scell:hover { border-color: rgba(150,180,230,0.6); }
|
||||
#ampler .scell.sel { border-color: rgba(150,200,255,0.85); background: rgba(50,66,96,0.85); }
|
||||
#ampler .scell.rev { background: rgba(120,150,230,0.32); }
|
||||
#ampler .scell .rv { position: absolute; top: 0; right: 2px; font-size: 9px; color: #bcd; }
|
||||
#ampler .scell .rv:hover { color: #eaf2ff; }
|
||||
#ampler .amap { display: flex; flex-direction: column; gap: 3px; }
|
||||
#ampler .maprow { display: flex; align-items: center; gap: 5px; }
|
||||
#ampler .mvn { width: 40px; color: #aebfda; font-size: 10px; }
|
||||
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||||
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* right-click context menu — every setting, on everything */
|
||||
#ctxmenu {
|
||||
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
|
||||
@ -427,7 +526,7 @@
|
||||
@keyframes gsTwinkle { from { opacity: 0.55; } to { opacity: 1; } }
|
||||
|
||||
/* ---- the grimoire (manual) ---- */
|
||||
#grimtrigger { position: fixed; top: 76px; left: 36px; z-index: 11; cursor: pointer;
|
||||
#grimtrigger { position: fixed; top: 24px; left: 36px; z-index: 11; cursor: pointer;
|
||||
color: rgba(180,200,235,0.75); font-size: 12px; letter-spacing: 0.5px; user-select: none;
|
||||
border-bottom: 1px dotted rgba(160,185,225,0.4); }
|
||||
#grimtrigger:hover { color: #eaf1ff; }
|
||||
@ -1006,6 +1105,7 @@
|
||||
<li><b>Press <kbd>P</kbd> — performance mode.</b> Everything that is not the world vanishes: the wordmark, the header, the counts, the godtime readout, the ticker, every button and chip — <i>pure visuals</i>, with only a rotating line of dispatches from the world (<i>bitcoin at $62,902 · the moon is 81% lit · mercury is retrograde · M4.2 earthquake</i>) rising for the audience. Put the console on the projector, hit <kbd>P</kbd>, and conduct. (Want even the labels and dispatches gone? <kbd>Z</kbd> is zen — nothing but the sky.)</li>
|
||||
<li><b>Press <kbd>W</kbd> — the sky wheel.</b> The zodiac rises behind the whole instrument, huge and faint, drawn the way a natal chart is drawn: a <b>sign band</b> with the twelve glyphs in their element colours and degree ticks running the rim, the <b>living planets</b> just inside it exactly where they stand tonight, the sky's <b>aspects strung as chords</b> across the wheel — red for the hard angles, green for the soft, gold for the meetings — and <b>the sign the Sun is walking right now lit in its own colour</b> (Cancer in early July), so one glance says where in the year you are. Cast a chart (✨ your stars) and the layer deepens: your <b>whole-sign houses</b> divide the wheel, the ASC and MC stand in gold, your natal planets sit as gold ghosts on the inner ring, and transit chords strike them as the sky moves. The same wheel the zodiac skin draws, now living under the main view. <kbd>W</kbd> again to put it away; it remembers.</li>
|
||||
<li><b>Press <kbd>Z</kbd> — zen.</b> Every word vanishes: labels, readouts, panels, the header, the ticker, the dial — all of it — and only the visuals remain, full screen. The spheres, the cables, the shockwaves, the sky wheel if it's up, still moving to the world; nothing left to read, everything left to watch. <kbd>Z</kbd> or <kbd>esc</kbd> brings the words back.</li>
|
||||
<li><b>🌱 Lifestrument — the gentle door.</b> The same hub, a second instrument: <a href="/life.html" style="color:#9fd0ff">godstrument.pro/life.html</a> plays only the sources about <i>living</i> — your pulse, the weather, the sun going down, a baby born somewhere — as eight one-tap <b>Vibes</b> with two knobs and no cables at all. Made for a phone in one hand and a friend who doesn't want a manual. This console is where you build; that one is where you feel. (Right-click the sky → 🌱 lifestrument.)</li>
|
||||
<li><b>The keyboard is yours — <kbd>F1</kbd>–<kbd>F10</kbd> and beyond.</b> A default set of function keys is ready to fly: <kbd>F1</kbd> the grimoire, <kbd>F2</kbd> play/stop the synth, <kbd>F3</kbd> the skin view, <kbd>F4</kbd> next skin, <kbd>F5</kbd> cycle mood, <kbd>F6</kbd> tracks, <kbd>F7</kbd> performance mode, <kbd>F8</kbd> lock the godtime, <kbd>F9</kbd> the earth echo, <kbd>F10</kbd> the stage. But every key is remappable: right-click the sky → <b>⌨ keys</b> to bind any key to any action, or <b>map a key</b> from a voice's own right-click menu. Want <kbd>a</kbd> and <kbd>s</kbd> to raise and lower a drone's level like a fader? Right-click that voice → <i>map a key → raise its level</i>, press <kbd>a</kbd>; again for <i>lower</i>, press <kbd>s</kbd> — now they nudge it live, hold to sweep. Your whole flow, under your fingers.</li>
|
||||
<li><b>Press <kbd>C</kbd> — chakra view.</b> The sources leave their column and take up their stations on the subtle body: seven wheels on a spine of light, crown to root, each feed orbiting the chakra it belongs to — the sky's machinery at the crown, the omens at the third eye, the species talking at the throat, weather and air at the heart, fire and money at the solar plexus, birth and water at the sacral, quakes and debt and the mortal weight at the root. Each wheel glows with the live average of its members and spins its traditional petals, counter-rotating wheel to wheel. <b>Drag empty space to spin the whole body 360°</b>, drag up/down to tilt, flick and let momentum carry it. Everything still works in there: click a node to mute it, drag it onto a voice to patch, shift-click for its tab. <kbd>C</kbd> again and the sources glide home.</li>
|
||||
<li><b>Right-click anything — the everything menu.</b> Every node carries its full settings under the right button: mute, <b>solo</b>, a <b>level</b> fader (0–2×), a <b>trim</b> offset, every cable it carries with its amount slider and an unpatch ×, and a nested <i>patch to →</i> list for laying new cables without dragging. Right-click the empty sky for the view menu. And the modifiers, for the fast hands: <b>click</b> mutes · <b>⇧-click</b> opens the node's tab · <b>⇧-drag</b> rides the level up and down · <b>⌥-click</b> solos a source · <b>⌘-click</b> multi-selects for grouping · <b>⌘⇧-click</b> resets a node to neutral. People play differently; the instrument shouldn't care which hand you reach with.</li>
|
||||
@ -1017,6 +1117,10 @@
|
||||
<li><b>The Groove — one feel for the whole pattern.</b> The grooves tab carries Ableton's Groove Pool, reduced to the 16-step grid: <b>swing</b> lays the off-beats back, <b>humanize</b> is a touch of random timing so nothing is machine-stiff, <b>velocity</b> lets each hit breathe in loudness, and an <b>amount</b> master scales the lot — with one-click presets (<i>straight, swing 16, MPC 8-4, drunk</i>). Under the roll sit three expression lanes: <b>velocity</b> (how hard each step hits), <b>chance</b> (how likely each step fires at all — drop a step to 40% and it plays four times in ten, so the loop varies itself), and <b>ratchet</b> (how many times a step re-fires, 1–4 — an instant roll or 32nd-note glitch from a single step, and if that step is a Play-One stack each sub-hit re-rolls the note). The feel and the per-step shaping <b>save with your patch</b>. And the <b>texture voices</b> (the filter, the glitch, the reverb, the drive) get their own lane: a <b>⬗ p-lock</b>, the Pocket-Operator move — pin that voice to a blocky value on any step, so the filter jumps to a different cutoff on every hit, disjointed and alive; drag a step below the lane to hand it back to the world. And any lane takes a <b>⬢ Euclidean fill</b> — say how many <i>pulses</i> to spread across the steps and they distribute themselves as evenly as possible (E(3,8) is the tresillo, E(5,8) the cinquillo, E(4,16) four-on-the-floor), with <i>steps</i> and <i>rotate</i> to taste: the oldest rhythms of the world fall straight out of the arithmetic.</li>
|
||||
<li><b>♪ One scale for the whole planet, and ⬛ Play One for the glitch.</b> Set a <b>root and scale</b> at the top of the grooves tab and every melody you draw is in that key — the piano roll is only ever the scale's own notes, so nothing lands wrong; change the scale and your patterns <i>re-harmonise</i> in place. And a column isn't limited to one note: <b>stack several</b> and it turns blue — a <b>Play One</b> group, from which the sequencer picks a single note at random each time it fires. Stack a handful of in-key notes under a fast Euclidean fill and the line glitches itself into something new on every pass, always in tune. (Adapted from Ableton's <i>Group Notes → Play One</i>.)</li>
|
||||
<li><b>⤳ Follow actions — the song composes itself.</b> Give any clip (A/B/C) a <b>follow action</b> in the grooves tab — <i>play again, next, any, any other, stop</i> — with a <b>chance</b>, and once the arrangement is playing that clip can hand off to another at the bar line instead of just repeating. Set two clips to "any other" and they trade the lane back and forth; sprinkle a low-chance divert and the piece drifts. As Ableton's manual puts it, "structures that repeat but can also be surprising… never exactly repeat." A ⤳ marks any clip that can divert. Nothing changes for arrangements without follow actions — they loop exactly as written.</li>
|
||||
<li><b>🥁 The beat — five drums on the godtime clock.</b> Press <kbd>B</kbd> (or the <b>🥁 button</b>) and the beat drawer opens: five lanes — <i>kick, snare, clap, closed hat, open hat</i> — synthesised from oscillators and noise like every other voice, so they ride the world's saturation, crush and reverb too. Click a step to draw; <b>▶</b> starts all five at once. They live on the same godtime clock as the grooves, and the feel — <b>swing, humanize, velocity, amount</b> — is <i>the very same</i> the tracks use (shared, not copied). The same three expression lanes shape each step (<b>velocity</b>, <b>chance</b>, <b>ratchet</b>), and <b>⬢ euclid</b> spreads N pulses across the sixteen. Open the <b>🎛 kit</b> drawer to voice each drum: <i>tune, punch, decay, level, pan</i> — and on the kick, <b>duck</b>: every kick <i>parts the sea</i>, dipping the pads and drone out from under itself so the thump lands in its own air. The beat <b>saves with your patch</b>; a <b>mood never silences it</b> — the world's hands change, but the beat belongs to you; and a beat drawn in <b>zero</b> counts as a build, so leaving zero offers to keep it.</li>
|
||||
<li><b>Keys <kbd>1</kbd>–<kbd>5</kbd> & ⏺ rec — finger-drum the kit.</b> With the beat open the number row <i>is</i> the kit — <kbd>1</kbd> kick, <kbd>2</kbd> snare, <kbd>3</kbd> clap, <kbd>4</kbd> hat, <kbd>5</kbd> open hat — played by hand, live, straight into the world's FX. Hold <b>⇧</b> for the ghost note, softer. Arm <b>⏺ rec</b> and every tap writes itself into the grid, <b>quantized to the nearest sixteenth</b> — tap a hi-hat pattern into a quiet pocket and it's a lane — so you play the beat in rather than clicking it out.</li>
|
||||
<li><b>🌾 AMPLER — there is ample, for god is abundant.</b> The SP-404 move, made of the planet. Right-click the sky → <b>🌾 AMPLER</b>: <b>⏺ capture</b> a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab <i>is</i> the world, that YouTube loop counts), then <b>✂ chop</b> it into 8 or 16 slices. Tap a slice to audition it, <b>⇄</b> to play it backwards, then <b>seat a slice onto a drum voice</b> — and that lane stops synthesising and starts <i>playing the world</i>. The slice rides the voice's own <i>level</i> and <i>pan</i> and, on the kick, its <b>duck</b>; a slice on the open hat still chokes under a closed one — the machine's rules don't care whether a hit is oscillator or sample. Sample anything playing on the machine, chop it, and it's a kit. Session-only for now: the slices don't travel with your patch (yet).</li>
|
||||
<li><b>The beat leaves the building.</b> The drum lanes ride <b>MIDI out on channel 10</b> — General MIDI percussion, <i>kick 36, snare 38, clap 39, closed hat 42, open hat 46</i> — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in <b>export .mid</b>: draw a beat, hit export, and the Standard MIDI File carries a channel-10 drum track (looped across the arrangement's bars) alongside your lead and bass — drag it into Ableton and the planet's rhythm is already programmed.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user