merge: matrix glow with persistence presets
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:
L'électron rare
2026-06-28 18:41:45 +02:00
9 changed files with 681 additions and 3 deletions
@@ -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 m1m6 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.
+12 -2
View File
@@ -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 };
+36
View File
@@ -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 }
+4 -1
View File
@@ -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; }
+62
View File
@@ -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";
+1
View File
@@ -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>
+12
View File
@@ -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;
}
+26
View File
@@ -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);
});