From 3eb6426f61c3230a93ad9d148665fdf54122d098 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 22:24:58 +0200 Subject: [PATCH] docs: complete per-instrument editor plan --- .../2026-06-28-matrix-instrument-editor.md | 687 ++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-matrix-instrument-editor.md diff --git a/docs/superpowers/plans/2026-06-28-matrix-instrument-editor.md b/docs/superpowers/plans/2026-06-28-matrix-instrument-editor.md new file mode 100644 index 0000000..4481e4c --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-matrix-instrument-editor.md @@ -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 ` 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 *`. + `OSCdef(\mat_pose)` on `/matrix/pose ` 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 + +``` + +- [ ] **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