docs: complete per-instrument editor plan

This commit is contained in:
clement
2026-06-28 22:24:58 +02:00
parent 8a3e7fa5cb
commit 3eb6426f61
@@ -0,0 +1,687 @@
# Matrix Complete Per-Instrument Editor 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:** Clicking a matrix voice opens one complete editor modal (Instrument · Colors · Sequence · Assignments · Poses); add a per-color 16-step note+velocity sequencer that replaces the base pattern, and pose→action bindings.
**Architecture:** Extend `~matColorDefs[vi][color]` with a `steps` field (16 × `(degree,vel)`|rest); when non-empty `~matVariation` builds a dedicated 16-step Pbind (replacing the base+overlay path), else the current path runs. Add `~matPoseBindings` driven by a `/matrix/pose` OSC contract. Web consolidates the per-row instrument/mod controls into a tabbed modal; new Sequence and Poses tabs; SC pushes editor state via fixed-stride `/matrix/steps` and `/matrix/poses` messages (the existing stride-6 `/matrix/colordefs` is unchanged).
**Tech Stack:** SuperCollider (sclang), vanilla browser JS (classic control.js), CSS, Node `node:test`.
## Anchoring note (HEAD 8a3e7fa)
All line numbers below reference HEAD `8a3e7fa`; locate by surrounding code if shifted.
Current engine (librarian-verified):
- `~matVariation` (matrix.scd:205-223) reads `spec = ~matColorDefs[vi][color]`, resolves
inst, builds `cdPairs` (cutoff/pan gated by `~matModTargets`), calls
`~matVariationOverlay.(name, vi, spec, volOf, instPair, cdPairs, base)`.
- `~matVariationOverlay` (164-195): `Pchain(Pbind(*([\matGlow, \stretch, \amp] ++ instPair
++ cdPairs ++ freqPair ++ modPairs)), base)`; `freqRatio = 2 ** (spec[\octave] ? 0)`.
- `~lpNote.(deg, oct)` (launchpad.scd:146) → MIDI note; use `~lpNote.(deg,0).midicps` for `\freq`.
- `~matColorDefs[vi][color]` Event fields: `stretch, octave, amp, inst, cutoff, pan`.
- Load allow-list (matrix.scd:511) hardcodes those 6 fields — MUST extend for `steps`.
## Global Constraints
- SC env vars lowercase `~xxx`; comments English; no emojis; `.scd` awk balance P:0 B:0. — CLAUDE.md
- Backward-compat: a color with an EMPTY steps array (all rests) falls back to the base
Pdef (existing behavior); `.matrix` files without `steps`/`poseBindings` load clean. — spec
- Step notes are scale DEGREES via `~lpNote` (relative to harmony); 16 steps = 16th notes
over one bar (`\dur = ~matBeatsPerBar / 16`); velocity scales amp. — spec
- The stepped Pbind keeps `\matGlow` FIRST and still applies inst/cutoff/pan/mod and the
`freqRatio = 2**octave` multiply. — spec + current overlay
- Pose RECOGNITION is out of scope — this consumes `/matrix/pose <poseId>` only. — spec
- No `server.js` change; no audio-graph/SynthDef change. — spec
- Commit subject <= 50 chars, no underscore in scope, no AI attribution. — CLAUDE.md
- `web_realart` is `"type":"module"`; tests use `node:test`. — package.json
---
### Task 1: SC step state + CRUD + push
**Files:** Modify `sound_algo/data_only/matrix.scd`; Test `sound_algo/data_only/test/test_matrix.scd`
**Interfaces:**
- Produces: each `~matColorDefs[vi][color]` gains `steps` (Array of 16, each `nil` or
`(degree: Int, vel: Float)`). `~matStepsEmpty.(steps)` → Bool. `~matSetStep.(vi, color,
stepIdx, degree, vel)` (degree `\rest`/nil clears). `~matStepsPush.(vi, color)` sends
`/matrix/steps vi color <32 vals>` (16 × `[degree, vel]`, rest encoded `degree=-99, vel=0`).
- [ ] **Step 1: Write failing tests** (append before final `pass.if(`)
```supercollider
// --- Step sequencer state (Task 1) ---
pass = pass and: { ~matStepsEmpty.notNil and: { ~matSetStep.notNil and: { ~matStepsPush.notNil } } };
// default: every color's steps is a 16-slot all-nil array -> empty
pass = pass and: { ~matColorDefs[5][2][\steps].size == 16 };
pass = pass and: { ~matStepsEmpty.(~matColorDefs[5][2][\steps]) };
// set a step, then not empty; step holds degree+vel
~trigLog = nil;
~toscSend = { |path ...args| ~trigLog = ([path] ++ args) };
~matSetStep.(5, 2, 3, 7, 0.8);
pass = pass and: { ~matColorDefs[5][2][\steps][3][\degree] == 7 };
pass = pass and: { ~matColorDefs[5][2][\steps][3][\vel] == 0.8 };
pass = pass and: { ~matStepsEmpty.(~matColorDefs[5][2][\steps]).not };
pass = pass and: { ~trigLog[0] == "/matrix/steps" and: { ~trigLog[1] == 5 } };
pass = pass and: { ~trigLog.size == 36 }; // path + vi + color + 32 vals
// rest clears the step
~matSetStep.(5, 2, 3, \rest, 0);
pass = pass and: { ~matColorDefs[5][2][\steps][3].isNil };
pass = pass and: { ~matStepsEmpty.(~matColorDefs[5][2][\steps]) };
```
- [ ] **Step 2: Run to verify failure** — `TEST FAIL` (`~matStepsEmpty` undefined).
- [ ] **Step 3: Add `steps` to the default color defs**
In `~matDefaultColorDefs` (matrix.scd:40-48), add a `steps:` field (a fresh 16-nil array)
to each of the 6 color Events. Example for color 1 (do the same for 2-6):
```supercollider
(stretch: 1.0, octave: 0, amp: 1.0, inst: nil, cutoff: nil, pan: nil, steps: Array.fill(16, nil)),
```
(Each color gets its own `Array.fill(16, nil)`.)
- [ ] **Step 4: Add helpers + OSCdef**
Add near the other color-def helpers:
```supercollider
// -- ~matStepsEmpty : true when no step has a note (all rests) --
~matStepsEmpty = { |steps| steps.isNil or: { steps.every({ |s| s.isNil }) } };
// -- ~matSetStep : write one step (degree \rest/nil clears), re-source, echo --
~matSetStep = { |vi, color, stepIdx, degree, vel|
((vi >= 0 and: { vi < ~matVoices.size }) and: { color >= 1 and: { color <= 6 } }
and: { stepIdx >= 0 and: { stepIdx < 16 } }).if({
var steps = ~matColorDefs[vi][color][\steps];
((degree == \rest) or: { degree.isNil }).if({
steps[stepIdx] = nil
}, {
steps[stepIdx] = (degree: degree.asInteger, vel: vel.asFloat.clip(0, 1))
});
~matLastColor[vi] = -1;
(~lp[\matPlaying] and: { ~matApplyBar.notNil }).if({ ~matApplyBar.(~lp[\matBar]) });
~matStepsPush.(vi, color)
})
};
// -- ~matStepsPush : send one color's 16 steps (degree, vel); rest -> (-99, 0) --
~matStepsPush = { |vi, color|
~toscSend !? {
var flat = ~matColorDefs[vi][color][\steps].collect({ |s|
s.isNil.if({ [-99, 0] }, { [s[\degree], s[\vel]] })
}).flatten;
~toscSend.valueArray(["/matrix/steps", vi, color] ++ flat)
}
};
OSCdef(\mat_step, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matSetStep.((msg[1] ? 0).asInteger, (msg[2] ? 1).asInteger, (msg[3] ? 0).asInteger,
msg[4], (msg[5] ? 0))
}, '/matrix/step');
OSCdef(\mat_steps_get, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
(1..6).do { |c| ~matStepsPush.((msg[1] ? 0).asInteger, c) }
}, '/matrix/steps/get');
```
Note: `/matrix/step` arg `degree` (msg[4]) may arrive as the float `-99` (web sends rest as
`-99`); `~matSetStep` treats `\rest`/nil as clear — so the web sends the symbol-less rest by
sending degree `-99`; handle it: in the OSCdef, map `msg[4] == -99` to `\rest`. Update the
OSCdef body to: `var deg = ((msg[4] ? -99) == -99).if({ \rest }, { msg[4] });` then pass `deg`.
- [ ] **Step 5: Run tests + balance** → `TEST PASS`, `P:0 B:0` (awk on matrix.scd).
- [ ] **Step 6: Commit**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
git commit -m "feat: matrix per-color step state and push"
```
---
### Task 2: Sequence engine — stepped Pbind replaces base
**Files:** Modify `sound_algo/data_only/matrix.scd` (`~matVariation`); Test `test_matrix.scd`
**Interfaces:**
- Produces: `~matColorStepPattern.(name, vi, color, spec, volOf, instPair, cdPairs)` → a
Pbind that plays `spec[\steps]` as 16th notes. `~matVariation` calls it when
`~matStepsEmpty.(spec[\steps])` is false; else the existing overlay path.
- [ ] **Step 1: Write failing tests**
```supercollider
// --- Sequence engine (Task 2) ---
pass = pass and: { ~matColorStepPattern.notNil };
// empty steps -> base path unchanged (parity): consuming yields the base-equivalent event
~matVoices.size.do { |i| ~matMod[i] = nil };
~matColorDefs[5][2][\steps] = Array.fill(16, nil);
~sv = ~matVariation.(\acid, 2, 5).asStream.next(());
pass = pass and: { ~sv[\stretch] == 0.5 }; // color 2 stretch, base path
// non-empty steps -> stepped pattern; first non-rest step degree shows up
~matColorDefs[5][2][\steps][0] = (degree: 4, vel: 0.9);
~sv2 = ~matVariation.(\acid, 2, 5).asStream.next(());
pass = pass and: { ~sv2[\degree] == 4 }; // step 0 degree
pass = pass and: { ~sv2[\dur] == (~matBeatsPerBar / 16) };
~matColorDefs[5][2][\steps] = Array.fill(16, nil); // restore empty
```
- [ ] **Step 2: Run to verify failure** — `TEST FAIL` (`~matColorStepPattern` undefined).
- [ ] **Step 3: Add `~matColorStepPattern`**
Add just before `~matVariation`:
```supercollider
// -- ~matColorStepPattern : 16-step note+vel sequence (replaces base) --
// \matGlow first; \degree/\type from steps; \freq via ~lpNote x 2**octave; \amp =
// colorAmp x stepVel x volOf; \dur = bar/16; inst/cutoff/pan/mod applied like the overlay.
~matColorStepPattern = { |name, vi, color, spec, volOf, instPair, cdPairs|
var steps = spec[\steps];
var freqRatio = 2 ** (spec[\octave] ? 0);
var mod = ~matMod[vi];
var modPairs = mod.isNil.if({ [] }, {
var src = mod[\source], tgt = mod[\target], d = mod[\depth];
(tgt == \cutoff).if({
var c0 = ~matModNeutralCut[name] ? 1000;
[\cutoff, Pfunc { var s = ~matModSourceVal.(src); c0 * (s.linexp(0,1,200,6000)/c0).pow(d) }]
}, {
(tgt == \pan).if({ [\pan, Pfunc { (~matModSourceVal.(src) * 2 - 1) * d }] },
{ [] }) // amp mod handled in the \amp Pfunc below
})
});
Pbind(*([
\matGlow, Pfunc { |e| ~matEmitTrig.(vi, (e[\amp] ? 0).clip(0, 1)); 0 },
\stepIdx, Pseq((0..15), inf),
\type, Pfunc { |e| steps[e[\stepIdx]].isNil.if({ \rest }, { \note }) },
\degree, Pfunc { |e| var s = steps[e[\stepIdx]]; s.isNil.if({ 0 }, { s[\degree] }) },
\freq, Pfunc { |e| ~lpNote.(e[\degree], 0).midicps * freqRatio },
\amp, Pfunc { |e|
var s = steps[e[\stepIdx]];
((spec[\amp] ? 1.0) * (s.isNil.if({ 0 }, { s[\vel] }))) * volOf.value },
\stretch, spec[\stretch],
\dur, ~matBeatsPerBar / 16
] ++ instPair ++ cdPairs ++ modPairs))
};
```
(Note: when `mod[\target] == \amp` the amp Pfunc above does not include the mod factor —
amp modulation of a stepped sequence is out of scope for v1; cutoff/pan mod still apply.)
- [ ] **Step 4: Branch `~matVariation` on non-empty steps**
In `~matVariation` (matrix.scd:213-220), after `var spec = ~matColorDefs[vi][color];` and the
`spec.notNil.if({` block computes `inst`/`volOf`/`instPair`/`cdPairs`, replace the final
`~matVariationOverlay.(...)` call with a steps branch:
```supercollider
(~matStepsEmpty.(spec[\steps]).not).if({
~matColorStepPattern.(name, vi, color, spec, volOf, instPair, cdPairs)
}, {
~matVariationOverlay.(name, vi, spec, volOf, instPair, cdPairs, base)
})
```
- [ ] **Step 5: Run tests + balance** → `TEST PASS`, `P:0 B:0`.
- [ ] **Step 6: Commit**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
git commit -m "feat: matrix per-color step sequence engine"
```
---
### Task 3: Persist steps (extend the load allow-list)
**Files:** Modify `sound_algo/data_only/matrix.scd` (`~matLoadFile`); Test `test_matrix.scd`
**Interfaces:** `.matrix` colorDefs Events now carry `steps`; load restores them.
- [ ] **Step 1: Write failing tests**
```supercollider
// --- Step persistence (Task 3) ---
~matColorDefs[4][3][\steps][2] = (degree: 5, vel: 0.7);
~matSave.("step grid");
~matColorDefs = Array.fill(~matVoices.size, { ~matDefaultColorDefs.value });
pass = pass and: { ~matLoad.("step grid") };
pass = pass and: { ~matColorDefs[4][3][\steps][2][\degree] == 5 };
pass = pass and: { ~matColorDefs[4][3][\steps][2][\vel] == 0.7 };
File.delete(~matDir +/+ "step_grid.matrix");
// legacy grid-only file -> default empty steps
File.use(~matDir +/+ "sleg.matrix", "w", { |f|
f.write(Array.fill(16, { Array.fill(32, 0) }).asCompileString) });
~matColorDefs[4][3][\steps][2] = (degree: 9, vel: 0.5);
pass = pass and: { ~matLoad.("sleg") };
pass = pass and: { ~matStepsEmpty.(~matColorDefs[4][3][\steps]) };
File.delete(~matDir +/+ "sleg.matrix");
```
- [ ] **Step 2: Run to verify failure** — `TEST FAIL` (steps not restored).
- [ ] **Step 3: Extend the load allow-list**
`~matSave` already serializes `steps` (it's a field of the colorDef Event that
`asCompileString` persists — no save change needed). In `~matLoadFile`'s colorDefs restore
loop (matrix.scd:511), the field-copy list hardcodes 6 scalar fields. Add `steps` with a
value-aware copy (it is an array, copy it directly; default stays an empty 16-array):
```supercollider
[\stretch, \octave, \amp, \inst, \cutoff, \pan].do { |f|
d[f].notNil.if({ ~matColorDefs[vi][ci + 1][f] = d[f] })
};
(d[\steps].notNil and: { d[\steps].isArray }).if({
~matColorDefs[vi][ci + 1][\steps] = d[\steps]
});
```
- [ ] **Step 4: Run tests + balance** → `TEST PASS`, `P:0 B:0`.
- [ ] **Step 5: Commit**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
git commit -m "feat: persist matrix step sequences"
```
---
### Task 4: SC pose bindings + /matrix/pose
**Files:** Modify `sound_algo/data_only/matrix.scd`; Test `test_matrix.scd`
**Interfaces:**
- Produces: `~matPoseBindings` (Array of 16, each a list of `(poseId: Sym, action: Sym,
color: Int)`). `~matSetPoseBind.(vi, poseId, action, color)` (empty poseId clears that
voice's bindings). `~matPosesPush.(vi)` sends `/matrix/poses vi <n> <poseId,action,color>*`.
`OSCdef(\mat_pose)` on `/matrix/pose <poseId>` runs matching bindings: `\trigger` re-sources
the voice to `color` now; `\gate` toggles the voice's Pdef.
- [ ] **Step 1: Write failing tests**
```supercollider
// --- Pose bindings (Task 4) ---
pass = pass and: { ~matPoseBindings.notNil and: { ~matSetPoseBind.notNil } };
pass = pass and: { ~matPoseBindings.size == 16 };
~trigLog = nil;
~toscSend = { |path ...args| ~trigLog = ([path] ++ args) };
~matSetPoseBind.(5, \poseA, \trigger, 3);
pass = pass and: { ~matPoseBindings[5].size == 1 };
pass = pass and: { ~matPoseBindings[5][0][\poseId] == \poseA };
pass = pass and: { ~trigLog[0] == "/matrix/poses" and: { ~trigLog[1] == 5 } };
// firing an unknown pose must not raise
try { ~matPoseFire.(\nope) } { |e| pass = false; ("EXCEPTION matPoseFire: " ++ e.class.name).postln };
// clear
~matSetPoseBind.(5, \poseA, \trigger, 3); // same id again toggles/replaces
~matPoseBindings.size.do { |i| ~matPoseBindings[i] = [] };
```
- [ ] **Step 2: Run to verify failure** — `TEST FAIL`.
- [ ] **Step 3: Add state + helpers + OSCdef**
```supercollider
~matPoseBindings = ~matPoseBindings ? Array.fill(~matVoices.size, { [] });
// -- ~matSetPoseBind : add/replace a (poseId, action, color) for a voice; empty clears --
~matSetPoseBind = { |vi, poseId, action, color|
(vi >= 0 and: { vi < ~matVoices.size }).if({
var pid = poseId.asSymbol;
((pid == '') or: { pid == \none }).if({
~matPoseBindings[vi] = []
}, {
// replace any existing binding for this poseId, else add
~matPoseBindings[vi] = ~matPoseBindings[vi].reject({ |b| b[\poseId] == pid })
++ [ (poseId: pid, action: action.asSymbol, color: color.asInteger.clip(1, 6)) ];
});
~matPosesPush.(vi)
})
};
// -- ~matPosesPush : send a voice's pose bindings --
~matPosesPush = { |vi|
~toscSend !? {
var flat = ~matPoseBindings[vi].collect({ |b|
[b[\poseId].asString, b[\action].asString, b[\color]] }).flatten;
~toscSend.valueArray(["/matrix/poses", vi, ~matPoseBindings[vi].size] ++ flat)
}
};
// -- ~matPoseFire : run every binding matching poseId across all voices --
~matPoseFire = { |poseId|
var pid = poseId.asSymbol;
~matVoices.do { |name, vi|
var key = ("lp_" ++ name).asSymbol;
~matPoseBindings[vi].do { |b|
(b[\poseId] == pid).if({
(b[\action] == \trigger).if({
Pdef.all.includesKey(key).if({
var pat = ~matVariation.(name, b[\color], vi);
pat.notNil.if({ Pdef(key, pat); Pdef(key).play(~lp[\clock] ? TempoClock.default, quant: 1) })
})
});
(b[\action] == \gate).if({
Pdef.all.includesKey(key).if({ Pdef(key).isPlaying.if({ Pdef(key).stop }, { Pdef(key).play(~lp[\clock] ? TempoClock.default, quant: 1) }) })
});
})
}
}
};
OSCdef(\mat_posebind, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matSetPoseBind.((msg[1] ? 0).asInteger, (msg[2] ? \none).asSymbol,
(msg[3] ? \trigger).asSymbol, (msg[4] ? 1).asInteger)
}, '/matrix/posebind');
OSCdef(\mat_pose, { |msg, time, addr|
~matPoseFire.((msg[1] ? \none).asSymbol)
}, '/matrix/pose');
OSCdef(\mat_poses_get, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matPosesPush.((msg[1] ? 0).asInteger)
}, '/matrix/poses/get');
```
- [ ] **Step 4: Persist pose bindings** — extend `~matSave` payload and `~matLoadFile`:
In `~matSave` payload Event add:
```supercollider
poseBindings: ~matPoseBindings
```
In `~matLoadFile`, after the colorDefs restore block, add:
```supercollider
~matPoseBindings = Array.fill(~matVoices.size, { [] });
((raw.isArray.not) and: { raw[\poseBindings].notNil }).if({
raw[\poseBindings].do { |list, vi|
(list.isArray and: { vi < ~matVoices.size }).if({ ~matPoseBindings[vi] = list })
}
});
~matVoices.size.do { |vi| ~matPosesPush.(vi) };
```
Add a persistence assertion to the test: set a binding, save, clear, load, assert restored.
- [ ] **Step 5: Run tests + balance** → `TEST PASS`, `P:0 B:0`.
- [ ] **Step 6: Commit**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
git commit -m "feat: matrix pose bindings and fire"
```
---
### Task 5: Web — consolidate row controls into a tabbed modal
**Files:** Modify `web_realart/public/control/index.html` (modal markup), `control.js`
(`renderMatrix`, modal, WS handlers), `control.css`.
**Interfaces:** Consumes `/matrix/instrument`, `/matrix/mod`, `/matrix/colordefs`,
`/matrix/audition` (existing). Produces a tabbed `#colordef-modal` with tab buttons and tab
panes; `renderMatrix` rows lose `.minst` and `.mmod` (and the `.minst-head`/`.mmod-head`
header spacers); the `.minst-edit` "..." button stays as the modal trigger. Mod + instrument
controls now live in the modal; WS echo handlers target modal elements.
- [ ] **Step 1: Make the modal tabbed in index.html**
Replace the `#colordef-modal` block (index.html:180-187) with:
```html
<div id="colordef-modal" class="cd-modal" hidden>
<div class="cd-panel">
<div class="cd-head"><span id="cd-title">voice</span>
<button id="cd-audition" class="cd-aud">AUDITION</button>
<button id="cd-close" class="cd-close">X</button></div>
<div class="cd-tabs">
<button class="cd-tab on" data-tab="inst">Instrument</button>
<button class="cd-tab" data-tab="colors">Colors</button>
<button class="cd-tab" data-tab="seq">Sequence</button>
<button class="cd-tab" data-tab="assign">Assign</button>
<button class="cd-tab" data-tab="poses">Poses</button>
</div>
<div id="cd-pane-inst" class="cd-pane"></div>
<div id="cd-pane-colors" class="cd-pane" hidden></div>
<div id="cd-pane-seq" class="cd-pane" hidden></div>
<div id="cd-pane-assign" class="cd-pane" hidden></div>
<div id="cd-pane-poses" class="cd-pane" hidden></div>
</div>
</div>
```
- [ ] **Step 2: Remove `.minst` + `.mmod` from `renderMatrix`**
In `control.js` `renderMatrix` (344-444): delete the `.minst` select block (380-394) and the
`.mmod` span block (426-441). KEEP the `.minst-edit` button (395-398). In the header-row
construction, delete the `.minst-head` (355-357) and `.mmod-head` (367-369) spacer appends;
KEEP `.minst-edit-head` (358-360).
- [ ] **Step 3: Build the Instrument, Colors, Assign panes**
Add a `renderInstEdit(vi)` that fills the panes. The Colors pane reuses the existing
per-color rows logic (move the body of `renderColorDefRows` to fill `#cd-pane-colors`).
Instrument pane = the curated select; Assign pane = the source/target/depth controls.
```javascript
function renderInstEdit(vi) {
// Instrument pane
const ip = document.getElementById("cd-pane-inst"); ip.innerHTML = "";
const sel = document.createElement("select"); sel.id = "cd-inst-sel";
[["default","default"]].concat((MATRIX_INST_CHOICES[MATRIX_VOICES[vi]]||[]).map(s=>[s,s]))
.forEach(([v,t])=>{const o=document.createElement("option");o.value=v;o.textContent=t;sel.appendChild(o);});
sel.value = matInst[vi] || "default";
sel.addEventListener("change", ()=>{ matInst[vi]=sel.value; saveMatState(); send("/matrix/instrument", vi, sel.value); });
ip.appendChild(sel);
// Colors pane: reuse renderColorDefRows but target #cd-pane-colors
renderColorDefRows(vi);
// Assign pane
const ap = document.getElementById("cd-pane-assign"); ap.innerHTML = "";
const src = document.createElement("select");
MATRIX_MOD_SOURCES.forEach(s=>{const o=document.createElement("option");o.value=s;o.textContent=s;src.appendChild(o);});
const tgt = document.createElement("select");
(MATRIX_MOD_TARGETS[MATRIX_VOICES[vi]]||["none","amp"]).forEach(t=>{const o=document.createElement("option");o.value=t;o.textContent=t;tgt.appendChild(o);});
const dep = document.createElement("input"); dep.type="range"; dep.min=0; dep.max=1; dep.step=0.01;
src.value=matMod[vi].source; tgt.value=matMod[vi].target; dep.value=matMod[vi].depth;
src.className="cd-mod-src"; tgt.className="cd-mod-tgt"; dep.className="cd-mod-dep";
const sm=()=>{matMod[vi]={source:src.value,target:tgt.value,depth:+dep.value};saveMatState();send("/matrix/mod",vi,src.value,tgt.value,+dep.value);};
src.addEventListener("change",sm); tgt.addEventListener("change",sm); dep.addEventListener("change",sm);
[src,tgt,dep].forEach(e=>ap.appendChild(e));
}
```
Change `renderColorDefRows` to write into `#cd-pane-colors` instead of `#cd-rows` (rename the
target element id). Change `openColorDef(vi)` to call `renderInstEdit(vi)` (which calls
`renderColorDefRows`) and send `/matrix/instrument`, `/matrix/colordefs/get`,
`/matrix/steps/get`, `/matrix/poses/get` on open. Wire the tab buttons (show/hide panes).
- [ ] **Step 4: Fix the WS echo handlers**
The `/matrix/instrument`, `/matrix/mod`, `/matrix/colordefs` handlers (215-272) query
`.minst[data-vi]` / `.mmod-src[data-vi]` which no longer exist on the row. Repoint them to the
modal elements (`#cd-inst-sel`, `.cd-mod-src/tgt/dep`) and only update DOM when the modal is
open for that vi. Keep updating the JS state (`matInst`/`matMod`/`matColorDefs`) regardless.
- [ ] **Step 5: CSS for tabs + verify**
Add `.cd-tabs { display:flex; gap:4px; margin:6px 0; } .cd-tab.on { background:#2b6; color:#fff; } .cd-pane[hidden] { display:none; }` to control.css.
Run `node --check web_realart/public/control/control.js`. Manual: open the modal; instrument,
colors, assign tabs edit and round-trip; rows are decluttered.
- [ ] **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: unified per-instrument editor modal"
```
---
### Task 6: Web — Sequence tab (6×16 step grid)
**Files:** Modify `control.js` (sequence pane + `/matrix/steps` handler + state), `control.css`.
**Interfaces:** Consumes `/matrix/step` (set) and `/matrix/steps` (push, vi+color+32 vals).
`matSteps[vi][color]` = 16 × `{degree, vel}`|null.
- [ ] **Step 1: Add state + render the step grid**
```javascript
let matSteps = Array.from({length:16}, () => Array.from({length:7}, () => new Array(16).fill(null)));
function renderSeq(vi) {
const p = document.getElementById("cd-pane-seq"); p.innerHTML = "";
for (let c = 1; c <= 6; c++) {
const row = document.createElement("div"); row.className = "seq-row m" + c;
for (let s = 0; s < 16; s++) {
const cell = document.createElement("button"); cell.className = "seq-cell";
const st = matSteps[vi][c][s];
cell.textContent = st ? st.degree : "";
if (st) cell.classList.add("on");
cell.addEventListener("click", () => { // left: toggle on with degree 0
if (matSteps[vi][c][s]) { matSteps[vi][c][s] = null; send("/matrix/step", vi, c, s, -99, 0); }
else { matSteps[vi][c][s] = {degree:0, vel:0.8}; send("/matrix/step", vi, c, s, 0, 0.8); }
saveMatState(); renderSeq(vi);
});
cell.addEventListener("wheel", (e) => { // wheel: change degree
e.preventDefault(); const st2 = matSteps[vi][c][s]; if (!st2) return;
st2.degree += (e.deltaY < 0 ? 1 : -1);
send("/matrix/step", vi, c, s, st2.degree, st2.vel); saveMatState(); renderSeq(vi);
});
row.appendChild(cell);
}
p.appendChild(row);
}
}
```
(Call `renderSeq(vi)` from `renderInstEdit`. Velocity edit: a shift+wheel adjusts `vel` by
0.1 and sends — add that to the wheel handler.)
- [ ] **Step 2: Add the `/matrix/steps` WS handler**
Inside the ws message listener:
```javascript
if (address === "/matrix/steps") {
const vi = Math.round(Number(args[0])), c = Math.round(Number(args[1]));
if (vi>=0 && vi<16 && c>=1 && c<=6) {
for (let s=0; s<16; s++) {
const deg = Number(args[2 + s*2]), vel = Number(args[3 + s*2]);
matSteps[vi][c][s] = (deg === -99) ? null : {degree:deg, vel:vel};
}
saveMatState();
if (cdEditVoice===vi && !document.getElementById("colordef-modal").hidden) renderSeq(vi);
}
return;
}
```
- [ ] **Step 3: CSS + verify**
`.seq-row { display:flex; gap:1px; } .seq-cell { width:20px; height:20px; font-size:9px; background:#1a1a1a; color:#ccc; border:1px solid #333; } .seq-cell.on { background:#4c8; color:#000; }`
`node --check`. Manual: toggle steps, wheel changes degree, the voice plays the sequence on the next bar.
- [ ] **Step 4: Commit**
```bash
git add web_realart/public/control/control.js web_realart/public/control/control.css
git commit -m "feat: web matrix step sequencer grid"
```
---
### Task 7: Web — Poses tab + persistence
**Files:** Modify `control.js` (poses pane + `/matrix/poses` handler + state + persistence).
**Interfaces:** Consumes `/matrix/posebind` (set), `/matrix/poses` (push). `matPoses[vi]` = list
of `{poseId, action, color}`. localStorage `pose` key + `steps` already saved under `cdef`.
- [ ] **Step 1: Add state + render the poses pane**
```javascript
let matPoses = Array.from({length:16}, () => []);
function renderPoses(vi) {
const p = document.getElementById("cd-pane-poses"); p.innerHTML = "";
const add = document.createElement("div");
const id = document.createElement("input"); id.placeholder = "poseId"; id.className = "pose-id";
const act = document.createElement("select");
["trigger","gate"].forEach(a=>{const o=document.createElement("option");o.value=a;o.textContent=a;act.appendChild(o);});
const col = document.createElement("select");
[1,2,3,4,5,6].forEach(c=>{const o=document.createElement("option");o.value=c;o.textContent="color "+c;col.appendChild(o);});
const go = document.createElement("button"); go.textContent = "+";
go.addEventListener("click", ()=>{
if (!id.value) return;
matPoses[vi] = matPoses[vi].filter(b=>b.poseId!==id.value).concat([{poseId:id.value, action:act.value, color:+col.value}]);
saveMatState(); send("/matrix/posebind", vi, id.value, act.value, +col.value); renderPoses(vi);
});
[id,act,col,go].forEach(e=>add.appendChild(e)); p.appendChild(add);
matPoses[vi].forEach(b=>{ const line=document.createElement("div");
line.textContent = `${b.poseId} -> ${b.action} color ${b.color}`; p.appendChild(line); });
}
```
(Call `renderPoses(vi)` from `renderInstEdit`.)
- [ ] **Step 2: Add the `/matrix/poses` WS handler**
```javascript
if (address === "/matrix/poses") {
const vi = Math.round(Number(args[0])), n = Math.round(Number(args[1]));
if (vi>=0 && vi<16) {
matPoses[vi] = [];
for (let k=0; k<n; k++) { const b=2+k*3;
matPoses[vi].push({poseId:String(args[b]), action:String(args[b+1]), color:Math.round(Number(args[b+2]))}); }
saveMatState();
if (cdEditVoice===vi && !document.getElementById("colordef-modal").hidden) renderPoses(vi);
}
return;
}
```
- [ ] **Step 3: Persist steps + poses in localStorage**
In `saveMatState` add `steps: matSteps, pose: matPoses` to the stored object. In
`loadMatState`, after restoring `cdef`, add:
```javascript
if (Array.isArray(raw.steps) && raw.steps.length === 16) matSteps = raw.steps;
if (Array.isArray(raw.pose) && raw.pose.length === 16) matPoses = raw.pose;
```
- [ ] **Step 4: Verify** — `node --check`. Manual: add a pose binding; send `/matrix/pose poseId`
from SC/another client and confirm the voice triggers/gates; reload page and confirm bindings restore.
- [ ] **Step 5: Commit**
```bash
git add web_realart/public/control/control.js
git commit -m "feat: web matrix pose bindings tab"
```
---
## Self-Review
**Spec coverage:** unified modal (tabs) → Task 5. Per-color 16-step note+vel sequence
replacing base → Task 1 (state/CRUD) + Task 2 (engine) + Task 6 (UI). Empty→base fallback →
Task 2 Step 4 (`~matStepsEmpty` branch). Pose bind + `/matrix/pose` contract → Task 4 + Task 7.
Persistence (steps + poseBindings, backward-compat) → Task 3 + Task 4 Step 4 + Task 7 Step 3.
Row consolidation (remove .minst/.mmod) → Task 5 Step 2. Degree via `~lpNote`, dur bar/16,
freqRatio → Task 2 Step 3. Pose recognition out-of-scope → not implemented (contract only). ✓
**Placeholder scan:** complete code per step; the rest sentinel (`degree -99`) is defined and
used consistently SC↔web; velocity-edit detail noted concretely in Task 6 Step 1. ✓
**Type consistency:** `~matSetStep.(vi,color,stepIdx,degree,vel)` / `~matStepsPush.(vi,color)` /
`~matColorStepPattern.(name,vi,color,spec,volOf,instPair,cdPairs)` consistent across Tasks 1-2.
`/matrix/steps` layout (vi+color+32, rest=-99) identical SC push (Task 1) ↔ JS decode (Task 6).
`/matrix/poses` layout (vi+n+triples) identical SC (Task 4) ↔ JS (Task 7). `matSteps[vi][c][s]`
and `matPoses[vi]` shapes consistent across Task 6/7. ✓