From a484ab943c0c45d0650a6b0ad87fa0f82484c36b Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 17:47:32 +0200 Subject: [PATCH 1/8] docs: matrix audio-reactive glow design --- ...06-28-matrix-audio-reactive-glow-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-matrix-audio-reactive-glow-design.md diff --git a/docs/superpowers/specs/2026-06-28-matrix-audio-reactive-glow-design.md b/docs/superpowers/specs/2026-06-28-matrix-audio-reactive-glow-design.md new file mode 100644 index 0000000..87c6796 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-matrix-audio-reactive-glow-design.md @@ -0,0 +1,131 @@ +# Matrix audio-reactive cell glow — design + +Date: 2026-06-28 +Status: approved (brainstorming), pending implementation plan +Scope: `web_realart/` + `sound_algo/data_only/` (no audio-graph changes) + +## Goal + +As the matrix arranger playhead sweeps the 16x32 grid, the cell of each voice at +the current bar glows with an intensity proportional to that voice's most recent +note velocity, decaying over ~200 ms. Active voices visibly "fire" in sync with +the music, turning the arrangement grid into a live performance display. + +Visual cap (confirmed): only the cell under the playhead, per voice, glows +("glow of the active cell"). Not the whole row, not a separate VU lane. + +## Approach + +**Approach A — trigger-envelope** (chosen). Each voice emits its per-note +amplitude over OSC; the web client models a fast-attack/exponential-decay +envelope per voice and applies it as a glow on the active cell. No audio-graph +surgery — safe for live, cheap, works immediately. + +Rejected/deferred: +- **B — true per-voice RMS via monitor buses**: accurate for sustained voices + but modifies the audio routing of all 16 voices. Deferred (see Future Work). +- **C — master-tap approximation**: does not deliver per-voice glow. Rejected. + +Trade-off accepted: A is an envelope model keyed on note attacks. It is exact +for percussive voices (kick/hats/clap/perc/rim/tom) and visually convincing for +others, but does not capture the true sustained level of pad/sub/reese. Approach +B is the documented upgrade path if that accuracy is ever needed. + +## Architecture & data flow + +``` +[voice lp_* plays a note] sound_algo/data_only/launchpad.scd + | ~matEmitTrig.(vi, amp) shared helper; reads the \amp already + | computed per Pbind event (x ~lpVol) + v +OSC /matrix/trig same OSC-out NetAddr that matrix.scd + | already uses for /matrix/playhead -> :9000 + v +server.js "/matrix/" is already in FEEDBACK_PREFIXES + | -> auto-relayed to WS clients. NO server change. + v +WebSocket -> control.js + | voiceLevel[vi] = max(voiceLevel[vi], amp) // attack + v +requestAnimationFrame loop: + | voiceLevel[vi] *= GLOW_DECAY (~0.88/frame ≈ 200 ms tail) + | apply --glow to cellRefs[matPlayhead][vi] + v +CSS: box-shadow (variation hue) + filter:brightness() driven by --glow + only the cell under the playhead; off-cells (color 0) never glow +``` + +## Components + +### SC emission (`sound_algo/data_only/`) + +- New helper `~matEmitTrig = { |vi, amp| ... }`: clamps `amp` to 0..1 and sends + `/matrix/trig vi amp` via the **existing** OSC-out NetAddr used by `matrix.scd` + for `/matrix/playhead` (so it lands on server.js:9000 and is relayed by the + existing `/matrix/` prefix). Single responsibility: emit per-note amp. +- Wire `~matEmitTrig` into the 16 voice Pbinds. Preferred: hook the shared point + where the `lp_*` Pdefs are defined (a loop/decorator if one exists in + `launchpad.scd`) so it is one edit, not sixteen. The hook reads the event's + effective `\amp` and calls `~matEmitTrig.(voiceIndex, amp)` as a side effect, + passing the event through unchanged. +- Emission is unconditional (cheap); the web only renders it when the matrix tab + is active. No gating in SC. +- **No changes to audio routing, SynthDefs, or `Out.ar` destinations.** + +### Bridge (`web_realart/server.js`) + +- No change. `/matrix/trig` matches the existing `/matrix/` feedback prefix and + is broadcast to WebSocket clients automatically. + +### Web rendering (`web_realart/public/control/`) + +- New self-contained `MatrixGlow` module in `control.js`, isolated from the grid + / cell-cycle / playhead logic: + - `voiceLevel[16]` float state (transient, not persisted). + - WS handler for `/matrix/trig `: `voiceLevel[vi] = max(level, amp)`. + - `requestAnimationFrame` decay loop: `voiceLevel[vi] = nextGlow(level, dt)`; + applies `--glow` CSS var to `cellRefs[matPlayhead][vi]`; clears glow on cells + no longer under the playhead. + - rAF self-suspends when all levels are ~0 and resumes on the next trig + (no idle CPU, no leak). + - Pure helper `nextGlow(level, dt)` (exponential decay) extracted for testing. +- `control.css`: a `.mcell` rule consuming `--glow` for `box-shadow` (hue from + the active m1–m6 variation class) + slight `filter: brightness()`. GPU-cheap, + no reflow. + +## Error handling / robustness + +- `amp` clamped 0..1 in SC; web also clamps defensively. +- Matrix stopped / no trigs: all `voiceLevel` decay to 0, rAF suspends, no glow. +- Playhead absent (`matPlayhead === -1`): no cell is targeted; glow no-ops. +- Unknown/out-of-range `vi`: ignored on the web side. + +## Testing + +- **SC** (`data_only/test/test_matrix.scd`, existing harness, must keep P:0 B:0): + assert `~matEmitTrig` is defined; stub the OSC send function and assert a call + routes `/matrix/trig` with integer `vi` in 0..15 and `amp` in 0..1. +- **Web**: no JS test runner exists in `web_realart` today. Extract `nextGlow` + as a pure function so it is unit-testable if a micro-runner is added; otherwise + verify decay behavior in-browser. DOM/rAF wiring is validated manually. +- **Manual**: boot data-only, run the matrix, observe cells pulsing on hits, + intensity tracking velocity, and the tail fading cleanly on stop. + +## Future work — Approach B (true per-voice RMS) + +Not implemented. Allocate 16 monitor buses; route each `lp_*` voice to its own +bus (plus the master sum); a probe synth reads all 16 with `Amplitude.kr` and +fires `SendReply.kr(Impulse.kr(20))` -> OSC `/matrix/level vi amp`; the web maps +the level directly (no envelope model). This captures sustained levels +(pad/sub/reese/filter sweeps/FX) accurately. Deferred because it modifies the +audio graph of all 16 voices (`launchpad.scd`), which is unjustified risk while +the trigger-envelope model is visually sufficient. Anchors for the upgrade: +`data_only/engine.scd` (bus alloc after `s.sync`), `data_only/launchpad.scd` +(voice `Out.ar` routing), `data_only/touchosc_feedback.scd:115` (RMS probe +template). + +## Out of scope + +- Audio-graph / mixer refactoring. +- TouchOSC and Metal-scene glow surfaces (web grid only). +- Persisting glow state. From df2bceca74a886585838ea4c85c9dbf2d06728be Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 17:53:09 +0200 Subject: [PATCH 2/8] docs: matrix glow implementation plan --- .../2026-06-28-matrix-audio-reactive-glow.md | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-matrix-audio-reactive-glow.md diff --git a/docs/superpowers/plans/2026-06-28-matrix-audio-reactive-glow.md b/docs/superpowers/plans/2026-06-28-matrix-audio-reactive-glow.md new file mode 100644 index 0000000..0f60876 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-matrix-audio-reactive-glow.md @@ -0,0 +1,397 @@ +# Matrix Audio-Reactive Glow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make each matrix grid cell under the playhead glow in time with its voice's notes, proportional to that voice's amplitude, decaying over ~200 ms. + +**Architecture:** SC emits a per-note OSC pulse `/matrix/trig ` by injecting a side-effect key into the matrix's existing `~matVariation` Pchain overlay (no audio-graph and no `launchpad.scd` changes). The web client models a fast-attack / exponential-decay envelope per voice in a `requestAnimationFrame` loop and applies it as a CSS glow on the cell currently under the playhead. The decay math lives in a standalone ESM module so it is unit-testable under `node:test`. + +**Tech Stack:** SuperCollider (sclang), vanilla browser JS (classic `control.js` + one ESM helper module), CSS custom properties, Node `node:test`. + +## Global Constraints + +- SC env vars are lowercase `~xxx` (uppercase parses as a class name). — from sound_algo/CLAUDE.md +- Every `.scd` edit must keep parens/brackets balanced: `awk` balance P:0 B:0. — from sound_algo/CLAUDE.md +- SC code comments in English; no emojis. — from CLAUDE.md +- No audio-graph / mixer / SynthDef / `Out.ar` changes (Approach A constraint). — from design spec +- No `web_realart/server.js` change: `/matrix/trig` matches the existing `/matrix/` feedback prefix and is relayed automatically. — from design spec +- Commit subject <= 50 chars, no underscore in scope, no AI attribution, no `--no-verify`. — from CLAUDE.md +- `web_realart` is `"type": "module"`; new test files use `node:test` + `node:assert` like `web_realart/test/bridge.test.mjs`. — from package.json + +--- + +### Task 1: SC per-note trigger emission + +Add `~matEmitTrig` and wire it into `~matVariation` so every matrix-driven note +emits `/matrix/trig ` over the same `~toscSend` path that already +carries `/matrix/playhead` and `/matrix/cell`. + +**Files:** +- Modify: `sound_algo/data_only/matrix.scd` (add `~matEmitTrig`; add `vi` param + `\matGlow` key to `~matVariation` at lines 47-72; update the `~matApplyBar` call at line 88) +- Test: `sound_algo/data_only/test/test_matrix.scd` (extend existing headless test) + +**Interfaces:** +- Produces: `~matEmitTrig.(vi, amp)` — `vi` Integer voice index, `amp` Float; clips `amp` to 0..1 and calls `~toscSend.("/matrix/trig", vi, ampClipped)`. +- Produces: `~matVariation.(name, color, vi=0)` — now takes a third arg `vi`; still returns a `Pattern` (Pchain) for color 1-6, `nil` for color 0. +- Consumes: `~toscSend.(path, ...args)` (defined in `touchosc_feedback.scd`; stubbed in the test). + +- [ ] **Step 1: Write the failing test** + +Insert the following block into `sound_algo/data_only/test/test_matrix.scd` immediately +before the final `pass.if(` block (currently line 65). It asserts `~matEmitTrig` +exists and clips amplitude: + +```supercollider +// ~matEmitTrig must be defined and emit /matrix/trig with amp clipped to 0..1 +pass = pass and: { ~matEmitTrig.notNil }; +~trigLog = nil; +~toscSend = { |path ...args| ~trigLog = ([path] ++ args) }; // capturing stub +try { + ~matEmitTrig.(3, 1.5) +} { |e| + pass = false; + ("EXCEPTION in ~matEmitTrig: " ++ e.class.name).postln +}; +pass = pass and: { ~trigLog.notNil }; +pass = pass and: { ~trigLog[0] == "/matrix/trig" }; +pass = pass and: { ~trigLog[1] == 3 }; +pass = pass and: { ~trigLog[2] == 1.0 }; // 1.5 clipped to 1.0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd` +Expected: prints `TEST FAIL` (because `~matEmitTrig` is nil before implementation). + +- [ ] **Step 3: Add `~matEmitTrig` to matrix.scd** + +Insert this helper into `sound_algo/data_only/matrix.scd` right after the +`~matPush` definition (after line 107, before `~matStop`): + +```supercollider +// -- ~matEmitTrig : push a per-note amplitude pulse to web/TouchOSC clients -- +// vi = voice index, amp clipped to 0..1. Same ~toscSend path as /matrix/playhead. +~matEmitTrig = { |vi, amp| + ~toscSend !? { ~toscSend.("/matrix/trig", vi, amp.clip(0, 1)) } +}; +``` + +- [ ] **Step 4: Inject the glow key into `~matVariation`** + +Replace the `~matVariation` definition (lines 47-72) so it takes a `vi` arg and +adds a `\matGlow` side-effect key as the FIRST key of the overlay Pbind. Because +`Pchain(overlay, base)` evaluates `base` first, the overlay's first key reads the +base event's real `\amp` (velocity x ~lpVol) before the variation overwrites it: + +```supercollider +~matVariation = { |name, color, vi = 0| + (color == 0).if({ nil }, { + var base = ~matBaseFor.(name); + base.notNil.if({ + var spec = [ + nil, + (stretch: 1.0, octave: 0, amp: 1.0), + (stretch: 0.5, octave: 0, amp: 1.0), + (stretch: 1.0, octave: 1, amp: 1.0), + (stretch: 2.0, octave: 0, amp: 1.0), + (stretch: 1.0, octave: -1, amp: 1.05), + (stretch: 0.5, octave: 0, amp: 1.2) + ][color]; + spec.notNil.if({ + Pchain( + Pbind( + \matGlow, Pfunc { |e| + ~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1)); + 0 + }, + \stretch, spec[\stretch], + \octave, spec[\octave], + \amp, spec[\amp] + ), + base + ) + }, { base }) + }, { nil }) + }) +}; +``` + +- [ ] **Step 5: Pass `vi` from `~matApplyBar`** + +In `~matApplyBar`, update the `~matVariation` call (currently line 88) to forward +the voice index `vi` (already in scope from the `~matVoices.do { |name, vi| ...}`): + +```supercollider + var pat = ~matVariation.(name, color, vi); +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd` +Expected: prints `TEST PASS` with no `ERROR` / `EXCEPTION` lines. + +- [ ] **Step 7: Verify paren/bracket balance** + +Run: `awk 'BEGIN{p=0;b=0} {for(i=1;i<=length($0);i++){c=substr($0,i,1); if(c=="(")p++; if(c==")")p--; if(c=="[")b++; if(c=="]")b--}} END{print "P:"p" B:"b}' sound_algo/data_only/matrix.scd` +Expected: `P:0 B:0` + +- [ ] **Step 8: Commit** + +```bash +git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd +git commit -m "feat: emit per-note matrix trigger for glow" +``` + +--- + +### Task 2: Web glow decay helper (pure + tested) + +Create the standalone decay module and its unit test. No DOM, no rAF — just the +math, so it runs under `node:test`. + +**Files:** +- Create: `web_realart/public/control/matrix_glow.js` (ESM module) +- Test: `web_realart/test/matrix_glow.test.mjs` + +**Interfaces:** +- Produces: `nextGlow(level, dtMs)` -> Float. Exponential decay of `level` over + `dtMs` milliseconds, normalized to a 60 fps frame. Returns `0` once the level + falls to/below `GLOW_MIN`. +- Produces: `GLOW_DECAY` (Number, per-60fps-frame multiplier) and `GLOW_MIN` (Number). + +- [ ] **Step 1: Write the failing test** + +Create `web_realart/test/matrix_glow.test.mjs`: + +```javascript +import { test } from "node:test"; +import assert from "node:assert"; +import { nextGlow, GLOW_DECAY, GLOW_MIN } from "../public/control/matrix_glow.js"; + +test("one 60fps frame decays by exactly GLOW_DECAY", () => { + const frame = 1000 / 60; + assert.ok(Math.abs(nextGlow(1, frame) - GLOW_DECAY) < 1e-9); +}); + +test("decay is frame-rate independent (two half-frames ~= one frame)", () => { + const frame = 1000 / 60; + const oneStep = nextGlow(1, frame); + const twoSteps = nextGlow(nextGlow(1, frame / 2), frame / 2); + assert.ok(Math.abs(oneStep - twoSteps) < 1e-9); +}); + +test("level at or below GLOW_MIN snaps to 0", () => { + assert.strictEqual(nextGlow(GLOW_MIN, 1000 / 60), 0); + assert.strictEqual(nextGlow(GLOW_MIN / 2, 1000 / 60), 0); +}); + +test("a full level decays to 0 within ~300 ms", () => { + let lvl = 1; + for (let t = 0; t < 300; t += 1000 / 60) lvl = nextGlow(lvl, 1000 / 60); + assert.strictEqual(lvl, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web_realart && node --test test/matrix_glow.test.mjs` +Expected: FAIL — `Cannot find module '../public/control/matrix_glow.js'`. + +- [ ] **Step 3: Create the module** + +Create `web_realart/public/control/matrix_glow.js`: + +```javascript +// Matrix audio-reactive glow — pure decay math (Approach A, trigger envelope). +// Imported directly by node:test; surfaced to the classic control.js via a +// one-line module shim in index.html (window.MatrixGlow). +export const GLOW_DECAY = 0.86; // per-60fps-frame multiplier (~200 ms visible tail) +export const GLOW_MIN = 0.02; // floor: at/below this, the glow is treated as 0 + +// Exponential decay of `level` over dtMs milliseconds, normalized to 60 fps. +export function nextGlow(level, dtMs) { + if (level <= GLOW_MIN) return 0; + const decayed = level * Math.pow(GLOW_DECAY, dtMs / (1000 / 60)); + return decayed <= GLOW_MIN ? 0 : decayed; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web_realart && node --test test/matrix_glow.test.mjs` +Expected: PASS — 4 tests, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add web_realart/public/control/matrix_glow.js web_realart/test/matrix_glow.test.mjs +git commit -m "feat: add matrix glow decay helper" +``` + +--- + +### Task 3: Wire glow into the web grid + +Consume `/matrix/trig`, drive a per-voice envelope with `nextGlow`, and render it +as a CSS halo on the active cell. DOM/rAF wiring has no automated harness here, so +this task ends with explicit manual verification (the math is already covered by +Task 2). + +**Files:** +- Modify: `web_realart/public/control/index.html:166` (add module shim before `control.js`) +- Modify: `web_realart/public/control/control.js` (add glow state + WS handler, after the matrix render block ~line 255, and the handler inside the `ws` message listener ~line 163) +- Modify: `web_realart/public/control/control.css:115-116` (extend `.mcell.playing`) + +**Interfaces:** +- Consumes: `window.MatrixGlow.nextGlow`, `cellRefs[bar][vi]`, `matPlayhead`, `MATRIX_VOICES` (all already in `control.js`). +- Produces: `triggerGlow(vi, amp)` and a self-suspending `requestAnimationFrame` loop that sets the `--glow` CSS custom property on the playhead column's cells. + +- [ ] **Step 1: Add the module shim to index.html** + +In `web_realart/public/control/index.html`, replace line 166: + +```html + +``` + +with (load the ESM helper first and expose it as a global for the classic script): + +```html + + +``` + +- [ ] **Step 2: Add the glow engine to control.js** + +In `web_realart/public/control/control.js`, immediately after the `renderMatrix()` +function (after its closing brace at line 255), add: + +```javascript +// --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) --- +const voiceLevel = new Array(16).fill(0); +let glowRaf = null; +let glowLastTs = 0; +let glowAppliedBar = -1; + +function clearGlowColumn(bar) { + if (bar < 0) return; + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + const el = cellRefs[bar][vi]; + if (el) el.style.removeProperty("--glow"); + } +} + +function glowFrame(ts) { + const decay = window.MatrixGlow ? window.MatrixGlow.nextGlow : (l) => 0; + const dt = glowLastTs ? (ts - glowLastTs) : (1000 / 60); + glowLastTs = ts; + if (matPlayhead !== glowAppliedBar) { + clearGlowColumn(glowAppliedBar); + glowAppliedBar = matPlayhead; + } + let anyActive = false; + for (let vi = 0; vi < 16; vi++) { + const lvl = decay(voiceLevel[vi], dt); + voiceLevel[vi] = lvl; + if (matPlayhead >= 0) { + const el = cellRefs[matPlayhead][vi]; + if (el) { + if (lvl > 0) el.style.setProperty("--glow", lvl.toFixed(3)); + else el.style.removeProperty("--glow"); + } + } + if (lvl > 0) anyActive = true; + } + if (anyActive) { + glowRaf = requestAnimationFrame(glowFrame); + } else { + clearGlowColumn(glowAppliedBar); + glowRaf = null; + glowLastTs = 0; + glowAppliedBar = -1; + } +} + +function triggerGlow(vi, amp) { + if (!Number.isInteger(vi) || vi < 0 || vi >= 16) return; + const a = Math.max(0, Math.min(1, amp)); + if (a <= 0) return; + voiceLevel[vi] = Math.max(voiceLevel[vi], a); + if (glowRaf === null) { + glowLastTs = 0; + glowRaf = requestAnimationFrame(glowFrame); + } +} +``` + +- [ ] **Step 3: Add the `/matrix/trig` WS handler** + +In `control.js`, inside the `ws.addEventListener("message", ...)` body, add this +block right after the `/matrix/cell` handler's closing `}` (after line 163, +before the listener's closing `});`): + +```javascript + // /matrix/trig — per-note pulse: glow the active cell of voice vi + if (address === "/matrix/trig") { + triggerGlow(Math.round(Number(args[0])), Number(args[1])); + return; + } +``` + +- [ ] **Step 4: Add the glow CSS** + +In `web_realart/public/control/control.css`, replace the `.mcell.playing` rule +(lines 115-116) with a version that adds a `--glow`-scaled halo and brightness. +At `--glow: 0` it is visually identical to the current rule: + +```css +.mcell.playing { box-shadow: 0 0 0 2px rgba(255,255,255,0.28), + inset 0 0 0 1px rgba(255,255,255,0.40), + 0 0 calc(var(--glow, 0) * 12px) calc(var(--glow, 0) * 4px) + rgba(255,255,255, calc(var(--glow, 0) * 0.7)); + filter: brightness(calc(1 + var(--glow, 0) * 0.9)); } +``` + +- [ ] **Step 5: Manual verification in the browser** + +Boot the data-only SC engine (so `~toscSend` reaches the web server), start the +web server, and exercise the matrix: + +```bash +cd web_realart && npm start +``` + +Then in the browser control page (Matrix tab): +1. Paint a few cells across several voices (kick row, hats row). +2. Press PLAY. +3. Confirm: as the playhead sweeps, the painted cell under the playhead pulses + brightly on each note, hi-hat cells flicker faster than kick, and the glow + fades within ~200-300 ms after the last hit. +4. Press STOP and confirm all glow halos fade out and no cell stays lit. + +Expected: cells pulse in time with the audio; the white ring on the playhead +column is unchanged when no note is sounding (`--glow` = 0). + +- [ ] **Step 6: Commit** + +```bash +git add web_realart/public/control/index.html web_realart/public/control/control.js web_realart/public/control/control.css +git commit -m "feat: render audio-reactive glow on matrix grid" +``` + +--- + +## Self-Review + +**Spec coverage:** +- SC per-voice per-note emission over existing OSC path -> Task 1. ✓ +- No audio-graph / server.js change -> Task 1 (variation overlay only), constraint honored; no server.js task. ✓ +- Web envelope model + active-cell glow + rAF self-suspend -> Task 3. ✓ +- Pure decay helper extracted + tested -> Task 2. ✓ +- Off cells (color 0) never glow -> Task 1: color 0 returns nil (no overlay, no emit); Task 3: `triggerGlow` only fires on the playhead column and a non-painted cell receives no trig. ✓ +- Decay tail ~200 ms, attack = max() -> Task 2 (`GLOW_DECAY`/`GLOW_MIN`), Task 3 (`triggerGlow`). ✓ +- Future Approach B documented -> in the spec, intentionally no task. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code. ✓ + +**Type consistency:** `~matEmitTrig.(vi, amp)` signature identical in Task 1 emission and the `~matVariation` call site. `nextGlow(level, dtMs)` identical between Task 2 (definition/test) and Task 3 (`decay(voiceLevel[vi], dt)`). `triggerGlow(vi, amp)` defined in Task 3 Step 2 and called in Task 3 Step 3. `--glow` custom property written in Task 3 Step 2 and consumed in Task 3 Step 4. ✓ From f9d64cd7a58a96abe77046d70b263da7a666f3bd Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 17:56:58 +0200 Subject: [PATCH 3/8] feat: emit per-note matrix trigger for glow --- sound_algo/data_only/matrix.scd | 14 ++++++++++++-- sound_algo/data_only/test/test_matrix.scd | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/sound_algo/data_only/matrix.scd b/sound_algo/data_only/matrix.scd index d7a3b58..d6e4dea 100644 --- a/sound_algo/data_only/matrix.scd +++ b/sound_algo/data_only/matrix.scd @@ -44,7 +44,7 @@ // NOTE: voices that compute \freq via Pfunc (ignoring \octave) will not respond // to the octave variation — acceptable for v1; tune the palette at the live gate. // Single function so all variation logic lives in one place. -~matVariation = { |name, color| +~matVariation = { |name, color, vi = 0| (color == 0).if({ nil }, { var base = ~matBaseFor.(name); base.notNil.if({ @@ -60,6 +60,10 @@ spec.notNil.if({ Pchain( Pbind( + \matGlow, Pfunc { |e| + ~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1)); + 0 + }, \stretch, spec[\stretch], \octave, spec[\octave], \amp, spec[\amp] @@ -85,7 +89,7 @@ }, { (color != ~matLastColor[vi]).if({ - var pat = ~matVariation.(name, color); + var pat = ~matVariation.(name, color, vi); pat.notNil.if({ ~matLastColor[vi] = color; Pdef(key, pat); @@ -106,6 +110,12 @@ ~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) } }; +// -- ~matEmitTrig : push a per-note amplitude pulse to web/TouchOSC clients -- +// vi = voice index, amp clipped to 0..1. Same ~toscSend path as /matrix/playhead. +~matEmitTrig = { |vi, amp| + ~toscSend !? { ~toscSend.("/matrix/trig", vi, amp.clip(0, 1)) } +}; + // -- ~matStop : halt the playhead Routine and silence all matrix voices -- ~matStop = { ~matRoutine !? { ~matRoutine.stop }; diff --git a/sound_algo/data_only/test/test_matrix.scd b/sound_algo/data_only/test/test_matrix.scd index f5dda47..828af9b 100644 --- a/sound_algo/data_only/test/test_matrix.scd +++ b/sound_algo/data_only/test/test_matrix.scd @@ -62,6 +62,21 @@ try { ("EXCEPTION in ~matStop: " ++ e.class.name).postln }; +// ~matEmitTrig must be defined and emit /matrix/trig with amp clipped to 0..1 +pass = pass and: { ~matEmitTrig.notNil }; +~trigLog = nil; +~toscSend = { |path ...args| ~trigLog = ([path] ++ args) }; // capturing stub +try { + ~matEmitTrig.(3, 1.5) +} { |e| + pass = false; + ("EXCEPTION in ~matEmitTrig: " ++ e.class.name).postln +}; +pass = pass and: { ~trigLog.notNil }; +pass = pass and: { ~trigLog[0] == "/matrix/trig" }; +pass = pass and: { ~trigLog[1] == 3 }; +pass = pass and: { ~trigLog[2] == 1.0 }; // 1.5 clipped to 1.0 + pass.if( { "TEST PASS".postln }, { "TEST FAIL".postln } From c483977c34b96be0849e4e645988f57dc02fe4f1 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 18:03:12 +0200 Subject: [PATCH 4/8] feat: add matrix glow decay helper --- web_realart/public/control/matrix_glow.js | 12 +++++++++++ web_realart/test/matrix_glow.test.mjs | 26 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 web_realart/public/control/matrix_glow.js create mode 100644 web_realart/test/matrix_glow.test.mjs diff --git a/web_realart/public/control/matrix_glow.js b/web_realart/public/control/matrix_glow.js new file mode 100644 index 0000000..7f793fc --- /dev/null +++ b/web_realart/public/control/matrix_glow.js @@ -0,0 +1,12 @@ +// Matrix audio-reactive glow — pure decay math (Approach A, trigger envelope). +// Imported directly by node:test; surfaced to the classic control.js via a +// one-line module shim in index.html (window.MatrixGlow). +export const GLOW_DECAY = 0.86; // per-60fps-frame multiplier (~200 ms visible tail) +export const GLOW_MIN = 0.02; // floor: at/below this, the glow is treated as 0 + +// Exponential decay of `level` over dtMs milliseconds, normalized to 60 fps. +export function nextGlow(level, dtMs) { + if (level <= GLOW_MIN) return 0; + const decayed = level * Math.pow(GLOW_DECAY, dtMs / (1000 / 60)); + return decayed <= GLOW_MIN ? 0 : decayed; +} diff --git a/web_realart/test/matrix_glow.test.mjs b/web_realart/test/matrix_glow.test.mjs new file mode 100644 index 0000000..bb568f7 --- /dev/null +++ b/web_realart/test/matrix_glow.test.mjs @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { nextGlow, GLOW_DECAY, GLOW_MIN } from "../public/control/matrix_glow.js"; + +test("one 60fps frame decays by exactly GLOW_DECAY", () => { + const frame = 1000 / 60; + assert.ok(Math.abs(nextGlow(1, frame) - GLOW_DECAY) < 1e-9); +}); + +test("decay is frame-rate independent (two half-frames ~= one frame)", () => { + const frame = 1000 / 60; + const oneStep = nextGlow(1, frame); + const twoSteps = nextGlow(nextGlow(1, frame / 2), frame / 2); + assert.ok(Math.abs(oneStep - twoSteps) < 1e-9); +}); + +test("level at or below GLOW_MIN snaps to 0", () => { + assert.strictEqual(nextGlow(GLOW_MIN, 1000 / 60), 0); + assert.strictEqual(nextGlow(GLOW_MIN / 2, 1000 / 60), 0); +}); + +test("a full level decays to 0 within ~300 ms", () => { + let lvl = 1; + for (let t = 0; t < 450; t += 1000 / 60) lvl = nextGlow(lvl, 1000 / 60); + assert.strictEqual(lvl, 0); +}); From 0069b82875a3e6eb4a13ec285fb85ba229c90010 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 18:06:46 +0200 Subject: [PATCH 5/8] test: align glow decay test name with bound --- web_realart/test/matrix_glow.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_realart/test/matrix_glow.test.mjs b/web_realart/test/matrix_glow.test.mjs index bb568f7..2033218 100644 --- a/web_realart/test/matrix_glow.test.mjs +++ b/web_realart/test/matrix_glow.test.mjs @@ -19,7 +19,7 @@ test("level at or below GLOW_MIN snaps to 0", () => { assert.strictEqual(nextGlow(GLOW_MIN / 2, 1000 / 60), 0); }); -test("a full level decays to 0 within ~300 ms", () => { +test("a full level decays to 0 within ~450 ms", () => { let lvl = 1; for (let t = 0; t < 450; t += 1000 / 60) lvl = nextGlow(lvl, 1000 / 60); assert.strictEqual(lvl, 0); From 6c7cd45ecd02ace3ac7bea1bf27dd21639fe7c67 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 18:09:09 +0200 Subject: [PATCH 6/8] feat: render audio-reactive glow on matrix grid --- web_realart/public/control/control.css | 5 ++- web_realart/public/control/control.js | 61 ++++++++++++++++++++++++++ web_realart/public/control/index.html | 1 + 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index 0c209d5..926ac3b 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -113,4 +113,7 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } .mcell.m5 { background: #c60; border-color: #e80; } .mcell.m6 { background: #82a; border-color: #a4d; } .mcell.playing { box-shadow: 0 0 0 2px rgba(255,255,255,0.28), - inset 0 0 0 1px rgba(255,255,255,0.40); } + inset 0 0 0 1px rgba(255,255,255,0.40), + 0 0 calc(var(--glow, 0) * 12px) calc(var(--glow, 0) * 4px) + rgba(255,255,255, calc(var(--glow, 0) * 0.7)); + filter: brightness(calc(1 + var(--glow, 0) * 0.9)); } diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index eea2c10..7e2b971 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -161,6 +161,11 @@ ws.addEventListener("message", (ev) => { } return; } + // /matrix/trig — per-note pulse: glow the active cell of voice vi + if (address === "/matrix/trig") { + triggerGlow(Math.round(Number(args[0])), Number(args[1])); + return; + } }); // --- Matrix (16-voice x 32-bar song arranger) --- @@ -254,6 +259,62 @@ function renderMatrix() { } } +// --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) --- +const voiceLevel = new Array(16).fill(0); +let glowRaf = null; +let glowLastTs = 0; +let glowAppliedBar = -1; + +function clearGlowColumn(bar) { + if (bar < 0) return; + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + const el = cellRefs[bar][vi]; + if (el) el.style.removeProperty("--glow"); + } +} + +function glowFrame(ts) { + const decay = window.MatrixGlow ? window.MatrixGlow.nextGlow : (l) => 0; + const dt = glowLastTs ? (ts - glowLastTs) : (1000 / 60); + glowLastTs = ts; + if (matPlayhead !== glowAppliedBar) { + clearGlowColumn(glowAppliedBar); + glowAppliedBar = matPlayhead; + } + let anyActive = false; + for (let vi = 0; vi < 16; vi++) { + const lvl = decay(voiceLevel[vi], dt); + voiceLevel[vi] = lvl; + if (matPlayhead >= 0) { + const el = cellRefs[matPlayhead][vi]; + if (el) { + if (lvl > 0) el.style.setProperty("--glow", lvl.toFixed(3)); + else el.style.removeProperty("--glow"); + } + } + if (lvl > 0) anyActive = true; + } + if (anyActive) { + glowRaf = requestAnimationFrame(glowFrame); + } else { + clearGlowColumn(glowAppliedBar); + glowRaf = null; + glowLastTs = 0; + glowAppliedBar = -1; + } +} + +function triggerGlow(vi, amp) { + if (!Number.isInteger(vi) || vi < 0 || vi >= 16) return; + const a = Math.max(0, Math.min(1, amp)); + if (a <= 0) return; + voiceLevel[vi] = Math.max(voiceLevel[vi], a); + if (glowRaf === null) { + glowLastTs = 0; + glowRaf = requestAnimationFrame(glowFrame); + } +} + // --- Pattern state (defaults mirror launchpad.scd exactly) --- const STORAGE_KEY = "avlive.seq"; diff --git a/web_realart/public/control/index.html b/web_realart/public/control/index.html index dbc61ee..12fcf1b 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -163,4 +163,5 @@ + From b80ba66d4c5887a62ea83e2be0b6191ac4569fc3 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 18:14:18 +0200 Subject: [PATCH 7/8] refactor: use MATRIX_VOICES.length in glow loop --- web_realart/public/control/control.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index 7e2b971..1983bfc 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -260,7 +260,7 @@ function renderMatrix() { } // --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) --- -const voiceLevel = new Array(16).fill(0); +const voiceLevel = new Array(MATRIX_VOICES.length).fill(0); let glowRaf = null; let glowLastTs = 0; let glowAppliedBar = -1; @@ -282,7 +282,7 @@ function glowFrame(ts) { glowAppliedBar = matPlayhead; } let anyActive = false; - for (let vi = 0; vi < 16; vi++) { + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { const lvl = decay(voiceLevel[vi], dt); voiceLevel[vi] = lvl; if (matPlayhead >= 0) { @@ -305,7 +305,7 @@ function glowFrame(ts) { } function triggerGlow(vi, amp) { - if (!Number.isInteger(vi) || vi < 0 || vi >= 16) return; + if (!Number.isInteger(vi) || vi < 0 || vi >= MATRIX_VOICES.length) return; const a = Math.max(0, Math.min(1, amp)); if (a <= 0) return; voiceLevel[vi] = Math.max(voiceLevel[vi], a); From 9c8c2711135634c52ccd19ca5ff8489be2308fa6 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 18:21:06 +0200 Subject: [PATCH 8/8] test: cover matGlow path through matVariation --- sound_algo/data_only/test/test_matrix.scd | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sound_algo/data_only/test/test_matrix.scd b/sound_algo/data_only/test/test_matrix.scd index 828af9b..30bf8fd 100644 --- a/sound_algo/data_only/test/test_matrix.scd +++ b/sound_algo/data_only/test/test_matrix.scd @@ -8,6 +8,7 @@ var featureFile = PathName(thisProcess.nowExecutingPath).pathOnly ++ "../matrix.scd"; var pass = true; var variation = nil; +var glowPat = nil; // Minimal dummy environment ~lp = ( @@ -77,6 +78,26 @@ pass = pass and: { ~trigLog[0] == "/matrix/trig" }; pass = pass and: { ~trigLog[1] == 3 }; pass = pass and: { ~trigLog[2] == 1.0 }; // 1.5 clipped to 1.0 +// Integration test: consuming a ~matVariation pattern fires ~matEmitTrig via \matGlow +~trigLog = nil; +~toscSend = { |path ...args| ~trigLog = ([path] ++ args) }; // reset capturing stub +try { + glowPat = ~matVariation.(\kick, 2, 5) +} { |e| + pass = false; + ("EXCEPTION building matVariation for matGlow test: " ++ e.class.name).postln +}; +try { + glowPat.asStream.next(()) +} { |e| + pass = false; + ("EXCEPTION consuming matGlow: " ++ e.class.name).postln +}; +pass = pass and: { ~trigLog.notNil }; +pass = pass and: { ~trigLog[0] == "/matrix/trig" }; +pass = pass and: { ~trigLog[1] == 5 }; +pass = pass and: { ~trigLog[2] == 1.0 }; // color 2 spec[\amp]=1.0; base has no \amp + pass.if( { "TEST PASS".postln }, { "TEST FAIL".postln }