docs: air-piano implementation plan

6 tasks (TDD): Python strike detector + bridge + publisher
wiring; SC note engine + modulation; loading + env docs.
Default instrument follows live ~melodyInst via \live.
This commit is contained in:
clement
2026-06-28 12:44:13 +02:00
parent 2e32dec51c
commit 8ad309de33
2 changed files with 883 additions and 2 deletions
@@ -0,0 +1,880 @@
# Air-Piano (iPhone fingers into SuperCollider) 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:** Let the performer play notes "like a piano" in the air — each finger
strikes a scale degree, hand position shifts octave, hand spacing modulates
global params — by bringing per-finger iPhone hand data into SuperCollider.
**Architecture:** Hybrid. Python (`data_only_viz`) detects finger strikes from
the raw 21-joint hand data it already holds and emits a discrete OSC
`/pose/finger` event per strike on the existing `:57121` channel. SuperCollider
(`sound_algo`) owns all musicality (scale, finger→degree, octave, instrument,
velocity) and continuous modulation read from the `/pose/hands` data already in
`~handFeat`.
**Tech Stack:** Python 3.11+ via `uv`, `python-osc` (`SimpleUDPClient`), numpy,
pytest. SuperCollider (sclang) `OSCdef`, `Synth`, `Bus.control`.
## Global Constraints
- Python: use `uv` for everything (never pip/poetry/conda directly).
- Python tests live in `data_only_viz/tests/`, run with
`cd data_only_viz && uv run pytest tests/ -v`.
- Mutate `state.*` only under `with state.lock():`.
- Guard every optional ML/import with `try/except ImportError` (n/a here, no new
heavy deps).
- SC env vars are lowercase `~xxx` (uppercase parses as CLASSNAME).
- `.load`-compatible `.scd` files MUST have exactly ONE top-level `(...)` block
and pass the parens/brackets balance check (P:0 B:0). Use the
`validating-scd-files` skill after each `.scd` edit.
- Commits: subject ≤ 50 chars, body ≤ 72 chars/line, no AI attribution, no
`--no-verify`, no underscore in the commit scope.
- No emojis in code/docs/commits.
- Comments: French in `sound_algo/*.scd` (project convention), English
elsewhere.
---
### Task 1: Python — `FingerStrikeDetector`
Pure signal logic: detect per-finger downward strikes from raw hand joints,
isolating finger flexion from whole-hand motion by measuring the fingertip
relative to its base knuckle. No I/O, fully unit-testable.
**Files:**
- Create: `data_only_viz/finger_strike.py`
- Test: `data_only_viz/tests/test_finger_strike.py`
**Interfaces:**
- Consumes: a `hands` list shaped like `state.persons_hands` — a list of hands,
each hand a sequence of ≥21 landmarks where each landmark is indexable
(`lm[0]=x, lm[1]=y, lm[2]=z`) or has `.x/.y/.z`. Reuses `_coord`, `_finite`,
`_clamp` from `data_only_viz/hand_features.py`.
- Produces:
- `class StrikeEvent` (dataclass) with fields
`hand:int, finger:int, strike_speed:float, z:float, tipx:float, tipy:float`.
- `class FingerStrikeDetector` with
`__init__(self, vel_thresh=0.02, refractory_ms=120.0, speed_scale=0.10, history_slots=2)`
and `step(self, hands:list, t_now:float) -> list[StrikeEvent]`.
- Module constants `FINGERTIPS=(4,8,12,16,20)` and `FINGER_BASES=(2,5,9,13,17)`.
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_finger_strike.py`:
```python
"""Tests for FingerStrikeDetector (air-piano strike detection)."""
from __future__ import annotations
from data_only_viz.finger_strike import (
FingerStrikeDetector,
StrikeEvent,
FINGERTIPS,
FINGER_BASES,
)
def _hand(tip_y_by_finger: dict[int, float], base_y: float = 0.4,
cx: float = 0.3) -> list[list[float]]:
"""Build a 21-landmark hand. Every base knuckle sits at base_y; each
fingertip sits at base_y unless overridden in tip_y_by_finger (keyed by
finger 0..4). x is set near cx so L/R slotting is deterministic."""
lm = [[cx, base_y, 0.0] for _ in range(21)]
for f, base_idx in enumerate(FINGER_BASES):
lm[base_idx] = [cx, base_y, 0.0]
for f, tip_idx in enumerate(FINGERTIPS):
ty = tip_y_by_finger.get(f, base_y)
lm[tip_idx] = [cx, ty, 0.0]
return lm
def test_downward_spike_fires_exactly_one_strike():
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=120.0)
# frame 0: neutral (primes prev), index tip level with base
det.step([_hand({1: 0.40})], t_now=0.00)
# frame 1: index tip drops 0.06 below -> downward velocity 0.06 > thresh
e1 = det.step([_hand({1: 0.46})], t_now=0.04)
# frame 2: tip stays down -> velocity ~0, must NOT refire
e2 = det.step([_hand({1: 0.46})], t_now=0.08)
strikes = e1 + e2
assert len(strikes) == 1
assert strikes[0].finger == 1
assert strikes[0].hand == 0
assert strikes[0].strike_speed > 0.0
def test_whole_hand_translation_does_not_fire():
det = FingerStrikeDetector(vel_thresh=0.02)
out = []
# tip and base move down together each frame -> relative y constant
for i, by in enumerate((0.40, 0.50, 0.60, 0.70)):
out += det.step([_hand({1: by}, base_y=by)], t_now=i * 0.04)
assert out == []
def test_refractory_blocks_second_strike():
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=120.0)
det.step([_hand({1: 0.40})], t_now=0.00) # prime
a = det.step([_hand({1: 0.46})], t_now=0.02) # strike 1
det.step([_hand({1: 0.40})], t_now=0.04) # lift -> rearm
b = det.step([_hand({1: 0.46})], t_now=0.06) # within 120 ms -> blocked
assert len(a) == 1
assert b == []
def test_strike_speed_scales_with_velocity():
soft = FingerStrikeDetector(vel_thresh=0.02, speed_scale=0.10)
hard = FingerStrikeDetector(vel_thresh=0.02, speed_scale=0.10)
soft.step([_hand({1: 0.40})], t_now=0.0)
hard.step([_hand({1: 0.40})], t_now=0.0)
s = soft.step([_hand({1: 0.44})], t_now=0.04) # delta 0.04
h = hard.step([_hand({1: 0.50})], t_now=0.04) # delta 0.10
assert h[0].strike_speed > s[0].strike_speed
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'data_only_viz.finger_strike'`
- [ ] **Step 3: Write minimal implementation**
Create `data_only_viz/finger_strike.py`:
```python
"""Air-piano finger strike detection from raw MediaPipe hand joints.
Mirrors hand_features.py conventions (L/R slotting by cx, finite-guarded
coordinate extraction). A "strike" is a fast downward motion of a fingertip
RELATIVE to its base knuckle, so translating the whole hand does not fire all
fingers. Output feeds the OSC /pose/finger route consumed by SuperCollider.
"""
from __future__ import annotations
from dataclasses import dataclass
from data_only_viz.hand_features import _clamp, _coord, _finite
# MediaPipe 21-kp hand: fingertip and base-knuckle indices per finger
# (thumb, index, middle, ring, pinky). Thumb base = ThumbMP (2).
FINGERTIPS: tuple[int, ...] = (4, 8, 12, 16, 20)
FINGER_BASES: tuple[int, ...] = (2, 5, 9, 13, 17)
@dataclass
class StrikeEvent:
hand: int # 0 = left slot (leftmost cx), 1 = right slot
finger: int # 0..4 = thumb, index, middle, ring, pinky
strike_speed: float
z: float
tipx: float
tipy: float
class _FingerState:
__slots__ = ("prev_rel", "armed", "last_t")
def __init__(self) -> None:
self.prev_rel: float | None = None
self.armed: bool = True
self.last_t: float = -1e9
class FingerStrikeDetector:
def __init__(self, vel_thresh: float = 0.02, refractory_ms: float = 120.0,
speed_scale: float = 0.10, history_slots: int = 2) -> None:
self.vel_thresh = vel_thresh
self.refractory_s = refractory_ms / 1000.0
self.speed_scale = max(1e-6, speed_scale)
# state[slot][finger]
self._state = [[_FingerState() for _ in range(5)]
for _ in range(history_slots)]
def _slot_hands(self, hands: list) -> list:
"""Validate and order hands leftmost-first (slot 0 = L), like
HandFeatureExtractor. Returns up to 2 (cx, landmarks) ordered."""
cand = []
for lm in hands:
try:
if lm is None or len(lm) < 21:
continue
except TypeError:
continue
xs = [_finite(_coord(p, "x", 0), 0.5) for p in lm[:21]]
cx = _clamp(sum(xs) / len(xs), 0.0, 1.0)
cand.append((cx, lm))
cand.sort(key=lambda c: c[0])
return cand[:len(self._state)]
def step(self, hands: list, t_now: float) -> list[StrikeEvent]:
ordered = self._slot_hands(hands)
present = set(range(len(ordered)))
events: list[StrikeEvent] = []
for slot, (_cx, lm) in enumerate(ordered):
for f in range(5):
tip = lm[FINGERTIPS[f]]
base = lm[FINGER_BASES[f]]
tip_y = _finite(_coord(tip, "y", 1), 0.5)
base_y = _finite(_coord(base, "y", 1), 0.5)
rel = tip_y - base_y # +down (image y grows downward)
st = self._state[slot][f]
if st.prev_rel is None:
st.prev_rel = rel
continue
vel = rel - st.prev_rel # +down velocity per frame
st.prev_rel = rel
if vel < 0.0: # lifting -> rearm
st.armed = True
if (vel > self.vel_thresh and st.armed
and (t_now - st.last_t) >= self.refractory_s):
st.armed = False
st.last_t = t_now
events.append(StrikeEvent(
hand=slot, finger=f,
strike_speed=_clamp(vel / self.speed_scale, 0.0, 1.0),
z=_finite(_coord(tip, "z", 2, 0.0), 0.0),
tipx=_finite(_coord(tip, "x", 0), 0.5),
tipy=tip_y,
))
# reset slots not present this tick so re-entry does not spike
for slot in range(len(self._state)):
if slot not in present:
for f in range(5):
self._state[slot][f].prev_rel = None
self._state[slot][f].armed = True
return events
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
Expected: PASS (4 passed)
- [ ] **Step 5: Commit**
```bash
git add data_only_viz/finger_strike.py data_only_viz/tests/test_finger_strike.py
git commit -m "feat: add finger strike detector"
```
---
### Task 2: Python — `PoseSoundBridge.send_finger()`
Add the OSC emitter for the new `/pose/finger` event, mirroring `send_hands`.
**Files:**
- Modify: `data_only_viz/pose_bridge.py` (class `PoseSoundBridge`, after
`send_hands`)
- Test: `data_only_viz/tests/test_pose_bridge_finger.py`
**Interfaces:**
- Consumes: nothing new (uses the existing `self._client` SimpleUDPClient bound
to `127.0.0.1:57121` from `PoseSoundBridge.__init__`).
- Produces: `PoseSoundBridge.send_finger(self, ev) -> None` where `ev` is a
`StrikeEvent` (duck-typed: needs `.hand .finger .strike_speed .z .tipx
.tipy`). Emits OSC `/pose/finger` with args
`[pid=0, hand, finger, strike_speed, z, tipx, tipy]` (ints for pid/hand/finger,
floats for the rest). Strike events are NOT throttled (they are already
rate-limited by the detector's refractory window).
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_pose_bridge_finger.py`:
```python
"""Tests for PoseSoundBridge.send_finger."""
from __future__ import annotations
from unittest.mock import MagicMock
from data_only_viz.finger_strike import StrikeEvent
from data_only_viz.pose_bridge import PoseSoundBridge
def _bridge() -> PoseSoundBridge:
b = PoseSoundBridge()
b._client = MagicMock() # stub the UDP client
return b
def test_send_finger_emits_expected_osc():
b = _bridge()
ev = StrikeEvent(hand=1, finger=2, strike_speed=0.5, z=0.1,
tipx=0.7, tipy=0.6)
b.send_finger(ev)
b._client.send_message.assert_called_once()
addr, args = b._client.send_message.call_args[0]
assert addr == "/pose/finger"
assert args[0] == 0 # pid
assert args[1] == 1 # hand
assert args[2] == 2 # finger
assert abs(args[3] - 0.5) < 1e-6
assert len(args) == 7
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd data_only_viz && uv run pytest tests/test_pose_bridge_finger.py -v`
Expected: FAIL with `AttributeError: 'PoseSoundBridge' object has no attribute 'send_finger'`
- [ ] **Step 3: Write minimal implementation**
In `data_only_viz/pose_bridge.py`, add this method to `PoseSoundBridge`
immediately after the `send_hands` method (search for `def send_hands`):
```python
def send_finger(self, ev) -> None:
"""Emit one air-piano strike event. Not throttled: the detector's
refractory window already rate-limits these."""
args = [
0, # pid (single performer)
int(ev.hand),
int(ev.finger),
float(ev.strike_speed),
float(ev.z),
float(ev.tipx),
float(ev.tipy),
]
try:
self._client.send_message("/pose/finger", args)
except OSError:
pass
self._vj("/pose/finger", args)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd data_only_viz && uv run pytest tests/test_pose_bridge_finger.py -v`
Expected: PASS (1 passed)
- [ ] **Step 5: Commit**
```bash
git add data_only_viz/pose_bridge.py data_only_viz/tests/test_pose_bridge_finger.py
git commit -m "feat: emit /pose/finger over osc bridge"
```
---
### Task 3: Python — wire strike emission into `ActionHeadPublisher`
Run the detector each tick on `state.persons_hands` (under lock), gated by
`FINGER_PIANO`, and emit each strike. Thresholds come from env vars.
**Files:**
- Modify: `data_only_viz/action_head_pub.py`
(`ActionHeadPublisher.__init__` and `_tick`; add `_emit_fingers`)
- Test: `data_only_viz/tests/test_action_head_finger_emit.py`
**Interfaces:**
- Consumes: `FingerStrikeDetector.step` (Task 1), `PoseSoundBridge.send_finger`
(Task 2), `state.persons_hands` (existing), `state.lock()` (existing).
- Produces: `ActionHeadPublisher._emit_fingers(self, t_now: float) -> None`,
called from `_tick` after `_emit_hands`. No-op when `FINGER_PIANO` env is unset
or `"0"`. Reads env once in `__init__`:
`FINGER_PIANO` (enable), `FINGER_STRIKE_VEL` (float, default 0.02),
`FINGER_STRIKE_REFRACTORY_MS` (float, default 120).
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_action_head_finger_emit.py`:
```python
"""ActionHeadPublisher emits /pose/finger when FINGER_PIANO is enabled."""
from __future__ import annotations
import threading
from unittest.mock import MagicMock
from data_only_viz.finger_strike import FINGERTIPS, FINGER_BASES
class _FakeState:
def __init__(self) -> None:
self.persons_hands = []
self._lock = threading.RLock()
def lock(self):
return self._lock
def _hand(index_tip_y: float, base_y: float = 0.4):
lm = [[0.3, base_y, 0.0] for _ in range(21)]
lm[FINGERTIPS[1]] = [0.3, index_tip_y, 0.0]
lm[FINGER_BASES[1]] = [0.3, base_y, 0.0]
return lm
def _pub(monkeypatch, enabled: bool):
monkeypatch.setenv("FINGER_PIANO", "1" if enabled else "0")
monkeypatch.setenv("FINGER_STRIKE_VEL", "0.02")
monkeypatch.setenv("FINGER_STRIKE_REFRACTORY_MS", "120")
from data_only_viz.action_head_pub import ActionHeadPublisher
# bypass __init__ heavy ckpt load: construct then set only what we touch
pub = ActionHeadPublisher.__new__(ActionHeadPublisher)
pub.state = _FakeState()
pub.bridge = MagicMock()
pub._init_finger_piano() # helper that reads env + builds detector
return pub
def test_emit_fingers_fires_when_enabled(monkeypatch):
pub = _pub(monkeypatch, enabled=True)
pub.state.persons_hands = [_hand(0.40)]
pub._emit_fingers(0.00) # prime
pub.state.persons_hands = [_hand(0.46)] # downward strike
pub._emit_fingers(0.04)
assert pub.bridge.send_finger.called
def test_emit_fingers_noop_when_disabled(monkeypatch):
pub = _pub(monkeypatch, enabled=False)
pub.state.persons_hands = [_hand(0.40)]
pub._emit_fingers(0.00)
pub.state.persons_hands = [_hand(0.46)]
pub._emit_fingers(0.04)
assert not pub.bridge.send_finger.called
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd data_only_viz && uv run pytest tests/test_action_head_finger_emit.py -v`
Expected: FAIL with `AttributeError: ... has no attribute '_init_finger_piano'`
- [ ] **Step 3: Write minimal implementation**
In `data_only_viz/action_head_pub.py`:
(3a) Add the import near the top of `__init__`'s body sibling imports — at the
end of `__init__` (after `self._hand_ext = HandFeatureExtractor()`, line ~102),
add:
```python
self._init_finger_piano()
```
(3b) Add these two methods to `ActionHeadPublisher` (place after `_emit_hands`):
```python
def _init_finger_piano(self) -> None:
"""Read FINGER_PIANO env config and build the strike detector."""
import os
from data_only_viz.finger_strike import FingerStrikeDetector
self._finger_enabled = os.environ.get("FINGER_PIANO", "0") not in (
"0", "", "false", "False",
)
vel = float(os.environ.get("FINGER_STRIKE_VEL", "0.02"))
refr = float(os.environ.get("FINGER_STRIKE_REFRACTORY_MS", "120"))
self._finger_det = FingerStrikeDetector(
vel_thresh=vel, refractory_ms=refr,
)
def _emit_fingers(self, t_now: float) -> None:
"""Detect finger strikes on persons_hands and emit /pose/finger."""
if not getattr(self, "_finger_enabled", False):
return
with self.state.lock():
hands = list(getattr(self.state, "persons_hands", None) or [])
for ev in self._finger_det.step(hands, t_now):
self.bridge.send_finger(ev)
```
(3c) In `_tick`, after the existing `self._emit_hands(t_now)` line (line ~147),
add:
```python
self._emit_fingers(t_now)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd data_only_viz && uv run pytest tests/test_action_head_finger_emit.py tests/test_action_head_pub.py -v`
Expected: PASS (existing action-head pub tests stay green; 2 new tests pass)
- [ ] **Step 5: Commit**
```bash
git add data_only_viz/action_head_pub.py data_only_viz/tests/test_action_head_finger_emit.py
git commit -m "feat: wire finger strikes into pose publisher"
```
---
### Task 4: SC — `finger_piano.scd` (resolve + OSCdef + `~fp` API + modulation)
The air-piano brain in SuperCollider. A single top-level `(...)` block,
`.load`-compatible. Note resolution is factored into the pure function
`~fpResolve` so it can be unit-tested without a running server.
**Files:**
- Create: `sound_algo/control/finger_piano.scd`
- Test: `sound_algo/tests/test_finger_piano.scd`
**Interfaces:**
- Consumes: `~handFeat` dict (from `data_feeds.scd`: `\lx \ly \rx \ry \dist`),
`~melodyInst` (current live instrument symbol), `palette/scales.scd` scale
arrays (`~scaleMinorPent`, `~scaleDorian`, ...). MIDI→Hz via `.midicps`.
- Produces (env vars / functions):
- `~fpEnabled` (Boolean), `~fpInst` (Symbol or `\live`), `~fpScaleArr`
(Array of MIDI notes), `~fpMap` (`[[5 L degrees],[5 R degrees]]`),
`~fpHandOct` (`[Lsemi, Rsemi]`), `~fpBaseOct` (Integer octaves),
`~fpOctSpan` (Float octaves of hand-x transpose), `~fpSens` (Float),
`~fpVelSource` (`\strike`|`\z`), `~fpHold` (Float seconds), `~fpAmp` (Float).
- `~fpResolve.(hand, finger, strikeSpeed, z, tipx, tipy)` →
`Event` with `\note` (Integer MIDI), `\freq` (Float Hz), `\amp` (Float),
`\inst` (Symbol). Pure; reads `~handFeat`/state but triggers nothing.
- `~fpResolveInst.()` → Symbol (the `\live` sentinel resolves to
`~melodyInst ? \pluck`).
- `~fpModTick.()` → updates `~fpCut` and `~fpFx` (Floats, one-pole smoothed)
from `~handFeat[\dist]` and mean hand Y; writes to control buses
`~fpCutBus`/`~fpFxBus` when the server is running.
- Live wrappers: `~fpOn`, `~fpOff`, `~fpInst_`, `~fpScale_`, `~fpSens_`,
`~fpOctave_`, `~fpVelSource_`.
- `OSCdef(\poseFinger)` on `/pose/finger` calling `~fpResolve` then triggering
`Synth`.
- [ ] **Step 1: Write the failing test**
Create `sound_algo/tests/test_finger_piano.scd`:
```supercollider
// Headless unit test for finger_piano note resolution.
// Run: sclang sound_algo/tests/test_finger_piano.scd
(
var base, ok = true, ev;
base = thisProcess.nowExecutingPath.dirname.dirname ++ "/";
// minimal deps: scales + a fake handFeat + load finger_piano
(base ++ "palette/scales.scd").load;
~handFeat = Dictionary.new;
~handFeat[\lx] = 0.5; ~handFeat[\rx] = 0.5;
~handFeat[\ly] = 0.5; ~handFeat[\ry] = 0.5;
~melodyInst = \pluck;
(base ++ "control/finger_piano.scd").load;
// default scale is minor pentatonic; left thumb (hand 0, finger 0) -> degree 0
ev = ~fpResolve.(0, 0, 1.0, 0.0, 0.5, 0.5);
("note L thumb = " ++ ev[\note]).postln;
if(ev[\note] != ~fpScaleArr.wrapAt(0)) {
ok = false; "FAIL: L thumb note wrong".postln;
};
// right hand defaults one octave up (~fpHandOct[1] = 12)
ev = ~fpResolve.(1, 0, 1.0, 0.0, 0.5, 0.5);
if(ev[\note] != (~fpScaleArr.wrapAt(0) + 12)) {
ok = false; "FAIL: R thumb octave wrong".postln;
};
// instrument follows live via \live sentinel
~fpInst = \live;
if(~fpResolveInst.() != \pluck) { ok = false; "FAIL: live inst".postln; };
~fpInst = \fm_bell;
if(~fpResolveInst.() != \fm_bell) { ok = false; "FAIL: pinned inst".postln; };
// modulation tick updates ~fpCut from dist
~handFeat[\dist] = 1.0;
~fpModTick.();
if(~fpCut.isNil) { ok = false; "FAIL: fpCut nil".postln; };
if(ok) { "PASS test_finger_piano".postln } { "TESTS FAILED".postln };
0.exit;
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `sclang sound_algo/tests/test_finger_piano.scd`
Expected: FAIL — sclang error that `control/finger_piano.scd` does not exist
(open failed / `~fpResolve` is nil).
- [ ] **Step 3: Write minimal implementation**
Create `sound_algo/control/finger_piano.scd`:
```supercollider
// =====================================================================
// control/finger_piano.scd -- Air-piano : doigts iPhone -> notes SC
//
// Recoit /pose/finger (emis par data_only_viz quand FINGER_PIANO=1) :
// /pose/finger <pid> <hand> <finger> <strikeSpeed> <z> <tipx> <tipy>
// Chaque doigt = un degre de gamme. La position de main decale l'octave,
// la vitesse de frappe donne la velocite. Module aussi des params globaux
// depuis l'ecart des mains (~handFeat[\dist]).
//
// Charge par data_only/boot.scd et boot.data-only.scd apres data_feeds.scd.
// Un seul bloc top-level (compatible .load).
// =====================================================================
(
// ---------- ETAT / CONFIG (idempotent : ne reset pas si deja defini) ----
~fpEnabled = ~fpEnabled ? false;
~fpInst = ~fpInst ? \live; // \live = suit ~melodyInst
~fpScaleArr = ~fpScaleArr ? ~scaleMinorPent;
~fpMap = ~fpMap ? [[0,1,2,3,4], [0,1,2,3,4]]; // [L, R] degres
~fpHandOct = ~fpHandOct ? [0, 12]; // L octave basse, R +1 octave
~fpBaseOct = ~fpBaseOct ? 0; // octaves globales en plus
~fpOctSpan = ~fpOctSpan ? 1; // +/- octaves selon x de la main
~fpSens = ~fpSens ? 1.0;
~fpVelSource = ~fpVelSource ? \strike; // \strike ou \z
~fpHold = ~fpHold ? 0.35; // duree avant gate=0 (sec)
~fpAmp = ~fpAmp ? 0.3;
~fpCut = ~fpCut ? 0.0;
~fpFx = ~fpFx ? 0.0;
// Bus de modulation continue (sorties a mapper sur un synth/param).
if(~fpCutBus.isNil and: { s.serverRunning }) {
~fpCutBus = Bus.control(s, 1);
~fpFxBus = Bus.control(s, 1);
};
// Gammes nommees accessibles via ~fpScale_
~fpScales = (
minorPent: ~scaleMinorPent, minor: ~scaleMinor, dorian: ~scaleDorian,
phrygian: ~scalePhrygian, blues: ~scaleBlues, japanese: ~scaleJapanese,
hijaz: ~scaleHijaz, hirajoshi: ~scaleHirajoshi
);
// ---------- RESOLUTION (pure, testable) ---------------------------------
~fpResolveInst = {
if(~fpInst == \live or: { ~fpInst.isNil }) { ~melodyInst ? \pluck }
{ ~fpInst };
};
~fpResolve = { |hand, finger, strikeSpeed = 1.0, z = 0.0, tipx = 0.5, tipy = 0.5|
var deg, handX, octFromX, transpose, note, vel, amp;
deg = ~fpMap[hand][finger] ? 0;
handX = if(hand == 0) { ~handFeat[\lx] ? 0.5 } { ~handFeat[\rx] ? 0.5 };
// x autour de 0.5 -> +/- ~fpOctSpan octaves
octFromX = ((handX - 0.5) * 2 * ~fpOctSpan).round(1.0).asInteger;
transpose = (~fpHandOct[hand] ? 0) + (~fpBaseOct * 12) + (octFromX * 12);
note = (~fpScaleArr.wrapAt(deg) + transpose).asInteger;
vel = if(~fpVelSource == \z) { z } { strikeSpeed };
amp = (~fpAmp * (0.25 + (0.75 * vel)) * ~fpSens).clip(0.0, 1.0);
(note: note, freq: note.midicps, amp: amp, inst: ~fpResolveInst.());
};
// ---------- MODULATION CONTINUE -----------------------------------------
// dist (0..1) -> cutoff exp 200..6000 Hz ; mean Y (0..1) -> fx 0..1
~fpModTick = {
var dist, meanY, cutHz, fx;
dist = (~handFeat[\dist] ? 0.0).clip(0.0, 1.0);
meanY = (((~handFeat[\ly] ? 0.5) + (~handFeat[\ry] ? 0.5)) * 0.5)
.clip(0.0, 1.0);
// lissage one-pole
~fpCut = ~fpCut + (0.2 * (dist - ~fpCut));
~fpFx = ~fpFx + (0.2 * (meanY - ~fpFx));
cutHz = (200 * (30 ** ~fpCut)); // 200 -> 6000 Hz
fx = ~fpFx;
if(~fpCutBus.notNil) { ~fpCutBus.set(cutHz) };
if(~fpFxBus.notNil) { ~fpFxBus.set(fx) };
};
// ---------- OSC : declenchement note ------------------------------------
OSCdef(\poseFinger, { |msg|
var ev, syn;
if(~fpEnabled == true) {
// msg = [/pose/finger, pid, hand, finger, strikeSpeed, z, tipx, tipy]
ev = ~fpResolve.(msg[2].asInteger, msg[3].asInteger,
msg[4], msg[5], msg[6], msg[7]);
if(s.serverRunning) {
syn = Synth(ev[\inst], [\freq, ev[\freq], \amp, ev[\amp], \gate, 1]);
SystemClock.sched(~fpHold, { syn.set(\gate, 0); nil });
};
};
}, '/pose/finger');
// ---------- API LIVE (env-direct wrappers) ------------------------------
~fpOn = { ~fpEnabled = true; "[fp] air-piano ON".postln; };
~fpOff = { ~fpEnabled = false; "[fp] air-piano OFF".postln; };
~fpInst_ = { |sym| ~fpInst = sym; ("[fp] inst = " ++ sym).postln; };
~fpScale_ = { |name|
~fpScaleArr = ~fpScales[name] ? ~fpScaleArr;
("[fp] scale = " ++ name).postln;
};
~fpSens_ = { |v| ~fpSens = v; };
~fpOctave_ = { |n| ~fpBaseOct = n; };
~fpVelSource_ = { |sym| ~fpVelSource = sym; };
"[OK] finger_piano : ~fpOn / ~fpScale_(\\dorian) / ~fpInst_(\\fm_bell)".postln;
)
```
- [ ] **Step 4: Run test + balance check to verify they pass**
Run: `sclang sound_algo/tests/test_finger_piano.scd`
Expected: `PASS test_finger_piano`
Then run the parens/brackets balance check (must be P:0 B:0):
Run:
```bash
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/control/finger_piano.scd
```
Expected: `P:0 B:0`
- [ ] **Step 5: Commit**
```bash
git add sound_algo/control/finger_piano.scd sound_algo/tests/test_finger_piano.scd
git commit -m "feat: air-piano finger note engine in sc"
```
---
### Task 5: SC — hook modulation into `data_feeds.scd`, wire loading + docs
Make `~fpModTick` run on every `/pose/hands` frame, load `finger_piano.scd` in
both data-only boot paths, and document the env vars.
**Files:**
- Modify: `sound_algo/control/data_feeds.scd:502-512` (`\poseHands` OSCdef)
- Modify: `sound_algo/data_only/boot.scd` (after the `data_feeds.scd` load,
line ~151)
- Modify: `sound_algo/boot.data-only.scd` (after the `data_feeds.scd` load,
line ~40)
- Modify: `CLAUDE.md` (Environment variables table)
**Interfaces:**
- Consumes: `~fpModTick` (Task 4), loaded `finger_piano.scd`.
- Produces: continuous calls to `~fpModTick` at the `/pose/hands` rate (~30 Hz);
`finger_piano.scd` present in the data-only runtime so `/pose/finger` is
handled and `~fp*` API is available.
- [ ] **Step 1: Add the modulation hook in `data_feeds.scd`**
In `sound_algo/control/data_feeds.scd`, change the `\poseHands` OSCdef
(line 511 `~handFeat[\dist] = msg[10];`) so the closing of the function calls
the modulation tick. Replace:
```supercollider
~handFeat[\dist] = msg[10];
}, '/pose/hands');
```
with:
```supercollider
~handFeat[\dist] = msg[10];
~fpModTick !? { ~fpModTick.value }; // air-piano modulation continue
}, '/pose/hands');
```
- [ ] **Step 2: Load `finger_piano.scd` in `data_only/boot.scd`**
In `sound_algo/data_only/boot.scd`, find the data_feeds load (line ~151,
`(~repoRoot ++ "control/data_feeds.scd").load;`) and add immediately after it:
```supercollider
(~repoRoot ++ "control/finger_piano.scd").load;
```
- [ ] **Step 3: Load `finger_piano.scd` in `boot.data-only.scd`**
In `sound_algo/boot.data-only.scd`, find the data_feeds load (line ~40,
`(~base ++ "control/data_feeds.scd").load;`) and add immediately after it:
```supercollider
(~base ++ "control/finger_piano.scd").load;
```
- [ ] **Step 4: Document env vars in root `CLAUDE.md`**
In `/Users/electron/Documents/Projets/AV-Live/CLAUDE.md`, in the second
`## Environment variables` table (the one starting with `POSE_FILTER`), add
these rows at the end of the table body:
```markdown
| `FINGER_PIANO` | `0` | `1` enables air-piano finger strike emission from data_only_viz |
| `FINGER_STRIKE_VEL` | `0.02` | downward relative-velocity threshold for a strike (normalized image units/frame) |
| `FINGER_STRIKE_REFRACTORY_MS` | `120` | min ms between strikes per finger |
```
- [ ] **Step 5: Verify balance of the edited `.scd` and re-run the SC test**
Run (both must report `P:0 B:0`):
```bash
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/control/data_feeds.scd
sclang sound_algo/tests/test_finger_piano.scd
```
Expected: `P:0 B:0` and `PASS test_finger_piano`
- [ ] **Step 6: Commit**
```bash
git add sound_algo/control/data_feeds.scd sound_algo/data_only/boot.scd sound_algo/boot.data-only.scd CLAUDE.md
git commit -m "feat: load air-piano and hook hand modulation"
```
---
### Task 6: End-to-end manual smoke (no code) + Python suite green
Final verification gate. No new code; confirm the whole chain and the full
Python suite.
**Files:** none (verification only).
- [ ] **Step 1: Full Python suite stays green**
Run: `cd data_only_viz && uv run pytest tests/ -v`
Expected: all pass (new finger tests + existing action-head/pose-bridge tests).
- [ ] **Step 2: Live smoke with iPhone (manual)**
With the iPhone ARBodyTracker streaming and data_only_viz running:
```bash
FINGER_PIANO=1 uv run python -m data_only_viz.main
```
In the SC data-only session, enable and play:
```supercollider
~fpOn.();
~fpScale_.(\dorian);
~fpInst_.(\live); // suit l'instrument melodique courant
```
Expected: striking a finger downward triggers a note; right hand sounds one
octave above the left; moving a hand left/right shifts the octave; spreading the
hands raises `~fpCut` (check `~fpCut.postln` rises toward 1.0). Tune
`FINGER_STRIKE_VEL` if strikes are too eager / too stiff.
- [ ] **Step 3: Commit (only if smoke required a threshold tweak in docs)**
If you changed a default threshold based on the smoke test, update the
`CLAUDE.md` default and the `FingerStrikeDetector` default to match, then:
```bash
git add -A
git commit -m "tune: finger strike default threshold"
```
---
## Self-Review
**Spec coverage:**
- `/pose/finger` message → Task 2 (emit), Task 3 (wire), Task 4 (receive). OK.
- Python strike detector with relative-to-MCP flexion → Task 1. OK.
- Env vars `FINGER_PIANO` / `FINGER_STRIKE_VEL` / `FINGER_STRIKE_REFRACTORY_MS`
→ Task 3 (read), Task 5 step 4 (doc). OK.
- SC air-piano: finger→degree, octave from hand x, velocity from strike/z,
scale resolution, configurable instrument following `~melodyInst` → Task 4
(`~fpResolve`, `~fpResolveInst`). OK.
- Continuous modulation from `~handFeat[\dist]` + mean position → Task 4
(`~fpModTick`), Task 5 step 1 (hook). OK.
- `~fp` live API → Task 4. OK.
- Runtime loading in data-only paths → Task 5 steps 2-3. OK.
- Tests: Python pytest (Tasks 1-3) + SC headless test + balance check
(Task 4-5) + manual smoke (Task 6). OK.
- Single-performer assumption (pid=0) → Task 2 emits pid=0. OK.
**Placeholder scan:** every code step contains full code; no TBD/TODO. OK.
**Type consistency:** `StrikeEvent` fields (`hand, finger, strike_speed, z,
tipx, tipy`) identical across Tasks 1-3. `send_finger(ev)` arg order matches the
`/pose/finger` arg order consumed by `~fpResolve` (hand=msg[2], finger=msg[3],
strikeSpeed=msg[4], z=msg[5], tipx=msg[6], tipy=msg[7]). `~fpResolve` /
`~fpResolveInst` / `~fpModTick` / `~fpCut` names match between Task 4 impl and
the Task 4 test and the Task 5 hook. OK.
@@ -148,8 +148,9 @@ Contents:
5. Trigger the instrument `~fpInst` as a one-shot
(`Synth(~fpInst, [\freq, f, \amp, vel, \gate, 1, ...])` with its own
envelope `doneAction: 2`, or play through the existing melody instrument
path). Default instrument: an existing pleasant lead/keys SynthDef; no new
SynthDef unless the user later wants a dedicated piano timbre.
path). Default instrument follows the current live melody instrument
(`~melodyInst`) via the `\live` sentinel, reassignable to any SynthDef
(`~fpInst_.(\fm_bell)`); no new SynthDef.
- **Continuous modulation hook:** extend `data_feeds.scd`'s `\poseHands` OSCdef
with a single call to `~fpModTick.value` (defined in `finger_piano.scd`, a
no-op stub if the file is not loaded). `~fpModTick`: