From 6cb355e6b0f003877ddbbca996d74525b7441369 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 13:46:41 +1000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=93=BC=20revelation:=20TESTAMENT=20?= =?UTF-8?q?=E2=80=94=20record=20what=20god=20does=20(Stage=201T)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MediaRecorder tapped off N.lim (the true final mix — post-squash, post-limiter). Lazy dest+recorder built on first roll, 1s timeslices. The #testament ⏺ button beside ♪ turns red and counts m:ss while rolling; on stop it downloads godstrument-testament-. and tickers "it is written". A ctxItem mirrors it near GODSONIQ. testamentStart/Stop/State exposed on the Synth return. Verified: button/timer/ticker/download all fire, blob is valid opus decoding to the right duration, and an oscillator probe proved the record→decode pipeline captures real audio (RMS 0.35). The Synth take is silent only because the headless preview suspends its AudioContext — the tap is correct; on a real machine it records what sounds. Co-Authored-By: Claude Fable 5 --- viz/index.html | 70 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/viz/index.html b/viz/index.html index 13770d9..c5e3992 100644 --- a/viz/index.html +++ b/viz/index.html @@ -378,6 +378,10 @@ #godspeakrec.on { display: flex; animation: gspk-pulse 1.1s ease-in-out infinite; } @keyframes gspk-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } + /* 📼 TESTAMENT — the record button; red + gently pulsing while it bears witness */ + #testament.on { color: #ff8a8a !important; border-color: rgba(240,110,110,0.6) !important; + background: rgba(230,90,90,0.2) !important; animation: gspk-pulse 1.5s ease-in-out infinite; font-variant-numeric: tabular-nums; } + /* right-click context menu — every setting, on everything */ #ctxmenu { position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh; @@ -7797,6 +7801,8 @@ 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.testamentState && Synth.testamentState().on) ? "📼 TESTAMENT — stop & save the take" : "📼 TESTAMENT — record what god does", + () => { if (Synth.testament) Synth.testament(); }); ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed()); ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"), async () => { const r = await Synth.feedTab(); @@ -9202,7 +9208,7 @@ g.connect(pan); pan.connect(drumBus); drums[dv] = { g, pan }; } - return { out, squash, squashMakeup, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp, + return { out, squash, squashMakeup, lim, 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, drumBus, drums, noiseBuf, duckG }; @@ -9626,7 +9632,69 @@ } }); + // ---- 📼 TESTAMENT — record what god does ("it is written") -------------- + // A MediaRecorder tapped off N.lim: the TRUE final mix, post-squash and + // post-limiter — exactly what you hear. Lazy (destination + recorder built + // on first roll), timesliced so a crash loses ≤1s. The GODSONIQ worklet + // Recorder is a different machine for a different purpose — untouched. + let testDest = null, testRec = null, testChunks = [], testT0 = 0, testUiTimer = null; + function testamentStart() { + if (!ctx) start(); // wake the graph — a recording can begin the session + if (testRec) return false; // already rolling + if (!testDest) { testDest = ctx.createMediaStreamDestination(); N.lim.connect(testDest); } + const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"] + .find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)); + if (!mime) return false; // no MediaRecorder / no supported container (old Safari) + testChunks = []; testRec = new MediaRecorder(testDest.stream, { mimeType: mime, audioBitsPerSecond: 256000 }); + testRec.ondataavailable = (e) => { if (e.data && e.data.size) testChunks.push(e.data); }; + testRec.start(1000); // 1s timeslices + testT0 = ctx.currentTime; return true; + } + function testamentStop() { + return new Promise((resolve) => { + if (!testRec) { resolve(null); return; } + const rec = testRec, secs = ctx.currentTime - testT0; + const ext = rec.mimeType.indexOf("mp4") >= 0 ? "m4a" : "webm"; + rec.onstop = () => { const blob = new Blob(testChunks, { type: rec.mimeType }); testChunks = []; resolve({ blob, secs, ext }); }; + testRec = null; rec.stop(); + }); + } + function testamentState() { return { on: !!testRec, secs: testRec ? ctx.currentTime - testT0 : 0 }; } + + const testBtn = document.createElement("div"); + testBtn.id = "testament"; testBtn.textContent = "⏺"; testBtn.title = "TESTAMENT — record the master mix to a file"; + Object.assign(testBtn.style, { position: "fixed", top: "12px", right: "132px", zIndex: 10, + height: "30px", minWidth: "30px", padding: "0 7px", borderRadius: "8px", display: "flex", + alignItems: "center", justifyContent: "center", background: "rgba(20,28,44,0.7)", color: "#cba6a6", + cursor: "pointer", fontSize: "13px", border: "1px solid rgba(120,150,200,0.25)", + backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" }); + if (!AC || !window.MediaRecorder) { testBtn.style.opacity = "0.3"; testBtn.title = "this browser cannot record"; } + document.body.appendChild(testBtn); + const testFmt = (s) => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }; + function testPaint() { const st = testamentState(); testBtn.classList.toggle("on", st.on); testBtn.textContent = st.on ? "⏺ " + testFmt(st.secs) : "⏺"; } + function testamentToggle() { + if (testamentState().on) { + testamentStop().then((r) => { + if (testUiTimer) { clearInterval(testUiTimer); testUiTimer = null; } + testPaint(); + if (!r || !r.blob || !r.blob.size) return; + const d = new Date(), pad = (n) => String(n).padStart(2, "0"); + const stamp = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + "-" + pad(d.getHours()) + pad(d.getMinutes()); + const a = document.createElement("a"); a.href = URL.createObjectURL(r.blob); + a.download = "godstrument-testament-" + stamp + "." + r.ext; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + eventTicker.unshift({ text: "📼 testament: " + testFmt(r.secs) + " — it is written", tAdded: now(), src: "sys" }); + }); + } else { + if (!testamentStart()) { eventTicker.unshift({ text: "📼 this browser cannot bear witness", tAdded: now(), src: "sys" }); return; } + testPaint(); testUiTimer = setInterval(testPaint, 1000); + } + } + testBtn.addEventListener("click", testamentToggle); + return { update(dests) { if (dests) Object.assign(D, dests); }, pluck, + testamentStart, testamentStop, testamentState, testament: testamentToggle, 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; }, From 0ff4259a4fba93a843d172d696897dfa7bb9d897 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 13:58:57 +1000 Subject: [PATCH 2/5] =?UTF-8?q?=E2=98=B8=20revelation:=20WHEELS=20?= =?UTF-8?q?=E2=80=94=20a=20wheel=20within=20a=20wheel=20(Stage=201W)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-lane pattern length (wlen, 1..16, default 16 = full wheel) → polymeter. Named wlen because keyroll already owns seq.len (the per-step note-length array). One monotonic seqAbsStep counter drives wheelStep(sq), which returns seqCurStep for full wheels (bit-identical to pre-WHEELS) and seqAbsStep%wlen for shorter ones. Threaded through seqTick (both branches, one cur for the gate AND len[] lookup), the S&H source sampling, and every applyGroove call. migrateSeq clamps wlen (legacy → 16); self-check gains wlen-default/preserve/clamp + wheel-full-identity. exportMid's drum block is now one absolute loop (a 12-wheel exports as it plays; sa%16 keeps the full wheel byte-identical). UI: a ☸ wheel chip in each beat lane + the arranger euclid row; steps past the wheel dim (.off, pointer-events:none) and the playhead paints at wheelStep. The owned() export gate is intact. Verified: groove self-check green (incl. new assertions), full-wheel export bit-identical over 4 bars, 12-wheel wraps at sa%12, kick-16/hat-12 drift over 48 ticks, beat-panel wheel=12 dims exactly 4 cells, zero errors. Co-Authored-By: Claude Fable 5 --- viz/index.html | 65 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/viz/index.html b/viz/index.html index c5e3992..5bef08b 100644 --- a/viz/index.html +++ b/viz/index.html @@ -234,6 +234,7 @@ .seqcell:hover { background: rgba(140,170,220,0.45); } .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); } + .seqcell.off { opacity: 0.3; pointer-events: none; } /* ☸ WHEELS — a step past the lane's length is outside the wheel */ /* note length — a placed note is an overlay block spanning `len` steps, drag its right edge (the ew-resize handle) to lengthen. Sits over the lit cells; only the handle takes the mouse, so the grid underneath still toggles notes. */ @@ -1643,7 +1644,7 @@ // voice gets an 808-style gate row that rhythmically chops the world's data. // "Quantize in time" — the rhythmic sibling of the matrix's quantize-to-scale. const seqs = {}; // destKey -> {on, steps[16]} (notes: -1|row, CC: 0|1) - let seqPhase = 0, seqCurStep = 0, seqGridRefresh = null; + let seqPhase = 0, seqCurStep = 0, seqAbsStep = 0, seqGridRefresh = null; // seqAbsStep: monotonic 16th counter for ☸ WHEELS polymeter // The Groove — Ableton's Groove Pool (manual §14.1), reduced to a fixed 1/16 // grid: swing delays the off-beats, humanize is the manual's "Random" timing // jitter, velo is per-note Velocity Deviation, amount is the Global Amount @@ -1775,6 +1776,7 @@ rat: new Array(16).fill(1), // per-step ratchet count (1 = single hit) lock: new Array(16).fill(-1), // per-step parameter lock (-1 = unlocked) len: new Array(16).fill(1), // per-step note length, in 16ths (1 = one step) — the piano-roll "key-roll" + wlen: 16, // ☸ WHEELS — the lane's own pattern length (1..16); 16 = a full wheel (default, unchanged) }); } // GODRUM drum lanes — same seq shape as seqFor, but steps are 0/1 gates that fill @@ -1794,8 +1796,13 @@ rat: new Array(16).fill(1), lock: new Array(16).fill(-1), // unused by drums, kept for seq-shape parity (migrateSeq) len: new Array(16).fill(1), // unused by drums (they're one-shots), kept for seq-shape parity + wlen: 16, // ☸ WHEELS — per-lane pattern length; polymeter for the drums }); } + // ☸ WHEELS — "a wheel within a wheel". A lane shorter than 16 wraps on its own + // length against the absolute clock, so kick-16 over hat-12 drift into polymeter. + // A full-length lane returns seqCurStep — bit-identical to pre-WHEELS behaviour. + const wheelStep = (sq) => (sq && sq.wlen && sq.wlen < 16) ? ((seqAbsStep % sq.wlen) + sq.wlen) % sq.wlen : seqCurStep; // ---- 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 @@ -1952,13 +1959,12 @@ const sq = seqs["drum." + v]; if (!sq || !owned(v, 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 - } + for (let sa = 0; sa < arr.bars * 16; sa++) { // ☸ WHEELS — one absolute loop + const s2 = (sq.wlen && sq.wlen < 16) ? sa % sq.wlen : sa % 16; // a 12-wheel exports exactly as it plays + if (!sq.steps[s2]) continue; + const dvel = Math.round(clamp(sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127); + const t0 = sa * 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) { @@ -2183,6 +2189,16 @@ euRow.appendChild(mkEuNum("pulses", () => euclidP.pulses, v => euclidP.pulses = v, 0, 16)); euRow.appendChild(mkEuNum("steps", () => euclidP.len, v => euclidP.len = v, 2, 16)); euRow.appendChild(mkEuNum("rotate", () => euclidP.rot, v => euclidP.rot = v, 0, 15)); + { // ☸ WHEELS — the lane's own pattern length (1..16); shorter than 16 → polymeter + const wsqA = seqFor(arrSelKey, nSel.isNote); + const w = document.createElement("span"); w.className = "gctl"; + const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = "☸ wheel"; + const n = document.createElement("input"); n.type = "number"; n.min = "1"; n.max = "16"; n.className = "eunum"; + n.value = String(wsqA.wlen || 16); n.title = "the lane's own length — 12 over a 16 kick is polymeter"; + n.oninput = () => { ungodlyGesture("wheel"); wsqA.wlen = clamp(Math.round(+n.value || 16), 1, 16); + euclidP.len = wsqA.wlen; paintG(); }; // a fill now spreads within the wheel (steps still overrides) + w.appendChild(l); w.appendChild(n); euRow.appendChild(w); + } const euBtn = document.createElement("button"); euBtn.className = "midibtn"; euBtn.textContent = "fill"; euBtn.onclick = () => { ungodlyMark("euclid"); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); }; euRow.appendChild(euBtn); @@ -2216,10 +2232,12 @@ if (nSel.isNote) { const vsq = seqFor(arrSelKey, true); if (!vsq.len) vsq.len = new Array(16).fill(1); renderNotesG = attachNoteLengths(grid, cells, () => steps, vsq.len, rows, null); } const paintG = () => { + const wsq = seqFor(arrSelKey, nSel.isNote), wl = wsq.wlen || 16, ph = wheelStep(wsq); // ☸ WHEELS for (const c2 of cells) { c2.el.classList.toggle("on", nSel.isNote ? stepHas(steps[c2.stp], c2.rw) : !!steps[c2.stp]); c2.el.classList.toggle("playone", nSel.isNote && stepStack(steps[c2.stp]) && stepHas(steps[c2.stp], c2.rw)); - c2.el.classList.toggle("cur", arr.playing && c2.stp === seqCurStep); + c2.el.classList.toggle("cur", arr.playing && c2.stp === ph); + c2.el.classList.toggle("off", c2.stp >= wl); // dim the steps past the wheel } if (renderNotesG) renderNotesG(); }; @@ -2837,7 +2855,11 @@ 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); + const wl = document.createElement("input"); wl.type = "number"; wl.min = "1"; wl.max = "16"; + wl.className = "eunum"; wl.style.width = "36px"; wl.value = String(sq.wlen || 16); // ☸ WHEELS — this lane's length + wl.title = "☸ wheel — this lane's length (a 12 over a 16 kick is polymeter)"; + wl.oninput = () => { ungodlyGesture("wheel"); sq.wlen = clamp(Math.round(+wl.value || 16), 1, 16); paintGrid(); }; + lead.appendChild(name); lead.appendChild(mute); lead.appendChild(eu); lead.appendChild(wl); lane.appendChild(lead); const steps = document.createElement("div"); steps.className = "bsteps"; const cells = []; @@ -2857,9 +2879,11 @@ const bv = String(Math.round(godtime.bpm)); if (document.activeElement !== bpm && bpm.value !== bv) bpm.value = bv; for (const L of lanes) { L.paintMute(); + const wl2 = L.sq.wlen || 16, ph = wheelStep(L.sq); // ☸ WHEELS — this lane's wrap 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); + L.cells[s].classList.toggle("cur", playing && s === ph); + L.cells[s].classList.toggle("off", s >= wl2); // dim steps past the wheel } } if (paintExpr) paintExpr(); // keep the expression lane's lit-bars in step with the grid @@ -3168,7 +3192,7 @@ const sq = seqs[k]; if (!sq.on) continue; if (k.slice(0, 5) === "drum.") { // a GODRUM lane — fire a synth drum - const cur = seqCurStep; + const cur = wheelStep(sq); // ☸ WHEELS — the lane's own wrap (16 → 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) { @@ -3189,7 +3213,7 @@ } const n = dests.get(k); if (n && n.isNote) { - const cur = seqCurStep, sv = sq.steps[cur]; + const cur = wheelStep(sq), sv = sq.steps[cur]; // ☸ WHEELS — one cur for both the gate and len[] (must not diverge) const hasNote = Array.isArray(sv) ? sv.length > 0 : sv >= 0; const ch = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire? if (hasNote && (ch >= 1 || Math.random() < ch)) { @@ -3263,7 +3287,8 @@ } else if (frozenVals[k] != null) delete frozenVals[k]; const sq = seqs[k]; if (sq && sq.on) { // sample & hold: the world only speaks on your steps - if (sq.steps[seqCurStep] && sq._sampled !== seqCurStep) { sq.held = v; sq._sampled = seqCurStep; } + const cur = wheelStep(sq); // ☸ WHEELS — a short source-wheel samples on its own turn + if (sq.steps[cur] && sq._sampled !== cur) { sq.held = v; sq._sampled = cur; } if (sq.held != null) v = sq.held; } sv[k] = v; @@ -3305,7 +3330,7 @@ if (tk && !muted && !n.isNote) // per-voice output level (⇧-drag / menu) v = clamp(v * tk.gain + tk.offset, 0, 1); const sq = seqs[k]; // groove grid: gate + per-step p-lock in time - if (!n.isNote) v = applyGroove(sq, seqCurStep, v); + if (!n.isNote) v = applyGroove(sq, wheelStep(sq), v); // ☸ WHEELS — the gate wraps on the lane's length destVals[k] = v; n.raw = v; n.muted = muted; n.norm = n.isNote ? clamp((v - 24) / (96 - 24), 0, 1) : clamp(v, 0, 1); @@ -3343,7 +3368,8 @@ chance: Array.isArray(s.chance) && s.chance.length === 16 ? s.chance.slice() : new Array(16).fill(1), rat: Array.isArray(s.rat) && s.rat.length === 16 ? s.rat.slice() : new Array(16).fill(1), lock: Array.isArray(s.lock) && s.lock.length === 16 ? s.lock.slice() : new Array(16).fill(-1), - len: Array.isArray(s.len) && s.len.length === 16 ? s.len.slice() : new Array(16).fill(1) }; + len: Array.isArray(s.len) && s.len.length === 16 ? s.len.slice() : new Array(16).fill(1), + wlen: Number.isFinite(s.wlen) ? clamp(Math.round(s.wlen), 1, 16) : 16 }; // ☸ WHEELS — per-lane length (legacy patches → a full wheel) } function gsGrooveSelfCheck() { // ponytail: one runnable guard on the risky bits const f = []; @@ -3390,6 +3416,11 @@ if (migrateSeq({ steps: new Array(16).fill(0) }).rat[0] !== 1) f.push("ratchet-default"); // rat back-fills to 1 if (migrateSeq({ steps: new Array(16).fill(0) }).len[0] !== 1) f.push("len-default"); // len back-fills to 1 (one 16th) if (migrateSeq({ steps: new Array(16).fill(0), len: new Array(4).fill(3) }).len[0] !== 1) f.push("len-badlen"); // wrong-length len → reset + if (migrateSeq({ steps: new Array(16).fill(0) }).wlen !== 16) f.push("wlen-default"); // ☸ legacy seq → full wheel + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 12 }).wlen !== 12) f.push("wlen-preserve"); // ☸ a 12-wheel round-trips + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 0 }).wlen !== 1) f.push("wlen-clamp-lo"); // ☸ clamps into 1..16 + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 99 }).wlen !== 16) f.push("wlen-clamp-hi"); + if (wheelStep({ wlen: 16 }) !== seqCurStep) f.push("wheel-full-identity"); // ☸ full wheel == pre-WHEELS // parameter locks (applyGroove) if (applyGroove(null, 0, 0.7) !== 0.7) f.push("plock-nogroove"); // no groove → world passes const lsq = { on: true, steps: [1, 0, 1, ...new Array(13).fill(0)], lock: [-1, -1, 0.25, ...new Array(13).fill(-1)] }; @@ -5028,7 +5059,7 @@ } const frac = seqPhase - Math.floor(seqPhase); const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset - if (st16 !== seqCurStep) { seqCurStep = st16; seqTick(); } + if (st16 !== seqCurStep) { seqCurStep = st16; seqAbsStep++; seqTick(); } // seqAbsStep drives WHEELS' per-lane wrap // the arrangement advances one column per bar const barAbs = Math.floor(seqPhase / 16); From cccbce536c0cad35bccb78e4e4354bc8aa215cab Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 14:04:28 +1000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=95=8A=20revelation:=20PSALM=20?= =?UTF-8?q?=E2=80=94=20sing,=20and=20the=20instrument=20answers=20(Stage?= =?UTF-8?q?=202P)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mic pitch → folded to the global scale → played as notes, or (when ⚡ GODSPEED is on) latched into the arp one note at a time. The mic lives in its OWN throwaway AudioContext — it is analysed, never routed into the music graph (zero feedback), and nothing leaves the page. A pure autocorrelation detector psalmDetect(buf,sr)→{freq,clarity,rms} (80–1000 Hz, parabolic interpolation) and a pure note state-machine psalmStep() (2 stable frames to fire, legato on change, 150ms gate close + re-attack, velocity from RMS) — both unit-tested headlessly. A 🕊 chip shows the sung note and brightens on voice; a ctxItem toggles it; mic denial tickers "the instrument cannot hear you". Verified: detector 220/440/554Hz within 0.09% (silence & noise gated), fold lands in-scale, the state machine single-fires / legatos / re-attacks / scales velocity, the ctxItem + mic-denial path work live, zero console errors. Co-Authored-By: Claude Fable 5 --- viz/index.html | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/viz/index.html b/viz/index.html index 5bef08b..6924d07 100644 --- a/viz/index.html +++ b/viz/index.html @@ -383,6 +383,14 @@ #testament.on { color: #ff8a8a !important; border-color: rgba(240,110,110,0.6) !important; background: rgba(230,90,90,0.2) !important; animation: gspk-pulse 1.5s ease-in-out infinite; font-variant-numeric: tabular-nums; } + /* 🕊 PSALM — the listening chip; shows the note you're singing, brightens on voice */ + #psalmchip { position: fixed; bottom: 164px; right: 14px; z-index: 12; display: none; + align-items: center; gap: 4px; min-width: 46px; padding: 6px 12px; border-radius: 8px; font-size: 12px; + background: rgba(20,28,44,0.7); color: #8fa4c8; border: 1px solid rgba(120,150,200,0.25); + cursor: pointer; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); } + #psalmchip.open { display: inline-flex; } + #psalmchip.voiced { color: #cfe4ff; border-color: rgba(150,200,255,0.6); background: rgba(60,95,150,0.5); } + /* right-click context menu — every setting, on everything */ #ctxmenu { position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh; @@ -3180,6 +3188,119 @@ godspeedEl.appendChild(hint); } + // ---- 🕊 PSALM — sing, and the instrument answers ------------------------ + // Mic pitch, folded to the global scale, played as notes — or, when ⚡ GODSPEED + // is on, latched into the arp one note at a time (sing your chord in). PRIVACY: + // analysis is entirely local, in a THROWAWAY AudioContext the music graph never + // touches — zero feedback risk, and nothing ever leaves the page. This block + // only consumes Synth.pluck / arpLatch; it reaches into no engine. + const PSALM_SENS = 0.012; // RMS gate — quieter than this is "silence" + const PSALM_CLARITY = 0.6; // autocorrelation confidence floor + // Pure pitch detector over a Float32Array — unit-testable without a mic. + function psalmDetect(buf, sampleRate) { + const n = buf.length; + let rms = 0; for (let i = 0; i < n; i++) rms += buf[i] * buf[i]; + rms = Math.sqrt(rms / n); + if (rms < 1e-4) return { freq: 0, clarity: 0, rms }; + const minLag = Math.floor(sampleRate / 1000), maxLag = Math.floor(sampleRate / 80); // 80–1000 Hz + let c0 = 0; for (let i = 0; i < n; i++) c0 += buf[i] * buf[i]; + const acAt = (L) => { let a = 0; for (let i = 0; i + L < n; i++) a += buf[i] * buf[i + L]; return a; }; + let bestLag = -1, bestVal = 0; + for (let lag = minLag; lag <= maxLag && lag < n; lag++) { const ac = acAt(lag); if (ac > bestVal) { bestVal = ac; bestLag = lag; } } + if (bestLag < 0) return { freq: 0, clarity: 0, rms }; + const clarity = c0 > 0 ? bestVal / c0 : 0; + let lag = bestLag; // parabolic interpolation for sub-sample accuracy + if (bestLag > minLag && bestLag < maxLag) { + const y0 = acAt(bestLag - 1), y1 = bestVal, y2 = acAt(bestLag + 1), den = (y0 - 2 * y1 + y2); + if (den !== 0) lag = bestLag + 0.5 * (y0 - y2) / den; + } + return { freq: sampleRate / lag, clarity, rms }; + } + const freqToMidi = (f) => 69 + 12 * Math.log2(f / 440); + function psalmFold(midiFloat) { // fold to the nearest note of the global scale + const iv = SCALES[gScale.name] || SCALES.minor_pent; + const pcs = iv.map(i => (((gScale.root + i) % 12) + 12) % 12); + const c = Math.round(midiFloat); let best = c, bestD = 1e9; + for (let k = c - 7; k <= c + 7; k++) { + if (pcs.indexOf(((k % 12) + 12) % 12) < 0) continue; + const d = Math.abs(k - midiFloat); if (d < bestD) { bestD = d; best = k; } + } + return best; + } + const psalm = { on: false, ctx: null, an: null, stream: null, timer: null, + lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0, curName: "" }; + // Pure note-logic state machine (testable): a NEW note fires once after 2 stable + // frames; legato re-fires on a note change; after ~150ms below the gate it closes + // and the same note may re-attack. Velocity from RMS. + function psalmStep(d, dtMs) { + const out = { fire: false, note: -1, vel: 0 }; + const voiced = d.rms >= PSALM_SENS && d.clarity >= PSALM_CLARITY && d.freq > 0; + if (!voiced) { + psalm.silentMs += dtMs; + if (psalm.silentMs >= 150) { psalm.gateOpen = false; psalm.lastNote = -1; psalm.stableNote = -1; psalm.stableCount = 0; } + return out; + } + psalm.silentMs = 0; + const note = psalmFold(freqToMidi(d.freq)); + if (note === psalm.stableNote) psalm.stableCount++; + else { psalm.stableNote = note; psalm.stableCount = 1; } + if (psalm.stableCount < 2) return out; // hold two frames before committing + if (note !== psalm.lastNote) { // a new note, or a legato change + psalm.lastNote = note; psalm.gateOpen = true; + out.fire = true; out.note = note; out.vel = clamp(0.3 + d.rms * 4, 0.3, 1); + } + return out; + } + let psalmChip = null; + function psalmChipEnsure() { + if (psalmChip) return psalmChip; + psalmChip = document.createElement("div"); psalmChip.id = "psalmchip"; + psalmChip.title = "🕊 PSALM — singing; click to stop listening"; + psalmChip.onclick = () => psalmSet(false); + document.body.appendChild(psalmChip); + return psalmChip; + } + function psalmPaint() { + if (!psalmChip) return; + psalmChip.classList.toggle("open", psalm.on); + psalmChip.classList.toggle("voiced", psalm.gateOpen); + psalmChip.textContent = "🕊 " + (psalm.on ? (psalm.curName || "…") : ""); + } + async function psalmSet(v) { + if (v && psalm.on) return; + if (!v) { // stop + fully release the mic + psalm.on = false; + if (psalm.timer) { clearInterval(psalm.timer); psalm.timer = null; } + if (psalm.stream) { psalm.stream.getTracks().forEach(t => t.stop()); psalm.stream = null; } + if (psalm.ctx) { try { psalm.ctx.close(); } catch (e) {} psalm.ctx = null; } + psalm.gateOpen = false; psalm.curName = ""; psalmPaint(); return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }); + const AC = window.AudioContext || window.webkitAudioContext; + const actx = new AC(); // OWN context — mic is analysed only, never routed to the music + const src = actx.createMediaStreamSource(stream); + const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an); + const buf = new Float32Array(an.fftSize), dtMs = 25; + Object.assign(psalm, { on: true, stream, ctx: actx, an, lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0 }); + psalmChipEnsure(); psalmPaint(); + psalm.timer = setInterval(() => { // setInterval, not rAF — a hidden tab shouldn't freeze the ear + an.getFloatTimeDomainData(buf); + const r = psalmStep(psalmDetect(buf, actx.sampleRate), dtMs); + if (r.fire && r.note >= 0) { + psalm.curName = midiToName(r.note); + if (arp.on) arpLatch(r.note, r.vel); else Synth.pluck(r.note, r.vel); + } + psalmPaint(); + }, dtMs); + } catch (e) { + psalm.on = false; + eventTicker.unshift({ text: "🕊 the instrument cannot hear you — allow the microphone", tAdded: now(), src: "sys" }); + psalmPaint(); + } + } + function togglePsalm() { psalmSet(!psalm.on); } + // 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) { @@ -7835,6 +7956,7 @@ ctxItem((Synth.testamentState && Synth.testamentState().on) ? "📼 TESTAMENT — stop & save the take" : "📼 TESTAMENT — record what god does", () => { if (Synth.testament) Synth.testament(); }); ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed()); + ctxItem((psalm.on ? "🕊 PSALM — listening… (click to stop)" : "🕊 PSALM — sing, and the instrument answers"), () => togglePsalm()); 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" From 7e568d8323b1eb6dff22b5648a0bd51195fde324 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 14:13:38 +1000 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A6=20revelation:=20CANON=20=E2=80=94?= =?UTF-8?q?=20factory=20dimensions=20(Stage=202C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four shipped patches on a read-only shelf: ✦ the heartbeat (boom-bap, mpc 8-4, kick duck), ✦ the wheel within the wheel (WHEELS polymeter — kick 16 / hat 12 / clap 10), ✦ the squeeze (GODSQUASH — the market & quakes pump the master), ✦ quiet arrival (gentle earth-echo ambient, no drums). git-tracked in factory/, authored in the client's serializePatch shape. Course-correction from the brief's stale anchor: the template select reads /api/presets (account-based, auth.py), NOT the hub's legacy patchList — so the factory shelf is injected there. list_factory() + load_factory() prepend the ✦ patches for every signed-in player; PUT/DELETE on a ✦ name → 403 (read-only); a missing factory/ degrades to []. test_factory.py (deploy-excluded) validates shape. Verified: HTTP dispatch serves the 4 ✦ names leading the list, loads one, rejects writes (403), user saves still work, traversal/missing → None, zero index.html changes. Taste disclaimer: these are structural drafts — correct shape is the deliverable, beauty is the human's. Co-Authored-By: Claude Fable 5 --- auth.py | 46 ++- factory/quiet_arrival.json | 57 +++ factory/the_heartbeat.json | 479 ++++++++++++++++++++++++ factory/the_squeeze.json | 376 +++++++++++++++++++ factory/the_wheel_within_the_wheel.json | 357 ++++++++++++++++++ test_factory.py | 91 +++++ 6 files changed, 1404 insertions(+), 2 deletions(-) create mode 100644 factory/quiet_arrival.json create mode 100644 factory/the_heartbeat.json create mode 100644 factory/the_squeeze.json create mode 100644 factory/the_wheel_within_the_wheel.json create mode 100644 test_factory.py diff --git a/auth.py b/auth.py index bd685f3..c36999a 100644 --- a/auth.py +++ b/auth.py @@ -30,6 +30,8 @@ import time HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_DB = os.path.join(HERE, "godstrument_users.db") +FACTORY_DIR = os.path.join(HERE, "factory") # ✦ CANON — git-tracked, read-only patches that ship with the instrument +FACTORY_PREFIX = "✦ " # "✦ " — the mark of a factory dimension (also the read-only guard key) EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$") MIN_PW = 8 @@ -403,6 +405,42 @@ def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool: return cur.rowcount > 0 +# ---- ✦ CANON — factory dimensions (git-tracked, read-only, shared by everyone) --- +def list_factory() -> list[dict]: + """The shipped ✦ patches, newest-name-order stable. Silent if factory/ is absent.""" + out = [] + try: + for fn in sorted(os.listdir(FACTORY_DIR)): + if not fn.endswith(".json"): + continue + try: + with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f: + name = (json.load(f).get("name") or "").strip() + if name.startswith(FACTORY_PREFIX): + out.append({"name": name, "updated": 0, "factory": True}) + except (OSError, ValueError): + continue + except OSError: + pass + return out + + +def load_factory(name: str): + """Load a factory patch by its ✦-prefixed name. Guards against path traversal + (the name never touches the filesystem — we match on the JSON's own name field).""" + for fn in os.listdir(FACTORY_DIR) if os.path.isdir(FACTORY_DIR) else []: + if not fn.endswith(".json"): + continue + try: + with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f: + obj = json.load(f) + if (obj.get("name") or "").strip() == name: + return obj.get("data") + except (OSError, ValueError): + continue + return None + + # ---- HTTP dispatch (called by hub.py's request handler) --------------------- COOKIE = "gs_session" @@ -498,13 +536,17 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str, return 404, {"error": "not found"}, None if path == "/api/presets" and method == "GET": - return 200, {"presets": list_presets(uid, db)}, None + # ✦ CANON — the factory shelf first, then the player's own saved dimensions + return 200, {"presets": list_factory() + list_presets(uid, db)}, None if path.startswith("/api/presets/"): name = _unquote(path[len("/api/presets/"):]) + is_factory = name.startswith(FACTORY_PREFIX) if method == "GET": - data = load_preset(uid, name, db) + data = load_factory(name) if is_factory else load_preset(uid, name, db) return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None) + if is_factory: # ✦ factory dimensions are read-only — never write or delete them + return 403, {"error": "factory dimensions are read-only — save it under a new name"}, None if method in ("PUT", "POST"): return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None) if method == "DELETE": diff --git a/factory/quiet_arrival.json b/factory/quiet_arrival.json new file mode 100644 index 0000000..e1f1094 --- /dev/null +++ b/factory/quiet_arrival.json @@ -0,0 +1,57 @@ +{ + "name": "✦ quiet arrival", + "data": { + "routes": [ + { + "source": "astro.moon", + "dest": "echo.wet", + "amount": 0.6, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the moon opens the echo" + }, + { + "source": "weather.wind", + "dest": "echo.time", + "amount": 0.5, + "curve": "scurve", + "gate": null, + "quantize": null, + "label": "the wind whips the tape speed" + }, + { + "source": "sky.elev", + "dest": "pad.brightness", + "amount": 0.7, + "curve": "scurve", + "gate": null, + "quantize": null, + "label": "the sun brightens the pads" + }, + { + "source": "sun.speed", + "dest": "reverb.size", + "amount": 0.5, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the solar wind sizes the room" + } + ], + "tweaks": {}, + "seqs": {}, + "bpm": 64, + "groove": { + "amount": 1, + "preset": "straight", + "swing": 0, + "humanize": 0, + "velo": 0 + }, + "scale": { + "root": 5, + "name": "major" + } + } +} \ No newline at end of file diff --git a/factory/the_heartbeat.json b/factory/the_heartbeat.json new file mode 100644 index 0000000..a96c4ac --- /dev/null +++ b/factory/the_heartbeat.json @@ -0,0 +1,479 @@ +{ + "name": "✦ the heartbeat", + "data": { + "routes": [], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.95, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.7, + 0.8, + 0.8, + 0.8, + 0.75, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.snare": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0 + ], + "vel": [ + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.5 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.ohat": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + } + }, + "bpm": 88, + "groove": { + "amount": 1, + "preset": "mpc 8-4", + "swing": 0.62, + "humanize": 0.12, + "velo": 0.18 + }, + "scale": { + "root": 0, + "name": "minor_pent" + }, + "kit": { + "kick": { + "tune": 0.44, + "punch": 0.6, + "decay": 0.55, + "duck": 0.5, + "level": 0.9, + "pan": 0.5 + } + } + } +} \ No newline at end of file diff --git a/factory/the_squeeze.json b/factory/the_squeeze.json new file mode 100644 index 0000000..51154ef --- /dev/null +++ b/factory/the_squeeze.json @@ -0,0 +1,376 @@ +{ + "name": "✦ the squeeze", + "data": { + "routes": [ + { + "source": "crypto.vel", + "dest": "squash", + "amount": 0.9, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the market pumps the master" + }, + { + "source": "quake.event", + "dest": "squash", + "amount": 0.7, + "curve": "exp3", + "gate": null, + "quantize": null, + "label": "a quake heaves the whole mix" + } + ], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.snare": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + } + }, + "bpm": 120, + "groove": { + "amount": 1, + "preset": "straight", + "swing": 0, + "humanize": 0.03, + "velo": 0.12 + }, + "scale": { + "root": 0, + "name": "minor_pent" + } + } +} \ No newline at end of file diff --git a/factory/the_wheel_within_the_wheel.json b/factory/the_wheel_within_the_wheel.json new file mode 100644 index 0000000..5db84bf --- /dev/null +++ b/factory/the_wheel_within_the_wheel.json @@ -0,0 +1,357 @@ +{ + "name": "✦ the wheel within the wheel", + "data": { + "routes": [], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 12 + }, + "drum.clap": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 10 + } + }, + "bpm": 96, + "groove": { + "amount": 1, + "preset": "swing 16", + "swing": 0.55, + "humanize": 0.05, + "velo": 0.1 + }, + "scale": { + "root": 0, + "name": "minor_pent" + } + } +} \ No newline at end of file diff --git a/test_factory.py b/test_factory.py new file mode 100644 index 0000000..7b91eda --- /dev/null +++ b/test_factory.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""test_factory.py — validate the ✦ CANON factory patches' SHAPE. + +Not a taste check (the human re-voices by ear) — this asserts every factory +patch loads cleanly through the client's migrateSeq expectations: 16-step seq +arrays, wlen in 1..16, sane bpm, valid route/groove/scale shape, and a +✦-prefixed name. Stdlib only; excluded from the deploy by the test_*.py filter. + + python3 test_factory.py +""" +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +FACTORY = os.path.join(HERE, "factory") +SCALES = {"chromatic", "major", "minor", "dorian", "phrygian", "lydian", + "mixolydian", "pentatonic", "minor_pent", "wholetone", "hirajoshi"} +SEQ_ARRAYS = ("steps", "vel", "chance", "rat", "lock", "len") + + +def check_seq(key, s, errs): + for a in SEQ_ARRAYS: + if a in s: + if not isinstance(s[a], list) or len(s[a]) != 16: + errs.append(f"{key}.{a} must be a 16-length array") + if "steps" not in s or not isinstance(s.get("steps"), list) or len(s["steps"]) != 16: + errs.append(f"{key}.steps missing/not-16 (migrateSeq would reject → dropped)") + if "wlen" in s and not (isinstance(s["wlen"], int) and 1 <= s["wlen"] <= 16): + errs.append(f"{key}.wlen must be an int 1..16, got {s.get('wlen')!r}") + if "on" in s and not isinstance(s["on"], (bool, int)): + errs.append(f"{key}.on must be boolean-ish") + + +def check_patch(name, p, errs): + if not isinstance(p.get("bpm"), (int, float)) or not (40 <= p["bpm"] <= 180): + errs.append(f"bpm must be 40..180, got {p.get('bpm')!r}") + for r in p.get("routes", []): + if not r.get("source") or not r.get("dest"): + errs.append("route missing source/dest") + if not isinstance(r.get("amount"), (int, float)): + errs.append(f"route {r.get('source')}→{r.get('dest')} amount not numeric") + for key, s in (p.get("seqs") or {}).items(): + check_seq(key, s, errs) + sc = p.get("scale") or {} + if sc and sc.get("name") not in SCALES: + errs.append(f"scale.name {sc.get('name')!r} not a known scale") + gr = p.get("groove") or {} + for k in ("swing", "humanize", "velo"): + if k in gr and not (0 <= gr[k] <= 1): + errs.append(f"groove.{k} out of 0..1") + + +def main(): + if not os.path.isdir(FACTORY): + print("FAIL: no factory/ dir"); sys.exit(1) + files = sorted(f for f in os.listdir(FACTORY) if f.endswith(".json")) + if len(files) < 4: + print(f"FAIL: expected ≥4 factory patches, found {len(files)}"); sys.exit(1) + total_errs = 0 + names = [] + for fn in files: + with open(os.path.join(FACTORY, fn), encoding="utf-8") as f: + obj = json.load(f) + name, data = obj.get("name", ""), obj.get("data") + errs = [] + if not name.startswith("✦ "): + errs.append(f"name {name!r} must start with '✦ '") + if not isinstance(data, dict): + errs.append("missing 'data' object") + else: + check_patch(name, data, errs) + names.append(name) + if errs: + total_errs += len(errs) + print(f" ✗ {fn}: " + "; ".join(errs)) + else: + nseq = len(data.get("seqs") or {}) + nroute = len(data.get("routes") or []) + print(f" ✓ {name} (bpm {data['bpm']}, {nseq} seqs, {nroute} routes)") + # the four the brief names + for want in ("✦ the heartbeat", "✦ the wheel within the wheel", "✦ the squeeze", "✦ quiet arrival"): + if want not in names: + print(f" ✗ missing canonical patch: {want}"); total_errs += 1 + if total_errs: + print(f"\nFAIL: {total_errs} shape error(s)"); sys.exit(1) + print(f"\nALL {len(files)} FACTORY PATCHES VALID (shape only — beauty is the human's)") + + +if __name__ == "__main__": + main() From f9f0ef52ff354ba7ae67beead0979ef997ea6f1c Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 14:16:34 +1000 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=A6=20revelation:=20=CF=89=20the=20cl?= =?UTF-8?q?oser=20=E2=80=94=20grimoire=20chapter=20+=20manual=20(Stage=203?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four grimoire entries in the house voice, fact-checked against what was verified: 📼 TESTAMENT ("it is written"), ☸ WHEELS (Ezekiel 1:16 — "a wheel in the middle of a wheel"), 🕊 PSALM (the local-listening privacy line — nothing leaves the machine), ✦ CANON (the read-only shelf of worked examples). Manual regenerated (27 sections). Cross-feature QA: all four surfaces coexist with the pantheon and the crossover — TESTAMENT ⏺ button, WHEELS chips in all 5 beat lanes, PSALM/TESTAMENT/GODSPEED ctxItems — zero console errors, groove self-check green. Co-Authored-By: Claude Fable 5 --- viz/index.html | 4 ++++ viz/manual.html | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/viz/index.html b/viz/index.html index 6924d07..52175e3 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1204,6 +1204,10 @@
  • 🫀 GODSQUASH — the master squeeze the world can pump. In OMNI's fx rack sits one new knob: squash, a compressor across the whole master bus. Turn it up and the threshold dives while the ratio climbs together, a matched gain riding underneath so the loudness stays level while the peaks kneel — the mix gets denser, not louder. And because it's a destination like any other, route a feed onto it — the quakes, your heartbeat, the market's velocity — and the planet pumps the mix, breathing the whole instrument in time with the world.
  • 🗣 GODSPEAK — the voice of god. On your own machine (Apple-Silicon only), run run.py --godspeak, then hold Right ⌥ and speak. Your words are heard locally — your own Whisper, nothing leaving the machine — and become commands: "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen", and — the voice of god undoing its own works — "take it back". A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply arrives in the world, printed to the ticker — the word is never an error, only another feed.
  • ⏪ UNGODLY — take it back. ⌘Z undoes; ⌘⇧Z redoes. The instrument remembers your last thirty-two acts — every cable patched, mood applied, groove and scale changed, kit knob turned, beat step drawn, level and mute — and steps back through them, un-doing what god (or you) did. A held drag counts as one act, not fifty. It is memory-only: a reload forgives everything and starts the history clean.
  • +
  • 📼 TESTAMENT — record what god does. Beside the sits a . Press it and the instrument records itself — the true final mix, taken after the squash and the limiter, exactly what you hear. The button turns red and counts the minutes; press it again and a file drops to your disk (godstrument-testament-…webm), and the ticker says it is written. Start it before the beat and it captures the session from its first breath; it needs no setup and no other app — the planet, at last, can bear witness to itself. (Also on the sky menu, near GODSONIQ.)
  • +
  • ☸ WHEELS — a wheel within a wheel. "…their work was as it were a wheel in the middle of a wheel" (Ezekiel 1:16). Each lane keeps its own length: set the ☸ wheel chip in a beat lane (or the arranger's euclid row) to anything from 1 to 16, and a lane shorter than the full sixteen wraps on its own turn. A hat of twelve over a kick of sixteen drift apart and only realign every forty-eight steps — polymeter, the oldest trick for making a loop that never quite repeats. Steps past the wheel dim out; the playhead rides each lane's own length; and the pattern exports to MIDI exactly as it plays, twelve-wheel and all. A full wheel of sixteen behaves precisely as it always did.
  • +
  • 🕊 PSALM — sing, and the instrument answers. Right-click the sky → 🕊 PSALM, allow the microphone, and sing. The pitch is heard, folded to the scale you're in (so you're never out of key), and played as notes — a 🕊 chip shows the note leaving your throat. And the duet with ⚡ GODSPEED: with the arp running, each note you sing latches into the held chord, one at a time — hum four notes and the machine arpeggiates the chord you sang. It listens locally: the analysis happens in the page, in its own silence, and nothing ever leaves the machine — no recording, no upload, just your voice becoming the instrument's.
  • +
  • ✦ CANON — the shelf of worked examples. Open ⚙ → DIMENSIONS and at the top of the list, above your own saved worlds, sit four ✦ factory dimensions that ship with the instrument — the heartbeat (a boom-bap kit with the swing and the kick's duck), the wheel within the wheel (WHEELS, made audible), the squeeze (the market pumping GODSQUASH), and quiet arrival (the earth echo, slow and sparse). Load one to hear a feature in its element, then save your own version under a new name — the ✦ ones are read-only, the same for everyone, a place to start rather than a thing to overwrite.
  • 📽 The stage — send the visual to any screen. Right-click the sky → the stage 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 double-click for fullscreen. The stage has a camera: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → project this node and the camera glides after that one sphere through its orbits, or project its station 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.
  • 🕸 The commune — play together, properly. Right-click the sky → the commune. This is for signed-in players: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you hosts: name the room, then ✉ invite the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and join or decline; anyone may leave whenever they wish, and if the host leaves, the room closes with them. Then the sections: members claim voices (right-click any voice, or the panel's picker), or the host allocates them — this bass to her, those pads to him, the godtime itself 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.
  • diff --git a/viz/manual.html b/viz/manual.html index b69dec3..8eceff0 100644 --- a/viz/manual.html +++ b/viz/manual.html @@ -238,6 +238,7 @@ .seqcell:hover { background: rgba(140,170,220,0.45); } .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); } + .seqcell.off { opacity: 0.3; pointer-events: none; } /* ☸ WHEELS — a step past the lane's length is outside the wheel */ /* note length — a placed note is an overlay block spanning `len` steps, drag its right edge (the ew-resize handle) to lengthen. Sits over the lit cells; only the handle takes the mouse, so the grid underneath still toggles notes. */ @@ -382,6 +383,18 @@ #godspeakrec.on { display: flex; animation: gspk-pulse 1.1s ease-in-out infinite; } @keyframes gspk-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } + /* 📼 TESTAMENT — the record button; red + gently pulsing while it bears witness */ + #testament.on { color: #ff8a8a !important; border-color: rgba(240,110,110,0.6) !important; + background: rgba(230,90,90,0.2) !important; animation: gspk-pulse 1.5s ease-in-out infinite; font-variant-numeric: tabular-nums; } + + /* 🕊 PSALM — the listening chip; shows the note you're singing, brightens on voice */ + #psalmchip { position: fixed; bottom: 164px; right: 14px; z-index: 12; display: none; + align-items: center; gap: 4px; min-width: 46px; padding: 6px 12px; border-radius: 8px; font-size: 12px; + background: rgba(20,28,44,0.7); color: #8fa4c8; border: 1px solid rgba(120,150,200,0.25); + cursor: pointer; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); } + #psalmchip.open { display: inline-flex; } + #psalmchip.voiced { color: #cfe4ff; border-color: rgba(150,200,255,0.6); background: rgba(60,95,150,0.5); } + /* right-click context menu — every setting, on everything */ #ctxmenu { position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh; @@ -1171,6 +1184,10 @@
  • 🫀 GODSQUASH — the master squeeze the world can pump. In OMNI's fx rack sits one new knob: squash, a compressor across the whole master bus. Turn it up and the threshold dives while the ratio climbs together, a matched gain riding underneath so the loudness stays level while the peaks kneel — the mix gets denser, not louder. And because it's a destination like any other, route a feed onto it — the quakes, your heartbeat, the market's velocity — and the planet pumps the mix, breathing the whole instrument in time with the world.
  • 🗣 GODSPEAK — the voice of god. On your own machine (Apple-Silicon only), run run.py --godspeak, then hold Right ⌥ and speak. Your words are heard locally — your own Whisper, nothing leaving the machine — and become commands: "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen", and — the voice of god undoing its own works — "take it back". A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply arrives in the world, printed to the ticker — the word is never an error, only another feed.
  • ⏪ UNGODLY — take it back. ⌘Z undoes; ⌘⇧Z redoes. The instrument remembers your last thirty-two acts — every cable patched, mood applied, groove and scale changed, kit knob turned, beat step drawn, level and mute — and steps back through them, un-doing what god (or you) did. A held drag counts as one act, not fifty. It is memory-only: a reload forgives everything and starts the history clean.
  • +
  • 📼 TESTAMENT — record what god does. Beside the sits a . Press it and the instrument records itself — the true final mix, taken after the squash and the limiter, exactly what you hear. The button turns red and counts the minutes; press it again and a file drops to your disk (godstrument-testament-…webm), and the ticker says it is written. Start it before the beat and it captures the session from its first breath; it needs no setup and no other app — the planet, at last, can bear witness to itself. (Also on the sky menu, near GODSONIQ.)
  • +
  • ☸ WHEELS — a wheel within a wheel. "…their work was as it were a wheel in the middle of a wheel" (Ezekiel 1:16). Each lane keeps its own length: set the ☸ wheel chip in a beat lane (or the arranger's euclid row) to anything from 1 to 16, and a lane shorter than the full sixteen wraps on its own turn. A hat of twelve over a kick of sixteen drift apart and only realign every forty-eight steps — polymeter, the oldest trick for making a loop that never quite repeats. Steps past the wheel dim out; the playhead rides each lane's own length; and the pattern exports to MIDI exactly as it plays, twelve-wheel and all. A full wheel of sixteen behaves precisely as it always did.
  • +
  • 🕊 PSALM — sing, and the instrument answers. Right-click the sky → 🕊 PSALM, allow the microphone, and sing. The pitch is heard, folded to the scale you're in (so you're never out of key), and played as notes — a 🕊 chip shows the note leaving your throat. And the duet with ⚡ GODSPEED: with the arp running, each note you sing latches into the held chord, one at a time — hum four notes and the machine arpeggiates the chord you sang. It listens locally: the analysis happens in the page, in its own silence, and nothing ever leaves the machine — no recording, no upload, just your voice becoming the instrument's.
  • +
  • ✦ CANON — the shelf of worked examples. Open ⚙ → DIMENSIONS and at the top of the list, above your own saved worlds, sit four ✦ factory dimensions that ship with the instrument — the heartbeat (a boom-bap kit with the swing and the kick's duck), the wheel within the wheel (WHEELS, made audible), the squeeze (the market pumping GODSQUASH), and quiet arrival (the earth echo, slow and sparse). Load one to hear a feature in its element, then save your own version under a new name — the ✦ ones are read-only, the same for everyone, a place to start rather than a thing to overwrite.
  • 📽 The stage — send the visual to any screen. Right-click the sky → the stage 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 double-click for fullscreen. The stage has a camera: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → project this node and the camera glides after that one sphere through its orbits, or project its station 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.
  • 🕸 The commune — play together, properly. Right-click the sky → the commune. This is for signed-in players: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you hosts: name the room, then ✉ invite the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and join or decline; anyone may leave whenever they wish, and if the host leaves, the room closes with them. Then the sections: members claim voices (right-click any voice, or the panel's picker), or the host allocates them — this bass to her, those pads to him, the godtime itself 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.