docs(matrix): pinch refinements plan

This commit is contained in:
L'électron rare
2026-06-30 15:43:24 +02:00
parent 4bccbc1580
commit 2902f5c7cd
@@ -0,0 +1,425 @@
# Matrix pinch refinements 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 finger pinches hold-to-fire (held ≥ an adjustable duration) with a per-slot progress indicator and a 2-column MG/MD menu, and run the pinch detector without the air-piano.
**Architecture:** `data_only_viz` emits pinch ENGAGE/RELEASE edges (`/pose/pinch [pid, hand, finger, state]`); the SC engine times the hold (`Main.elapsedTime` vs `~matPinchHoldMs`), fires the mapped global action only past the threshold, and streams `/matrix/pinchprogress [slot, 0..1]` to the panel. The decouple (`PINCH_ENABLE`) is already committed; the launcher switch + the menu (2 columns, hold slider, progress fill) follow.
**Tech Stack:** Python 3 (uv), SuperCollider (sclang), vanilla ES modules + `node --test`, zsh launcher.
## Global Constraints
- SC env vars `~xxx` lowercase. `matrix.scd` stays ONE top-level block — `awk` balance `P:0 B:0` after every edit:
`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`
- sclang: `/Applications/SuperCollider.app/Contents/MacOS/sclang <file>` (NOT on PATH; no `timeout`). Harness: `cd sound_algo/data_only/matrix_presets && sclang test_global_actions.scd``GLOBALACT PASS`.
- Python: `data_only_viz/.venv/bin/python -m pytest tests/<file> -q` from the `data_only_viz` dir. `finger_strike.py` + `pose_bridge.py` tests need NO torch (run locally); `action_head*` tests need torch (NOT runnable on GrosMac — that path is already committed + verified on macm1 at deploy).
- Generator: `cd sound_algo/data_only/matrix_presets && uv run python ...`. Web: `cd web_realart && node --test test/*.test.mjs` (GLOB).
- Commit subject ≤ 50, no underscore in scope, no AI attribution, no `--no-verify`. No emojis.
- Slot = `hand*4 + (finger-1)` (0..7). `/pose/pinch` msg: `hand=msg[2]`, `finger=msg[3]`, `state=msg[4]` (1=engage, 0=release).
- Hold is REAL-TIME (`Main.elapsedTime`, ms), default `~matPinchHoldMs = 300`, clamp 50..2000, live via `/matrix/pinchhold`, saved per `.matrix` as top-level `pinchHoldMs` (separate from `globalSettings`). Per-voice pinch bindings still fire on ENGAGE (no hold). No `data_only_viz` change beyond the edges (the air-piano decouple is committed at 4bccbc1).
## File Structure
- `data_only_viz/finger_strike.py``PinchEvent.state`; emit engage(1)+release(0) edges (+ release on hand-disappear).
- `data_only_viz/pose_bridge.py``send_pinch` adds `state`.
- `data_only_viz/tests/{test_finger_strike.py,test_pose_bridge_finger.py}` — edge + state coverage.
- `launcher/concert/launch_concert.sh``PINCH_ENABLE` instead of `FINGER_PIANO`.
- `sound_algo/data_only/matrix.scd``~matPinchHoldMs`/`~matPinchHeld`/`~matPinchFired`/`~matPinchTick`/poller; `\mat_ev_pinch` rework; `/matrix/pinchhold[/get]` + `/matrix/pinchprogress`; save/load.
- `sound_algo/data_only/matrix_presets/{generate_presets.py,test_global_actions.{py,scd}}``pinchHoldMs` emit + harness P rework.
- `web_realart/public/control/{js/matrix-state.js,js/morceau-panel.js,index.html,control.css}` + `web_realart/test/matrix-state.test.mjs`.
---
### Task 1: data_only_viz pinch edges + launcher
**Files:** Modify `data_only_viz/finger_strike.py`, `data_only_viz/pose_bridge.py`, `data_only_viz/tests/test_finger_strike.py`, `data_only_viz/tests/test_pose_bridge_finger.py`, `launcher/concert/launch_concert.sh`.
**Interfaces:**
- Produces: `/pose/pinch [0, hand, finger, state]` with `state` 1=engage / 0=release; `PinchEvent(hand, finger, state)`.
- [ ] **Step 1: Failing tests.** Read `tests/test_finger_strike.py` to match its hand-builder helpers, then add a test that an engage→release sequence emits a `state=1` then a `state=0` `PinchEvent` for the same (hand, finger). Add to `tests/test_pose_bridge_finger.py` a test that `send_pinch` puts the state as the 4th arg of `/pose/pinch` (build a fake event with `.state`). (Use the files' existing patterns; the detector fires a pinch when the thumb tip is within `ratio_on*size` of a finger tip, and releases above `ratio_off*size`.)
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_finger_strike.py tests/test_pose_bridge_finger.py -q`
Expected: new tests FAIL (no `state` field / no release event / 3-arg pinch).
- [ ] **Step 3: Add the `state` edges in `finger_strike.py`.** Change `PinchEvent`:
```python
@dataclass
class PinchEvent:
hand: int # 0 = left slot (leftmost cx), 1 = right slot
finger: int # 1..4 = index, middle, ring, pinky (thumb is trigger)
state: int = 1 # 1 = engage edge, 0 = release edge
```
In `PinchDetector.step`, emit both edges. Replace:
```python
if st.engaged:
if ratio > self.ratio_off:
st.engaged = False
elif ratio < self.ratio_on and (
t_now - st.last_t) >= self.refractory_s:
st.engaged = True
st.last_t = t_now
events.append(PinchEvent(hand=slot, finger=i + 1))
```
with:
```python
if st.engaged:
if ratio > self.ratio_off:
st.engaged = False
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
elif ratio < self.ratio_on and (
t_now - st.last_t) >= self.refractory_s:
st.engaged = True
st.last_t = t_now
events.append(PinchEvent(hand=slot, finger=i + 1, state=1))
```
And emit a release when an engaged hand disappears. Replace:
```python
for slot in range(len(self._state)):
if slot not in present:
for i in range(4):
self._state[slot][i].engaged = False
return events
```
with:
```python
for slot in range(len(self._state)):
if slot not in present:
for i in range(4):
if self._state[slot][i].engaged:
self._state[slot][i].engaged = False
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
return events
```
- [ ] **Step 4: Add `state` to `send_pinch` in `pose_bridge.py`.** Replace:
```python
def send_pinch(self, ev) -> None:
"""Emit one thumb-to-finger pinch event (clip toggle trigger)."""
args = [0, int(ev.hand), int(ev.finger)]
```
with:
```python
def send_pinch(self, ev) -> None:
"""Emit a thumb-to-finger pinch edge (state 1=engage, 0=release)."""
args = [0, int(ev.hand), int(ev.finger), int(getattr(ev, "state", 1))]
```
- [ ] **Step 5: Run — expect PASS.**
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_finger_strike.py tests/test_pose_bridge_finger.py -q`
Expected: all green.
- [ ] **Step 6: Launcher → `PINCH_ENABLE`.** In `launcher/concert/launch_concert.sh`, replace:
```bash
# Finger pinches drive the matrix global actions (8 slots). Enabling the pinch
# detector also enables the air-piano finger-strike detector, inert unless a
# voice has a finger binding (none by default).
export FINGER_PIANO="${FINGER_PIANO:-1}"
```
with:
```bash
# Finger pinches drive the matrix global actions (8 slots). PINCH_ENABLE gates
# ONLY the pinch detector; the air-piano finger-strike (FINGER_PIANO) stays off.
export PINCH_ENABLE="${PINCH_ENABLE:-1}"
```
Verify: `zsh -n launcher/concert/launch_concert.sh` → no output.
- [ ] **Step 7: Commit.**
```bash
git add data_only_viz/finger_strike.py data_only_viz/pose_bridge.py data_only_viz/tests/test_finger_strike.py data_only_viz/tests/test_pose_bridge_finger.py launcher/concert/launch_concert.sh
git commit -m "feat(pose): pinch engage/release edges + launcher"
```
---
### Task 2: SC — hold-to-fire timing + progress
**Files:** Modify `sound_algo/data_only/matrix.scd` + `sound_algo/data_only/matrix_presets/test_global_actions.scd`.
**Interfaces:**
- Consumes: `~matFireGlobal`, `~matEventPinch`, `~matPoseFire`, `~toscSend`/`~toscTouch`, `~matSave`/`~matLoadFile`.
- Produces: `~matPinchHoldMs`, `~matPinchHeld`, `~matPinchFired`, `~matPinchTick`, `~matPinchHoldPush`; `/matrix/pinchhold[/get]` set+echo; `/matrix/pinchprogress [slot, prog]` stream; `pinchHoldMs` saved per `.matrix`.
- [ ] **Step 1: Rework harness group P (failing).** In `test_global_actions.scd`, REPLACE the existing `// -- P:` block with:
```supercollider
// -- P: pinch hold-to-fire (engage doesn't fire; fires past hold; release cancels) --
~matPinchRoutine !? { ~matPinchRoutine.stop };
~matGlobalActions = [\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next];
~matPinchHoldMs = 300; ~matTransient = nil;
~matPinchHeld = Array.fill(8, nil); ~matPinchFired = Array.fill(8, false);
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 0, 1, 1]); // L index engage -> slot 0 held
(~matTransient.isNil).not.if({ pass = false; "FAIL: engage fired immediately".postln });
(~matPinchHeld[0].isNil).if({ pass = false; "FAIL: engage did not record hold".postln });
~matPinchHeld[0] = Main.elapsedTime - 0.5; // simulate 500ms held (> 300)
~matPinchTick.();
(~matTransient.notNil and: { ~matTransient[\kind] == \fill }).not.if({ pass = false; "FAIL: hold did not fire fill".postln });
~matTransient = nil;
~matPinchHeld = Array.fill(8, nil); ~matPinchFired = Array.fill(8, false);
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 1, 1, 1]); // R index engage -> slot 4
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 1, 1, 0]); // release before hold
~matPinchTick.();
(~matTransient.isNil).not.if({ pass = false; "FAIL: released-early pinch fired".postln });
(~matPinchHeld[4].isNil).not.if({ pass = false; "FAIL: release did not clear hold".postln });
OSCdef(\mat_pinchhold).func.value(['/matrix/pinchhold', 500]);
(~matPinchHoldMs == 500).not.if({ pass = false; "FAIL: pinchhold not set".postln });
~matPinchHoldMs = 300;
// pinchHoldMs save/load roundtrip
~matInstruments = ~matInstruments ? Array.fill(~matVoices.size, { \default });
~matColorDefs = ~matColorDefs ? Array.fill(~matVoices.size, { ~matDefaultColorDefs.value });
~matSavedNames = ~matSavedNames ? Set.new;
~matPinchHoldMs = 420;
~matSave.("zz_phms_test");
~matPinchHoldMs = 300;
~matLoadFile.(~matDir +/+ "zz_phms_test.matrix");
(~matPinchHoldMs == 420).not.if({ pass = false; "FAIL: pinchHoldMs not restored".postln });
("rm -f '" ++ (~matDir +/+ "zz_phms_test.matrix") ++ "'").systemCmd;
```
- [ ] **Step 2: Run — expect FAIL** (`~matPinchTick`/`~matPinchHeld` undefined; old `\mat_ev_pinch` fires immediately).
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
Expected: `GLOBALACT FAIL` / undefined errors.
- [ ] **Step 3: Add the hold state + tick + push.** In the global-actions block (after `~matGlobalActionsPush`), add:
```supercollider
~matPinchHoldMs = ~matPinchHoldMs ? 300;
~matPinchHeld = ~matPinchHeld ? Array.fill(8, nil); // engage time (Main.elapsedTime) or nil
~matPinchFired = ~matPinchFired ? Array.fill(8, false);
~matPinchHoldPush = { ~toscSend !? { ~toscSend.("/matrix/pinchhold", ~matPinchHoldMs) } };
~matPinchTick = {
8.do { |slot|
(~matPinchHeld[slot].notNil).if({
var prog = (((Main.elapsedTime - ~matPinchHeld[slot]) * 1000) / ~matPinchHoldMs).clip(0, 1);
~toscSend !? { ~toscSend.("/matrix/pinchprogress", slot, prog) };
((prog >= 1.0) and: { ~matPinchFired[slot].not }).if({
~matPinchFired[slot] = true;
~matFireGlobal.(slot);
});
});
};
};
```
- [ ] **Step 4: Rework `\mat_ev_pinch` for engage/release.** Replace the existing OSCdef:
```supercollider
OSCdef(\mat_ev_pinch, { |msg|
var hand = (msg[2] ? 0).asInteger;
var finger = (msg[3] ? 1).asInteger;
~matPoseFire.(~matEventPinch.(hand, finger)); // per-voice pinch bindings (unchanged)
~matFireGlobal.((hand * 4) + (finger - 1)); // global action by pinch slot 0..7
}, '/pose/pinch');
```
with:
```supercollider
OSCdef(\mat_ev_pinch, { |msg|
var hand = (msg[2] ? 0).asInteger;
var finger = (msg[3] ? 1).asInteger;
var state = (msg[4] ? 1).asInteger;
var slot = (hand * 4) + (finger - 1);
(state == 1).if({
~matPoseFire.(~matEventPinch.(hand, finger)); // per-voice pinch bindings on engage
(slot >= 0 and: { slot < 8 }).if({
~matPinchHeld[slot] = Main.elapsedTime;
~matPinchFired[slot] = false;
});
}, {
(slot >= 0 and: { slot < 8 }).if({
~matPinchHeld[slot] = nil;
~matPinchFired[slot] = false;
~toscSend !? { ~toscSend.("/matrix/pinchprogress", slot, 0) };
});
});
}, '/pose/pinch');
```
- [ ] **Step 5: Add the OSC routes + the poller.** After `\mat_globalsettings_get` (or near the other matrix OSCdefs), add:
```supercollider
OSCdef(\mat_pinchhold, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matPinchHoldMs = (msg[1] ? 300).asFloat.clip(50, 2000);
~matPinchHoldPush.()
}, '/matrix/pinchhold');
OSCdef(\mat_pinchhold_get, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matPinchHoldPush.()
}, '/matrix/pinchhold/get');
```
Near the engine-tail (where other routines start, e.g. after `~matModValuesRoutine`), add the poller:
```supercollider
~matPinchRoutine !? { ~matPinchRoutine.stop };
~matPinchRoutine = Routine({ loop { ~matPinchTick.(); (1/30).wait } }).play(AppClock);
```
- [ ] **Step 6: Persist `pinchHoldMs`.** In `~matSave`'s `payload`, add after `globalSettings: ~matGlobalSettings`:
```supercollider
globalSettings: ~matGlobalSettings,
pinchHoldMs: ~matPinchHoldMs
```
In `~matLoadFile`, just after the `globalSettings` restore block, add:
```supercollider
~matPinchHoldMs = ((raw.isArray.not) and: { raw[\pinchHoldMs].notNil }).if({ raw[\pinchHoldMs].asFloat.clip(50, 2000) }, { 300 });
```
And after `~matGlobalSettingsPush.();` add `~matPinchHoldPush.();`.
- [ ] **Step 7: Balance + harness.**
`awk` balance → `P:0 B:0`; `sclang test_global_actions.scd``GLOBALACT PASS` (AP).
- [ ] **Step 8: Commit.**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/matrix_presets/test_global_actions.scd
git commit -m "feat(matrix): pinch hold-to-fire + progress"
```
---
### Task 3: Generator — emit `pinchHoldMs`
**Files:** Modify `generate_presets.py` (`to_sc`), `test_global_actions.py`; regenerate `*.matrix`.
- [ ] **Step 1: Test (failing).** In `test_global_actions.py`, add after the `EXPECT_SET` block:
```python
EXPECT_PHMS = "'pinchHoldMs': 300"
check(EXPECT_PHMS in sample, "to_sc output missing pinchHoldMs")
missing_phms = [os.path.basename(f) for f in files
if EXPECT_PHMS not in open(f, encoding="utf-8").read()]
check(not missing_phms, "pinchHoldMs missing in: %s" % missing_phms[:5])
```
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_global_actions.py`
Expected: `FAIL: to_sc output missing pinchHoldMs`.
- [ ] **Step 3: Emit it.** In `to_sc`, change the tail so `globalSettings` ends with a comma and `pinchHoldMs` is the last field. Replace:
```python
"'globalSettings': ( 'transientBars': 1, "
"'breakdownKeep': [ \\kick, \\sub ] )\n)\n"
```
with:
```python
"'globalSettings': ( 'transientBars': 1, "
"'breakdownKeep': [ \\kick, \\sub ] ),\n"
"'pinchHoldMs': 300\n)\n"
```
- [ ] **Step 4: Regen (twice) + gates.**
`uv run python generate_presets.py` (twice). Then `uv run python test_global_actions.py` (`GLOBALACT-PY PASS`), `uv run python test_sections.py` (`TEST PASS`), `uv run python test_families.py` (`TEST PASS`), `/Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd` (`GLOBALACT PASS`).
- [ ] **Step 5: Commit.**
```bash
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_global_actions.py sound_algo/data_only/matrix_presets/*.matrix
git commit -m "feat(presets): emit default pinch hold ms"
```
---
### Task 4: Web — 2-column menu, hold slider, progress fill
**Files:** Modify `web_realart/public/control/js/matrix-state.js`, `js/morceau-panel.js`, `index.html`, `control.css`, `web_realart/test/matrix-state.test.mjs`.
**Interfaces:**
- Consumes: `GLOBAL_SLOTS` (8), the SC `/matrix/pinchhold` + `/matrix/pinchprogress` routes.
- Produces: `matPinchHoldMs` state + persistence; a hold slider; per-slot progress fill; 2-column MG/MD layout.
- [ ] **Step 1: State test (failing).** Append to `matrix-state.test.mjs`:
```javascript
import { matPinchHoldMs, setPinchHoldMs } from "../public/control/js/matrix-state.js";
test("default pinch hold ms + setter", () => {
assert.equal(matPinchHoldMs, 300);
setPinchHoldMs(450);
assert.equal(matPinchHoldMs, 450);
setPinchHoldMs(300);
});
```
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
Expected: import failure.
- [ ] **Step 3: Add the state.** In `matrix-state.js`, after `matGlobalSettings`, add:
```javascript
export let matPinchHoldMs = 300;
export function setPinchHoldMs(v) { matPinchHoldMs = v; }
```
In `saveMatState`, add `phms: matPinchHoldMs` to the blob; in `loadMatState`, after the `gset` restore, add:
```javascript
if (Number.isFinite(raw.phms)) matPinchHoldMs = raw.phms;
```
- [ ] **Step 4: 2-column layout.** In `control.css`, change `.morceau-slots` to fill column-first so slots 0-3 (MG) form the left column and 4-7 (MD) the right. Replace the `.morceau-slots` rule with:
```css
.morceau-slots { display: grid; grid-auto-flow: column; grid-template-rows: repeat(4, auto);
grid-template-columns: 1fr 1fr; gap: 6px 12px; }
.morceau-slot { position: relative; }
.morceau-slot-fill { position: absolute; left: 0; bottom: 0; height: 2px; width: 0%;
background: #8c8; transition: width 60ms linear; pointer-events: none; }
.morceau-slot-fill.fired { background: #cc2; height: 100%; opacity: 0.25; }
```
- [ ] **Step 5: Progress fill + hold slider in `morceau-panel.js`.**
In the slot-render loop, append a fill element to each slot label and keep a reference:
```javascript
const fill = document.createElement("div");
fill.className = "morceau-slot-fill";
wrap.appendChild(fill);
fills[slot.key] = fill;
```
(declare `const fills = [];` next to `const selects = [];`). After the slots loop, add the progress handler:
```javascript
on("/matrix/pinchprogress", (args) => {
const slot = Math.round(Number(args[0]));
const prog = Number(args[1]) || 0;
const f = fills[slot];
if (!f) return;
f.style.width = (prog * 100) + "%";
f.classList.toggle("fired", prog >= 1);
});
```
Add the hold slider into the Réglages group (next to the transient-duration slider), wired to `/matrix/pinchhold`:
```javascript
const holdRow = document.createElement("label");
holdRow.className = "morceau-set-row";
const hSpan = document.createElement("span"); hSpan.textContent = "Maintien pince (ms)";
const hInp = document.createElement("input");
hInp.type = "range"; hInp.min = 50; hInp.max = 2000; hInp.step = 10; hInp.value = matPinchHoldMs;
const hOut = document.createElement("output"); hOut.textContent = matPinchHoldMs;
hInp.addEventListener("input", () => { hOut.textContent = hInp.value; });
hInp.addEventListener("change", () => { setPinchHoldMs(+hInp.value); send("/matrix/pinchhold", +hInp.value); saveMatState(); });
holdRow.append(hSpan, hInp, hOut);
sHost.appendChild(holdRow);
on("/matrix/pinchhold", (args) => {
const v = Math.round(Number(args[0]));
if (Number.isFinite(v)) { hInp.value = v; hOut.textContent = v; setPinchHoldMs(v); saveMatState(); }
});
onOpen(() => send("/matrix/pinchhold/get"));
send("/matrix/pinchhold/get");
```
Add `matPinchHoldMs, setPinchHoldMs` to the `matrix-state.js` import.
- [ ] **Step 6: Parse + suite.**
Run: `cd web_realart && node --check public/control/js/morceau-panel.js` (no output) and `node --test test/*.test.mjs` (all green).
- [ ] **Step 7: Commit.**
```bash
git add web_realart/public/control/js/matrix-state.js web_realart/public/control/js/morceau-panel.js web_realart/public/control/index.html web_realart/public/control/control.css web_realart/test/matrix-state.test.mjs
git commit -m "feat(control): pinch hold slider, progress, 2-col menu"
```
---
### Task 5: Deploy + live smoke (macm1)
**Files:** none.
- [ ] **Step 1:** On user OK: `git push origin main`; `ssh macm1 'cd ~/Documents/Projets/AV-Live && git pull --ff-only'`. Verify the decouple on macm1 (torch present): `ssh macm1 'cd ~/Documents/Projets/AV-Live/data_only_viz && .venv/bin/python -m pytest tests/test_action_head_finger_emit.py -q'` → all green. Then FULL launcher restart (`launch_concert.sh` relaunches the pose pipeline with `PINCH_ENABLE=1`). Hard-reload the web page.
- [ ] **Step 2:** Pinch-AND-HOLD a finger → the slot cell fills 0→1 and fires at the threshold; a quick pinch fires nothing; the "Maintien pince (ms)" slider changes the required hold live; the air-piano makes no sound; the menu shows MG (left) / MD (right) columns. Report to the user.
---
## Self-review
**Spec coverage:** decouple (committed 4bccbc1) + launcher → Task 1; pinch edges → Task 1; SC hold-to-fire + progress + hold route + save/load → Task 2; generator pinchHoldMs → Task 3; web 2-col + hold slider + progress → Task 4; smoke + decouple verify → Task 5. ✓
**Placeholder scan:** complete code; hold 300/clamp 50-2000, slot math, OSC routes concrete; no TBD.
**Type consistency:** `/pose/pinch [pid, hand, finger, state]` produced by `send_pinch` (Task 1) ↔ read `state=msg[4]` in `\mat_ev_pinch` (Task 2). `~matPinchHoldMs` (Float) set by `/matrix/pinchhold` (Task 2) ↔ web `matPinchHoldMs`/`setPinchHoldMs` + slider send (Task 4) — single-value contract. `/matrix/pinchprogress [slot, prog]` emitted in `~matPinchTick`/release (Task 2) ↔ web `on("/matrix/pinchprogress")` fill (Task 4). `pinchHoldMs` save field consistent in `~matSave`/`~matLoadFile` (Task 2) ↔ generator literal (Task 3). Slot 0..7 = `hand*4+(finger-1)` everywhere. Per-voice `~matPoseFire` still on engage only.