merge: matrix glow with persistence presets
CI build oscope-of / build-check (push) Has been cancelled
CI build oscope-of / build-check (push) Has been cancelled
Reconcile two parallel matrix features built since 3ce4c38:
- macm1 main: matrix audio-reactive glow (/matrix/trig, triggerGlow,
matEmitTrig, --glow CSS var, matrix_glow.js).
- gitea main: matrix file persistence + 14 morceau-inspired presets
(/matrix/save|load|list, web save/load UI).
Conflict resolution kept BOTH features:
- control.js: /matrix/list + /matrix/grid handlers AND /matrix/trig.
- control.css: glow-enhanced .mcell.playing AND persistence-bar styles.
- test_matrix.scd: persistence round-trip AND matEmitTrig/matGlow tests.
Verified: SC headless test_matrix TEST PASS (both suites), matrix.scd
P:0 B:0, node --check control.js OK.
This commit is contained in:
@@ -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 <vi> <amp>` 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 <vi> <amp>` 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
|
||||
<script src="control.js"></script></body></html>
|
||||
```
|
||||
|
||||
with (load the ESM helper first and expose it as a global for the classic script):
|
||||
|
||||
```html
|
||||
<script type="module">import * as G from "./matrix_glow.js"; window.MatrixGlow = G;</script>
|
||||
<script src="control.js"></script></body></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 <vi> <amp> — 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. ✓
|
||||
@@ -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 <vi:i> <amp:f> 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 <vi> <amp>`: `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.
|
||||
@@ -50,7 +50,7 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
|
||||
// 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({
|
||||
@@ -66,6 +66,10 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
|
||||
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]
|
||||
@@ -91,7 +95,7 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
|
||||
},
|
||||
{
|
||||
(color != ~matLastColor[vi]).if({
|
||||
var pat = ~matVariation.(name, color);
|
||||
var pat = ~matVariation.(name, color, vi);
|
||||
pat.notNil.if({
|
||||
~matLastColor[vi] = color;
|
||||
Pdef(key, pat);
|
||||
@@ -112,6 +116,12 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
|
||||
~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 };
|
||||
|
||||
@@ -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 = (
|
||||
@@ -105,6 +106,41 @@ pass = pass and: {
|
||||
|
||||
File.delete(~matDir +/+ "test_grid.matrix");
|
||||
|
||||
// ~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
|
||||
|
||||
// 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 }
|
||||
|
||||
@@ -113,7 +113,10 @@ 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)); }
|
||||
|
||||
/* --- Matrix persistence bar --- */
|
||||
.matrix-persist { display: flex; align-items: center; gap: 6px; margin: 8px 0; flex-wrap: wrap; }
|
||||
|
||||
@@ -192,6 +192,12 @@ ws.addEventListener("message", (ev) => {
|
||||
renderMatrix();
|
||||
return;
|
||||
}
|
||||
|
||||
// /matrix/trig <vi> <amp> — 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) ---
|
||||
@@ -285,6 +291,62 @@ function renderMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) ---
|
||||
const voiceLevel = new Array(MATRIX_VOICES.length).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 < MATRIX_VOICES.length; 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 >= MATRIX_VOICES.length) 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";
|
||||
|
||||
|
||||
@@ -170,4 +170,5 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script type="module">import * as G from "./matrix_glow.js"; window.MatrixGlow = G;</script>
|
||||
<script src="control.js"></script></body></html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ~450 ms", () => {
|
||||
let lvl = 1;
|
||||
for (let t = 0; t < 450; t += 1000 / 60) lvl = nextGlow(lvl, 1000 / 60);
|
||||
assert.strictEqual(lvl, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user