Compare commits
236 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cfa0694a7 | |||
| 1fe186bdb8 | |||
| d058858b23 | |||
| 144d55a7fa | |||
| 0bc15e7000 | |||
| 0e5c4df3d7 | |||
| e71ae814f7 | |||
| ba5db3237e | |||
| c8318cecc2 | |||
| 876afc0cf9 | |||
| a6556e809a | |||
| 51a3412158 | |||
| 69c560dfe6 | |||
| 7b039fbb78 | |||
| d970f4cbe9 | |||
| 8d6d42ed3c | |||
| fbc6d4efa3 | |||
| 1302ffd87a | |||
| 720d566294 | |||
| 1d59d68777 | |||
| 8d93163766 | |||
| 6d43120c0a | |||
| 1c07211dd8 | |||
| 6fe98c6b00 | |||
| 32a722e281 | |||
| eb3803680b | |||
| bcf7ac180e | |||
| a569122f14 | |||
| feb32938fb | |||
| c878056733 | |||
| 3199aa326d | |||
| c66c4271fa | |||
| ef1d557eff | |||
| 3ffd81afc7 | |||
| 65f13b4621 | |||
| b85a9c93a9 | |||
| 651fd86d37 | |||
| d337ed9b29 | |||
| 0126fc97a8 | |||
| 4ea0bb2c2a | |||
| ba4a1bb103 | |||
| 05d0fc1df1 | |||
| caa41fd7f1 | |||
| e10361c56d | |||
| a7a1563ef8 | |||
| 5769af3d26 | |||
| f3ca0ac100 | |||
| 4e33a03e1b | |||
| 8b08282aa5 | |||
| 91b3923431 | |||
| e2a41c0d8a | |||
| 65bf3aad08 | |||
| c0cde337c9 | |||
| e7ce016688 | |||
| 5d425b1fbd | |||
| 32cdfa0d3e | |||
| e20a4d130a | |||
| 366d916e9d | |||
| 9ee7990dcd | |||
| 8bc7acbd14 | |||
| 0a4f51ebfb | |||
| 039abaa4a8 | |||
| 5bfc63f86d | |||
| ac6768d8a8 | |||
| 287c4977a9 | |||
| b8ae1a668c | |||
| 48b04084fa | |||
| 06b868e377 | |||
| 0a2865e136 | |||
| 3a3e6b113f | |||
| ef719e76f3 | |||
| 60640a881e | |||
| 29c66c7151 | |||
| 1f19a5db96 | |||
| ea42ba4bb3 | |||
| 021c53f078 | |||
| 50bc6f5d71 | |||
| 1510a37059 | |||
| 095fd51812 | |||
| 1491aef30e | |||
| 24ce960992 | |||
| 08b7c72e49 | |||
| 86c071d0c9 | |||
| 04eb182fab | |||
| b00b6dfbda | |||
| fbc8b55044 | |||
| 67f347e705 | |||
| 6f941c52cb | |||
| b2dab64aae | |||
| bb90820988 | |||
| 5adb8abdc8 | |||
| 40c21a246c | |||
| 2b7b4a81c6 | |||
| ef99c32cac | |||
| 27d5ce9d98 | |||
| 33de64f599 | |||
| 5a9a6338a0 | |||
| c49de5c500 | |||
| 660d493ad6 | |||
| bd9f9fca29 | |||
| 7037ebcd82 | |||
| 02f7ad42b2 | |||
| 16cd6a3655 | |||
| 4c0794a3db | |||
| 9111da4a5c | |||
| 65c06ed4c6 | |||
| 24078111b4 | |||
| 4cfd2f0a98 | |||
| f9f62e2951 | |||
| 38dca5642e | |||
| 2ced4e728f | |||
| 0daa6d6bcd | |||
| b3ec348378 | |||
| 2902f5c7cd | |||
| 4bccbc1580 | |||
| 50386efe8f | |||
| d31c6c52d0 | |||
| 65fe76d5f0 | |||
| 140953f3cb | |||
| 9590615069 | |||
| cc485003f6 | |||
| 2994778b02 | |||
| e04fb88e42 | |||
| afc220f83f | |||
| d4306f6252 | |||
| 9dbf0bcbc9 | |||
| 0bd8e46dd7 | |||
| d483a29d3d | |||
| 64348aa90d | |||
| 8bead14e23 | |||
| 9f53f27317 | |||
| 1d6f65bbb2 | |||
| cadbced958 | |||
| 8ed0365309 | |||
| 69176fb94d | |||
| 50e9e79a22 | |||
| 0cc64194df | |||
| b72056ecb3 | |||
| c0e13a0fa0 | |||
| 2f9f43e664 | |||
| 8dde561c34 | |||
| 15e0b79083 | |||
| d8d6017443 | |||
| db8d7d465f | |||
| 635343262e | |||
| d02a83d6a2 | |||
| 5def521587 | |||
| 6de2627f7e | |||
| fc268405d2 | |||
| ce00b90f43 | |||
| f635240df9 | |||
| e0c4405044 | |||
| 68fa3c4e3b | |||
| af55f8a145 | |||
| f6b350a35c | |||
| 0e6604cb80 | |||
| 3a5cf80aa7 | |||
| 4c7d960b45 | |||
| 1090f2c223 | |||
| fc73be4851 | |||
| 38d79f8510 | |||
| 6acc302982 | |||
| b7fa138172 | |||
| 3d366be50c | |||
| eb42da0f31 | |||
| b7d537b10d | |||
| 6bb8e41af1 | |||
| d8f1cd9e71 | |||
| 6f8903e303 | |||
| c7ba26100e | |||
| d0a66a5fd3 | |||
| 39ac5d0ad1 | |||
| f8c65b91ba | |||
| 9d2a414718 | |||
| e99fe1f585 | |||
| 2532a9a7e5 | |||
| 47fe6a46af | |||
| b254d7cf1d | |||
| 8559cba14a | |||
| 93bbfd8960 | |||
| ee4cd9b92c | |||
| 7bd5a4f5fe | |||
| cd77189c12 | |||
| e9ca68102a | |||
| ae146ac48e | |||
| 0c3d410e98 | |||
| e53ae8d94d | |||
| ece67bf1fb | |||
| 09fec3aa36 | |||
| 84de68cb12 | |||
| 8daad4040b | |||
| 5063e5929a | |||
| 8db2c2a7a9 | |||
| 8aa41e2aff | |||
| d3a2a591ae | |||
| 89ade5524c | |||
| 3ecef87f4b | |||
| 244d5dd944 | |||
| 21d5209047 | |||
| c82b149741 | |||
| b954032165 | |||
| 0179e32837 | |||
| 0c8ea8b104 | |||
| 6964831036 | |||
| adeb98e569 | |||
| 73af4890b1 | |||
| ab265e4473 | |||
| fa7797d259 | |||
| d646f4649d | |||
| 71926dec84 | |||
| 80aa33a19c | |||
| 946b83d9ea | |||
| cde2882975 | |||
| c0a360ff58 | |||
| 5aa9b31ec5 | |||
| 1edeaf1e6e | |||
| e81a70de26 | |||
| 5470ae9ed5 | |||
| bc661aea5a | |||
| aec2014b0a | |||
| 8615472589 | |||
| b0357af715 | |||
| 3bab0d5e42 | |||
| 7b35c19017 | |||
| 3098593dd0 | |||
| 04ff9ceb22 | |||
| 8ebd028039 | |||
| 929fd9aaf0 | |||
| 25bfdf0cb2 | |||
| f03b7bb3bd | |||
| 84054714d3 | |||
| 4cb6ce14c0 | |||
| dcb35772ae | |||
| df04d8d051 | |||
| b18cb7c514 | |||
| 6cf09c21be |
@@ -44,7 +44,7 @@ Toujours répondre en français à l'utilisateur. Code, commentaires de code, co
|
||||
| `MULTIHMR_SERVER_BACKEND` | `pyobjc` | server: `pyobjc` or `coremltools` |
|
||||
| `MULTIHMR_LOOP_FPS` | `30` | Python worker loop target_fps |
|
||||
| `AVBODY_HOST` | `127.0.0.1` | route TCP mesh + OSC to remote AVLiveBody |
|
||||
| `MEDIAPIPE_DELEGATE` | `cpu` | `gpu` Metal SRGBA (faster, flake on M5) |
|
||||
| `MEDIAPIPE_DELEGATE` | `gpu` | Metal SRGBA (faster); `cpu` fallback for stability |
|
||||
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages |
|
||||
| `MULTIHMR_REID` | `dino` | DINO cosine matching, `iou` fallback |
|
||||
|
||||
@@ -74,9 +74,25 @@ Toujours répondre en français à l'utilisateur. Code, commentaires de code, co
|
||||
| `FINGER_STRIKE_REFRACTORY_MS` | `120` | min ms between strikes per finger |
|
||||
| `FINGER_SOURCE` | `auto` | hand source for finger gestures: `auto` (iPhone Vision hands if fresh, else MediaPipe), `iphone`, `mediapipe` |
|
||||
| `FINGER_DEBUG` | `0` | `1` logs each detected strike/pinch (hand, finger) — for threshold tuning |
|
||||
| `PINCH_RATIO_ON` | `0.45` | thumb-to-finger distance / hand size below which a pinch engages (clip toggle) |
|
||||
| `PINCH_RATIO_ON` | `0.50` | thumb-to-finger distance / hand size below which a pinch engages (0.50 validated live 2026-07-02 for ring/pinky reach) |
|
||||
| `PINCH_RATIO_OFF` | `0.65` | distance/size above which the pinch re-arms (hysteresis) |
|
||||
| `PINCH_REFRACTORY_MS` | `250` | min ms between pinches per finger |
|
||||
| `PINCH_MARGIN` | `0.05` | closest-wins margin between nearest and runner-up fingertip (0.05 validated live: 0.20 rejected middle/ring/pinky pinches) |
|
||||
| `PINCH_EXT_RATIO` | `1.35` | tip-to-wrist distance / hand size above which a finger counts as extended (open-hand pinch gate) |
|
||||
| `PINCH_EXT_MIN` | `1` | min extended non-pinching fingers required for a pinch to engage; `0` disables the open-hand gate (1 validated live: inner-finger pinches curl neighbours) |
|
||||
| `PINCH_DEBOUNCE_FRAMES` | `3` | consecutive qualifying frames before a pinch engage fires (release stays immediate); `1` disables |
|
||||
| `HAND_CONF_MIN` | `0.45` | min Vision hand confidence to draw (raised from 0.3; applies to both overlay and side panels) |
|
||||
| `HAND_PERSIST_FRAMES` | `3` | consecutive frames a hand track must match before being drawn (1 = off, all hands drawn immediately) |
|
||||
| `HAND_SWAP_LR` | `0` | Safety knob: invert the Vision chirality interpretation when routing hands (gestures + panels). Chirality validated correct live 2026-07-02; default `0`. Set `1` if a future iPhone app build delivers inverted chirality |
|
||||
| `HAND_NEAR_MIN` | `0.10` | Minimum wrist-to-middle-MCP distance (normalised) for a hand to trigger pinch/strike. Hands smaller than this are gated out. Display panels are unaffected |
|
||||
| `HAND_PERSIST_GRACE` | `2` | grace frames before dropping a hand track (0 = strict per-frame) |
|
||||
| `HAND_NEAR_OFF` | `0.08` | hand size hysteresis off-threshold for gesture slot stabilizer |
|
||||
| `HAND_HOLD_FRAMES` | `2` | gesture slot: frames to hold last hand on Vision miss |
|
||||
| `HAND_FACE_MIN` | `0.5` | palm-spread ratio threshold — gesture slot arms only when hand_facing >= this (palm-facing ~0.7-1.0, side-on < 0.4; 0.8× hysteresis for off) |
|
||||
| `ARKIT_BONE_MAX` | `0.5` | max ARKit body bone length in normalized units; longer bones are skipped (tracking-loss garbage filter) |
|
||||
| `PINCH_ENABLE` | `0` | enable pinch gesture detection |
|
||||
| `VIZ_HUD` | `0` | show debug HUD overlay |
|
||||
| `CONCERT_MIRROR` | `1` | mirror video horizontally (!=0 enables) |
|
||||
|
||||
## Conventions globales
|
||||
|
||||
|
||||
+23
-6
@@ -32,12 +32,29 @@ Python **3.11+** requis. `pyproject.toml` est la source de vérité — ne jamai
|
||||
|
||||
- État partagé multi-thread : `state.py` expose `State.lock()` — toujours mutationner sous lock.
|
||||
- Filtrage temporel : `euro_filter.py` (One Euro Filter) sur les keypoints avant tracker.
|
||||
- ARKit fusion : `iphone_osc_listener.py` consume /body3d/kp UDP :57128
|
||||
→ `state.persons_arkit_joints`. `pose_filter.py::ArkitFuse` (stage
|
||||
`arkit_fuse`) splices the 14 mapped body slots into MediaPipe pose
|
||||
before kalman ; `multi_hmr_worker::arkit_pelvis_z_override` locks the
|
||||
SMPL-X cam translation z to the ARKit pelvis. Mapping in
|
||||
`arkit_joint_map.py`.
|
||||
- ARKit body source (**`--iphone-usb`, défaut concert 2026-06-30**) : le corps
|
||||
vient **100% ARKit**, le pose+face MediaPipe du Mac sont **coupés** (comme les
|
||||
mains, cf. `4c0794a`). L'app iPhone envoie le squelette 2D projeté (frame
|
||||
AVLiveWire `skeleton2D`, tag **6**) → `state.persons_arkit_2d` ; le 3D monde
|
||||
arrive via la frame `skeleton` → `state.persons_arkit_joints`.
|
||||
`multi.py` reconstruit `bodies`/`bodies3d` (MP33) via
|
||||
`arkit_joint_map.arkit_body_2d`/`arkit_body_3d` (slots mappés c=1, sinon c=0 ;
|
||||
pid le plus bas, single-person). `/pose/mouth` + le mesh visage disparaissent.
|
||||
`POSE_FILTER` **sans `arkit_fuse`** (ARKit EST la source). Décode :
|
||||
`scripts/iphone_usb_bridge.decode_skeleton2D` (819 octets = 91×2 f32 BE + 91
|
||||
valid). Mapping 14 joints + ancre tête `ARKIT_HEAD_IDX` dans `arkit_joint_map.py`
|
||||
(index à confirmer live). **Orientation : PORTRAIT validé live 2026-07-02**
|
||||
(meilleure détection des mains, ARKit body suit — l'ancienne contrainte
|
||||
paysage de 2026-06-30 ne se reproduit plus). La vidéo HEVC arrive en repère
|
||||
capteur paysage → `VIDEO_ROTATE=ccw` (défaut launcher concert) la redresse ;
|
||||
les DEUX flux 2D (skeleton2D + mains Vision) arrivent déjà droits et ne sont
|
||||
PAS tournés (knobs `IPHONE_SKEL_ROTATE`/`IPHONE_HANDS_ROTATE` si besoin).
|
||||
- ARKit fusion (**legacy, chemin webcam + OSC**) : `iphone_osc_listener.py`
|
||||
consume /body3d/kp UDP :57128 → `state.persons_arkit_joints`.
|
||||
`pose_filter.py::ArkitFuse` (stage `arkit_fuse`) splices the 14 mapped body
|
||||
slots into MediaPipe pose before kalman ; `multi_hmr_worker::arkit_pelvis_z_override`
|
||||
locks the SMPL-X cam translation z to the ARKit pelvis. Mapping in
|
||||
`arkit_joint_map.py`. (Supersédé par l'ARKit body source sous `--iphone-usb`.)
|
||||
- Association multi-personne : `tracker.py` IoU-based, `scipy.optimize.linear_sum_assignment`.
|
||||
- Shaders Metal dans `shaders/` (`.metal`), recompilés au runtime ; topologie mesh (SMPL faces) en binaire dans `mesh_topology.py`.
|
||||
- OSC out : `osc_listener.py` / `pose_bridge.py` — destination `oscope-of` sur `:57123`.
|
||||
|
||||
@@ -24,6 +24,8 @@ from data_only_viz.action_head import (
|
||||
J3D_FINGERS_PER_HAND,
|
||||
LABELS,
|
||||
)
|
||||
from data_only_viz.hand_display import gesture_quality, hand_plausible, HandPersistenceGate
|
||||
from data_only_viz.hand_slots import route_hands
|
||||
|
||||
LOG = logging.getLogger("action_head_pub")
|
||||
|
||||
@@ -139,77 +141,191 @@ class ActionHeadPublisher(threading.Thread):
|
||||
idx = LABELS.index(label)
|
||||
self.bridge.send_action(pid, idx, probs, t_now, force=True)
|
||||
self.bridge.send_kin(pid, kin, t_now, force=True)
|
||||
self.bridge.send_mouth(pid, mouth)
|
||||
if pid not in self._last_pids:
|
||||
self.bridge.send_enter(pid=pid)
|
||||
for gone in self._last_pids - current_pids:
|
||||
self.head.forget(gone)
|
||||
self.bridge.send_leave(pid=gone)
|
||||
self._last_pids = current_pids
|
||||
if getattr(self, "_stab", None) is not None:
|
||||
self._stab_slotted = self._step_stab()
|
||||
# Reset finger-strike state for slots that just transitioned from
|
||||
# "held replay" back to a real hand, preventing phantom strikes from
|
||||
# the compressed motion delta accumulated during the hold window.
|
||||
for i, resumed in enumerate(self._stab.resumed_flags()):
|
||||
if resumed:
|
||||
self._finger_det.reset_slot(i)
|
||||
else:
|
||||
self._stab_slotted = None
|
||||
self._emit_hands(t_now)
|
||||
self._emit_fingers(t_now)
|
||||
self._emit_pinch(t_now)
|
||||
self._update_gesture_slot_status()
|
||||
|
||||
def _emit_hands(self, t_now: float) -> None:
|
||||
"""Lock state, extract hand features, emit /pose/hands once per tick."""
|
||||
with self.state.lock():
|
||||
hands = list(getattr(self.state, "persons_hands", None) or [])
|
||||
if not hands and getattr(self.state, "hands_present", False):
|
||||
lkp = getattr(self.state, "left_hand_kp", None)
|
||||
rkp = getattr(self.state, "right_hand_kp", None)
|
||||
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
|
||||
feats = self._hand_ext.step(hands)
|
||||
if getattr(self, "_tick_hands", None) is not None:
|
||||
# Fast path: validated by _step_stab this tick (audit R3/R4: single lock).
|
||||
hands = self._tick_hands
|
||||
chirality = self._tick_chir
|
||||
mirror = self._tick_mirror
|
||||
else:
|
||||
# Fallback for standalone calls (unit tests bypassing _tick).
|
||||
with self.state.lock():
|
||||
hands = list(getattr(self.state, "persons_hands", None) or [])
|
||||
chirality = list(getattr(self.state, "persons_hands_chirality", None) or [])
|
||||
mirror = bool(getattr(self.state, "mirror_2d", False))
|
||||
if not hands and getattr(self.state, "hands_present", False):
|
||||
lkp = getattr(self.state, "left_hand_kp", None)
|
||||
rkp = getattr(self.state, "right_hand_kp", None)
|
||||
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
|
||||
chirality = []
|
||||
# Inline plausibility gate only (no persistence gate in this path).
|
||||
conf_min = getattr(self, "_gesture_conf_min", 0.0)
|
||||
def _plausible_fb(h):
|
||||
try:
|
||||
return hand_plausible(h, conf_min=conf_min)
|
||||
except (AttributeError, TypeError):
|
||||
return True
|
||||
plausible_mask = [_plausible_fb(h) for h in hands]
|
||||
hands = [h for h, ok in zip(hands, plausible_mask) if ok]
|
||||
chirality = [c for c, ok in zip(chirality, plausible_mask) if ok]
|
||||
stab = getattr(self, "_stab", None)
|
||||
fist_enabled = stab.active_flags() if stab is not None else (True, True)
|
||||
feats = self._hand_ext.step(hands, chirality or None,
|
||||
swap=getattr(self, "_hand_swap_lr", False),
|
||||
fist_enabled=fist_enabled,
|
||||
mirror=mirror)
|
||||
with self.state.lock():
|
||||
self.state.hand_feats = feats
|
||||
self.bridge.send_hands(feats, t_now)
|
||||
|
||||
def _init_finger_piano(self) -> None:
|
||||
"""Read FINGER_* env config and build the strike + pinch detectors."""
|
||||
import os
|
||||
from data_only_viz.config import VizConfig
|
||||
from data_only_viz.finger_strike import (
|
||||
FingerStrikeDetector, PinchDetector,
|
||||
)
|
||||
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"))
|
||||
cfg = VizConfig.from_env()
|
||||
self._finger_enabled = cfg.finger_piano
|
||||
# Pinch detection is gated independently of the air-piano finger-strike:
|
||||
# the matrix global actions want pinches WITHOUT the finger-strike "piano".
|
||||
self._pinch_enabled = cfg.pinch_enable
|
||||
self._finger_det = FingerStrikeDetector(
|
||||
vel_thresh=vel, refractory_ms=refr,
|
||||
vel_thresh=cfg.finger_strike_vel,
|
||||
refractory_ms=cfg.finger_strike_refractory_ms,
|
||||
)
|
||||
self._pinch_det = PinchDetector(
|
||||
ratio_on=float(os.environ.get("PINCH_RATIO_ON", "0.45")),
|
||||
ratio_off=float(os.environ.get("PINCH_RATIO_OFF", "0.65")),
|
||||
refractory_ms=float(os.environ.get("PINCH_REFRACTORY_MS", "250")),
|
||||
)
|
||||
self._finger_dbg = os.environ.get("FINGER_DEBUG", "0") not in (
|
||||
"0", "", "false", "False",
|
||||
ratio_on=cfg.pinch_ratio_on,
|
||||
ratio_off=cfg.pinch_ratio_off,
|
||||
refractory_ms=cfg.pinch_refractory_ms,
|
||||
margin=cfg.pinch_margin,
|
||||
ext_ratio=cfg.pinch_ext_ratio,
|
||||
ext_min=cfg.pinch_ext_min,
|
||||
debounce_frames=cfg.pinch_debounce_frames,
|
||||
)
|
||||
self._finger_dbg = cfg.finger_debug
|
||||
# auto = iPhone Vision hands if fresh, else MediaPipe. Or force
|
||||
# "iphone" / "mediapipe".
|
||||
self._finger_source = os.environ.get("FINGER_SOURCE", "auto").lower()
|
||||
self._finger_source = cfg.finger_source
|
||||
self._fdbg_n = 0
|
||||
# Safety knob: invert Vision chirality interpretation.
|
||||
# Chirality validated correct live 2026-07-02; keep False unless a
|
||||
# future iPhone app build flips it.
|
||||
self._hand_swap_lr = cfg.hand_swap_lr
|
||||
self._hand_near_min = cfg.hand_near_min
|
||||
self._hand_near_off = cfg.hand_near_off
|
||||
self._hand_hold_frames = cfg.hand_hold_frames
|
||||
from data_only_viz.hand_slots import GestureSlotStabilizer
|
||||
self._stab = GestureSlotStabilizer(
|
||||
hold_frames=cfg.hand_hold_frames,
|
||||
near_on=cfg.hand_near_min,
|
||||
near_off=cfg.hand_near_off,
|
||||
face_min=cfg.hand_face_min,
|
||||
)
|
||||
self._gesture_conf_min = cfg.hand_conf_min
|
||||
# Gesture-path gate stepped at ~30 Hz (publisher tick) — correctly
|
||||
# cadenced, unlike the renderer's 60 fps instance (audit R6).
|
||||
self._gesture_gate = HandPersistenceGate(
|
||||
min_frames=cfg.hand_persist_frames, grace=cfg.hand_persist_grace
|
||||
)
|
||||
self._tick_hands: list | None = None
|
||||
self._tick_chir: list = []
|
||||
self._tick_mirror: bool = False
|
||||
self._tick_raw_slotted: list = [None, None]
|
||||
|
||||
def _pick_hands(self) -> tuple[list, str, bool]:
|
||||
"""Return (hands, source_label, ip_fresh) per FINGER_SOURCE policy."""
|
||||
def _pick_hands(self) -> tuple[list, list, str, bool, bool]:
|
||||
"""Return (hands, chirality, source_label, ip_fresh, mirror).
|
||||
|
||||
mirror is read from state.mirror_2d under the lock so it is consistent
|
||||
with the chirality dropout decision made in the same tick. Callers
|
||||
pass it through to route_hands so the cx fallback slot assignment stays
|
||||
coherent with the renderer's panel layout when chirality is absent.
|
||||
"""
|
||||
with self.state.lock():
|
||||
mp_hands = list(getattr(self.state, "persons_hands", None) or [])
|
||||
ip_hands = list(getattr(self.state, "persons_hands_iphone", None) or [])
|
||||
ip_t = getattr(self.state, "persons_hands_iphone_t", 0.0)
|
||||
ip_chir = list(getattr(self.state, "persons_hands_chirality", None) or [])
|
||||
mirror = bool(getattr(self.state, "mirror_2d", False))
|
||||
ip_fresh = bool(ip_hands) and (time.perf_counter() - ip_t) < 0.5
|
||||
src = getattr(self, "_finger_source", "auto")
|
||||
if src == "iphone":
|
||||
return ip_hands, "iphone", ip_fresh
|
||||
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
|
||||
if src == "mediapipe":
|
||||
return mp_hands, "mediapipe", ip_fresh
|
||||
return (ip_hands, "iphone", ip_fresh) if ip_fresh else (
|
||||
mp_hands, "mediapipe", ip_fresh)
|
||||
return mp_hands, [], "mediapipe", ip_fresh, mirror
|
||||
if ip_fresh:
|
||||
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
|
||||
return mp_hands, [], "mediapipe", ip_fresh, mirror
|
||||
|
||||
def _step_stab(self) -> list:
|
||||
"""Pick hands, apply quality gates, route, step gesture stabilizer.
|
||||
|
||||
Called once per tick from _tick() to share the result across all
|
||||
three gesture emitters. Also callable directly from emitters when
|
||||
_stab_slotted is not yet set (e.g. in unit tests that call emitters
|
||||
without going through _tick).
|
||||
"""
|
||||
hands, chir, _, _, mirror = self._pick_hands()
|
||||
|
||||
# 1. Plausibility gate: reject out-of-frame / undersized / low-conf ghosts.
|
||||
# Raw numpy-array hands (no .x/.y/.c protocol) are passed through unchanged
|
||||
# so _build_hands_map / action-head model paths keep working.
|
||||
def _plausible(h):
|
||||
try:
|
||||
return hand_plausible(h, conf_min=self._gesture_conf_min)
|
||||
except (AttributeError, TypeError):
|
||||
return True
|
||||
plausible_mask = [_plausible(h) for h in hands]
|
||||
plausible_hands = [h for h, ok in zip(hands, plausible_mask) if ok]
|
||||
plausible_chir = [c for c, ok in zip(chir, plausible_mask) if ok]
|
||||
|
||||
# 2. Establishment gate: anti-ghost temporal filter; same chirality mask
|
||||
# keeps chir[i] aligned with plausible_hands[i] through both filters.
|
||||
established_mask = self._gesture_gate.step(plausible_hands)
|
||||
self._tick_hands = [h for h, ok in zip(plausible_hands, established_mask) if ok]
|
||||
self._tick_chir = [c for c, ok in zip(plausible_chir, established_mask) if ok]
|
||||
self._tick_mirror = mirror
|
||||
|
||||
raw = route_hands(
|
||||
self._tick_hands, self._tick_chir or None,
|
||||
swap=getattr(self, "_hand_swap_lr", False),
|
||||
near_min=0.0,
|
||||
mirror=mirror,
|
||||
)
|
||||
self._tick_raw_slotted = list(raw) # save pre-stabilizer routing for status
|
||||
return self._stab.step(raw)
|
||||
|
||||
def _emit_fingers(self, t_now: float) -> None:
|
||||
"""Detect finger strikes (air-piano) and emit /pose/finger."""
|
||||
if not getattr(self, "_finger_enabled", False):
|
||||
return
|
||||
hands, used, ip_fresh = self._pick_hands()
|
||||
events = self._finger_det.step(hands, t_now)
|
||||
hands, chirality, used, ip_fresh, _ = self._pick_hands()
|
||||
slotted = getattr(self, "_stab_slotted", None)
|
||||
if slotted is None:
|
||||
slotted = self._step_stab()
|
||||
events = self._finger_det.step(slotted, t_now)
|
||||
if getattr(self, "_finger_dbg", False):
|
||||
self._fdbg_n += 1
|
||||
if events or self._fdbg_n % 30 == 0:
|
||||
@@ -219,16 +335,56 @@ class ActionHeadPublisher(threading.Thread):
|
||||
self.bridge.send_finger(ev)
|
||||
|
||||
def _emit_pinch(self, t_now: float) -> None:
|
||||
"""Detect thumb-to-finger pinches (clip toggles) and emit /pose/pinch."""
|
||||
if not getattr(self, "_finger_enabled", False):
|
||||
"""Detect thumb-to-finger pinches (matrix global actions) and emit
|
||||
/pose/pinch. Gated by PINCH_ENABLE, independently of the air-piano."""
|
||||
if not getattr(self, "_pinch_enabled", False):
|
||||
return
|
||||
hands, used, _ip = self._pick_hands()
|
||||
for ev in self._pinch_det.step(hands, t_now):
|
||||
hands, chirality, used, _ip, _ = self._pick_hands()
|
||||
slotted = getattr(self, "_stab_slotted", None)
|
||||
if slotted is None:
|
||||
slotted = self._step_stab()
|
||||
for ev in self._pinch_det.step(slotted, t_now):
|
||||
if getattr(self, "_finger_dbg", False):
|
||||
LOG.info("pinch dbg: src=%s hand=%d finger=%d",
|
||||
used, ev.hand, ev.finger)
|
||||
self.bridge.send_pinch(ev)
|
||||
|
||||
def _update_gesture_slot_status(self) -> None:
|
||||
"""Compute per-slot gesture status and quality, write both to state.
|
||||
|
||||
Status values: 0=absent, 1=detected(plausible+established, not armed),
|
||||
2=armed(near+facing), 3=pinch engaged.
|
||||
Quality ∈ [0, 1]: continuous intensity for frame brightness + thickness.
|
||||
Called once per tick after all emitters have run.
|
||||
"""
|
||||
stab = getattr(self, "_stab", None)
|
||||
if stab is None:
|
||||
return
|
||||
pinch_det = getattr(self, "_pinch_det", None)
|
||||
raw = getattr(self, "_tick_raw_slotted", [None, None])
|
||||
active = stab.active_flags()
|
||||
engaged = pinch_det.engaged_slots() if pinch_det is not None else (False, False)
|
||||
status = [0, 0]
|
||||
quality = [0.0, 0.0]
|
||||
face_min = stab._face_min
|
||||
near_on = stab._near_on
|
||||
for slot in range(2):
|
||||
if engaged[slot]:
|
||||
status[slot] = 3
|
||||
elif active[slot]:
|
||||
status[slot] = 2
|
||||
elif raw[slot] is not None:
|
||||
status[slot] = 1
|
||||
quality[slot] = gesture_quality(
|
||||
raw[slot],
|
||||
face_min=face_min,
|
||||
near_on=near_on,
|
||||
engaged=bool(engaged[slot]),
|
||||
)
|
||||
with self.state.lock():
|
||||
self.state.gesture_slot_status = status
|
||||
self.state.gesture_slot_quality = quality
|
||||
|
||||
def _read_sources(self) -> tuple[
|
||||
list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] | None,
|
||||
float, str, bool,
|
||||
@@ -263,8 +419,12 @@ class ActionHeadPublisher(threading.Thread):
|
||||
if t_smplx > self._last_smplx_t:
|
||||
out: list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] = []
|
||||
for i, p in enumerate(persons_smplx or []):
|
||||
pid = int(p.get("pid", i))
|
||||
v3d = p.get("v3d")
|
||||
# Support both SMPLXPerson dataclass (multi_hmr_worker, field
|
||||
# names: pid / vertices_3d / expression) and legacy dict format
|
||||
# (keys: "pid" / "v3d" / "expression").
|
||||
_is_dict = isinstance(p, dict)
|
||||
pid = int(p.get("pid", i) if _is_dict else p.pid)
|
||||
v3d = p.get("v3d") if _is_dict else p.vertices_3d
|
||||
if v3d is None:
|
||||
continue
|
||||
# CoreMLArray wraps a numpy array but has no __array__
|
||||
@@ -275,8 +435,8 @@ class ActionHeadPublisher(threading.Thread):
|
||||
if v3d_np.shape[0] < max(SMPLX_JOINT_ANCHOR_VERTS) + 1:
|
||||
continue
|
||||
j3d32 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
|
||||
# expression
|
||||
expr = p.get("expression")
|
||||
# expression — field name matches in both dict and dataclass
|
||||
expr = p.get("expression") if _is_dict else p.expression
|
||||
if expr is not None:
|
||||
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
|
||||
expr = expr.numpy()
|
||||
|
||||
@@ -25,9 +25,15 @@ MP33_NUM_LANDMARKS = 33
|
||||
# Used by multi_hmr_worker for cam-translation z lock.
|
||||
ARKIT_PELVIS_IDX = 1
|
||||
|
||||
# ARKit head joint index in the 91-joint enum.
|
||||
# Tentatively mapped to index 51 (head_joint in Apple's canonical ordering).
|
||||
# VERIFY LIVE (controller will confirm the index on the rig)
|
||||
ARKIT_HEAD_IDX = 51
|
||||
|
||||
# (arkit_joint_idx, mediapipe_pose_idx). Match the body slots used
|
||||
# by the SMPL-X body fusion in multi.py.
|
||||
ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
|
||||
(ARKIT_HEAD_IDX, 0), # head_joint -> NOSE (anchor; VERIFY LIVE)
|
||||
(50, 11), # left_shoulder_1_joint -> L_SHOULDER
|
||||
(32, 12), # right_shoulder_1_joint -> R_SHOULDER
|
||||
(53, 13), # left_arm_joint -> L_ELBOW
|
||||
@@ -43,3 +49,43 @@ ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
|
||||
(65, 31), # left_toes_joint -> L_FOOT_INDEX
|
||||
(60, 32), # right_toes_joint -> R_FOOT_INDEX
|
||||
)
|
||||
|
||||
|
||||
def arkit_body_2d(arr2d) -> list:
|
||||
"""Map a 91×2 normalized ARKit 2D skeleton to a MediaPipe Pose-33 list.
|
||||
|
||||
Mapped slots get c=1.0; all other slots default to c=0.0 (low confidence).
|
||||
|
||||
Args:
|
||||
arr2d: array-like of shape (91, 2), image-normalized coords [0..1].
|
||||
|
||||
Returns:
|
||||
list of 33 PoseKp instances.
|
||||
"""
|
||||
from .state import PoseKp # noqa: PLC0415
|
||||
body: list = [PoseKp() for _ in range(MP33_NUM_LANDMARKS)]
|
||||
for ai, mp in ARKIT91_TO_MP33:
|
||||
if mp < MP33_NUM_LANDMARKS and ai < len(arr2d):
|
||||
body[mp] = PoseKp(x=float(arr2d[ai][0]), y=float(arr2d[ai][1]),
|
||||
z=0.0, c=1.0)
|
||||
return body
|
||||
|
||||
|
||||
def arkit_body_3d(arr3d) -> list:
|
||||
"""Map a 91×3 world ARKit skeleton to a MediaPipe Pose-33 Kp3D list.
|
||||
|
||||
Mapped slots get c=1.0; all other slots default to c=0.0 (low confidence).
|
||||
|
||||
Args:
|
||||
arr3d: array-like of shape (91, 3), metric world coords (metres).
|
||||
|
||||
Returns:
|
||||
list of 33 Kp3D instances.
|
||||
"""
|
||||
from .state import Kp3D # noqa: PLC0415
|
||||
body: list = [Kp3D() for _ in range(MP33_NUM_LANDMARKS)]
|
||||
for ai, mp in ARKIT91_TO_MP33:
|
||||
if mp < MP33_NUM_LANDMARKS and ai < len(arr3d):
|
||||
body[mp] = Kp3D(x=float(arr3d[ai][0]), y=float(arr3d[ai][1]),
|
||||
z=float(arr3d[ai][2]), c=1.0)
|
||||
return body
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Build line segments from a full ARKit skeleton + parent topology.
|
||||
|
||||
Pure functions, no Metal/state dependency, so they are unit-testable.
|
||||
The renderer feeds the resulting (ax, ay, bx, by) tuples straight into
|
||||
the GPU line buffer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def bones_from_parents(parents: list[int]) -> list[tuple[int, int]]:
|
||||
"""(child, parent) pairs for every joint with a valid parent."""
|
||||
n = len(parents)
|
||||
return [(i, p) for i, p in enumerate(parents) if 0 <= p < n]
|
||||
|
||||
|
||||
def arkit_segments(arr2d, valid, parents, max_len: float = 0.5, bounds: float = 0.1):
|
||||
"""Return (ax, ay, bx, by) for each bone with both endpoints valid.
|
||||
|
||||
arr2d: indexable of (x, y) normalized [0,1], length == len(parents).
|
||||
valid: indexable of bool (length == len(parents)) or None to keep all.
|
||||
parents: parent index per joint (-1 = root).
|
||||
max_len: maximum bone length in normalized units; bones longer than this
|
||||
are skipped (garbage coords during ARKit tracking loss).
|
||||
Caveat: on an extreme close-up (subject filling the frame) a
|
||||
legit bone can approach 0.5 — raise ARKIT_BONE_MAX live if
|
||||
the skeleton visibly loses bones when very near the camera.
|
||||
bounds: margin outside [0,1] that is still accepted; endpoints outside
|
||||
[-bounds, 1+bounds] in x or y are rejected.
|
||||
"""
|
||||
n = len(parents)
|
||||
lo = -bounds
|
||||
hi = 1.0 + bounds
|
||||
segs: list[tuple[float, float, float, float]] = []
|
||||
for child, parent in bones_from_parents(parents):
|
||||
if child >= n or parent >= n:
|
||||
continue
|
||||
if valid is not None and (not valid[child] or not valid[parent]):
|
||||
continue
|
||||
ax, ay = float(arr2d[child][0]), float(arr2d[child][1])
|
||||
bx, by = float(arr2d[parent][0]), float(arr2d[parent][1])
|
||||
# Reject out-of-frame endpoints
|
||||
if not (lo <= ax <= hi and lo <= ay <= hi):
|
||||
continue
|
||||
if not (lo <= bx <= hi and lo <= by <= hi):
|
||||
continue
|
||||
# Reject implausibly long bones (tracking-loss garbage)
|
||||
if math.hypot(bx - ax, by - ay) > max_len:
|
||||
continue
|
||||
segs.append((ax, ay, bx, by))
|
||||
return segs
|
||||
|
||||
|
||||
# ARKit's 91-joint body includes per-finger joints whose 2D projection is
|
||||
# unreliable (the user sees them as "false hands" glued to the skeleton).
|
||||
# The display uses the filtered Vision hands instead; these joints are
|
||||
# masked out of the wireframe. The wrist anchors (left_hand_joint /
|
||||
# right_hand_joint) are kept so the forearm bone still terminates.
|
||||
_FINGER_TOKENS = ("thumb", "index", "mid", "ring", "little", "pinky")
|
||||
|
||||
|
||||
def finger_joint_mask(names: list) -> list[bool]:
|
||||
"""True for each ARKit joint that belongs to a finger (to be hidden)."""
|
||||
out = []
|
||||
for n in names:
|
||||
ln = str(n).lower()
|
||||
out.append("hand" in ln and any(t in ln for t in _FINGER_TOKENS))
|
||||
return out
|
||||
@@ -0,0 +1,43 @@
|
||||
"""AVLiveWire topology payload codec.
|
||||
|
||||
Mirror of the Swift `TopologyPayload` (shared/AVLiveWire). Layout
|
||||
(big-endian): u16 jointCount; per joint [u8 nameLen + utf8 name];
|
||||
then per joint i16 parent (-1 = root).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
def encode_topology(names: list[str], parents: list[int]) -> bytes:
|
||||
out = bytearray()
|
||||
out += struct.pack(">H", len(names))
|
||||
for nm in names:
|
||||
b = nm.encode("utf-8")[:255]
|
||||
out.append(len(b))
|
||||
out += b
|
||||
for p in parents:
|
||||
out += struct.pack(">h", p)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_topology(payload: bytes) -> tuple[list[str], list[int]] | None:
|
||||
"""Return (joint_names, parents) or None if malformed."""
|
||||
if len(payload) < 2:
|
||||
return None
|
||||
n = struct.unpack(">H", payload[:2])[0]
|
||||
o = 2
|
||||
names: list[str] = []
|
||||
for _ in range(n):
|
||||
if o >= len(payload):
|
||||
return None
|
||||
ln = payload[o]
|
||||
o += 1
|
||||
if o + ln > len(payload):
|
||||
return None
|
||||
names.append(payload[o:o + ln].decode("utf-8", "replace"))
|
||||
o += ln
|
||||
if o + n * 2 > len(payload):
|
||||
return None
|
||||
parents = list(struct.unpack(">" + "h" * n, payload[o:o + n * 2]))
|
||||
return names, parents
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Central configuration for data_only_viz — single source of truth for env vars.
|
||||
|
||||
All os.environ reads for data_only_viz belong here.
|
||||
Consumers call ``VizConfig.from_env()`` inside their ``__init__`` or the
|
||||
consuming method — never at module import time so that test monkeypatching
|
||||
of os.environ is picked up correctly.
|
||||
|
||||
Env var reference table
|
||||
-----------------------
|
||||
Name Default Type Effect
|
||||
MEDIAPIPE_DELEGATE "gpu" str "gpu" (Metal) or "cpu"
|
||||
VIDEO_ROTATE "none" str none/ccw/cw/180 — rotate frame before MP+JPEG
|
||||
POSE_GHOST_MIN_VISIBLE 10 int min visible kps to accept a body detection
|
||||
POSE_GHOST_MIN_CONF 0.5 float min mean confidence to keep a detection
|
||||
POSE_HAND_MIN_VISIBLE 15 int visibility gate for hand kps
|
||||
POSE_FACE_MIN_VISIBLE 50 int visibility gate for face kps
|
||||
POSE_NMS_IOU 0.7 float IoU threshold for body NMS
|
||||
POSE_FILTER None str filter stage list (+/-sep); None=defaults
|
||||
POSE_FILTER_FACE None str filter stages for face; None=defaults
|
||||
POSE_FILTER_HAND None str filter stages for hands; None=defaults
|
||||
FINGER_PIANO "0" bool enable air-piano finger-strike emission
|
||||
FINGER_STRIKE_VEL 0.02 float downward relative-velocity threshold
|
||||
FINGER_STRIKE_REFRACTORY_MS 120 float min ms between strikes per finger
|
||||
FINGER_SOURCE "auto" str "auto"/"iphone"/"mediapipe"
|
||||
FINGER_DEBUG "0" bool log each detected strike/pinch
|
||||
PINCH_ENABLE "0" bool enable pinch gesture detection
|
||||
PINCH_RATIO_ON 0.45 float thumb-to-finger dist/hand-size to engage
|
||||
PINCH_RATIO_OFF 0.65 float dist/size above which pinch re-arms
|
||||
PINCH_REFRACTORY_MS 250 float min ms between pinches per finger
|
||||
PINCH_MARGIN 0.20 float extra margin added to pinch zone
|
||||
PINCH_EXT_RATIO 1.35 float extended-pinch activation ratio
|
||||
PINCH_EXT_MIN 2 int min extended fingers for a pinch to fire; 0 disables open-hand gate
|
||||
PINCH_DEBOUNCE_FRAMES 3 int debounce frames for pinch state machine
|
||||
HAND_CONF_MIN 0.45 float min hand landmark confidence to render
|
||||
HAND_PERSIST_FRAMES 3 int consecutive frames before a hand track is drawn; 1=off
|
||||
HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it)
|
||||
HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near"
|
||||
HAND_FACE_MIN 0.5 float palm-spread ratio threshold: slot activates gestures only when facing >= this (side-on < 0.4, facing ~0.7-1.0)
|
||||
HAND_PERSIST_GRACE 2 int grace frames before dropping a hand track on Vision miss (0 = strict)
|
||||
HAND_NEAR_OFF 0.08 float hand size below which near gate deactivates (hysteresis off-threshold)
|
||||
HAND_HOLD_FRAMES 2 int gesture slot hold: frames to carry last hand on Vision miss
|
||||
ARKIT_BONE_MAX 0.5 float max bone length (norm) for ARKit skeleton
|
||||
ARKIT_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True)
|
||||
VIZ_HUD "0" bool show debug HUD overlay
|
||||
VIZ_SOURCE_KEYS "0" bool wxcvbn + 0-9 source-bundle shortcuts (off: perf safety)
|
||||
VIZ_AUDIO_KEYS "0" bool qsdfghjkl audio-scene shortcuts (off: perf safety; m stays)
|
||||
VIZ_DEFAULT_MODE "auto" str fixed boot viz mode (e.g. voronoi); disables auto-openpos
|
||||
SKEL_LINE_WIDTH "4" int body skeleton stroke width, approx px (1 = thin)
|
||||
IPHONE_OSC_PORT 57128 int UDP port for iPhone ARBodyTracker /body3d/kp
|
||||
CONCERT_MIRROR "1" bool mirror video horizontally (!=0 = True)
|
||||
ICP_FUSION "0" bool enable ICP LiDAR fusion (only "1" = True)
|
||||
ICP_LIDAR_HOST None str iPhone LiDAR stream host when ICP_FUSION=1
|
||||
ICP_LIDAR_PORT 5500 int iPhone LiDAR stream port
|
||||
ICP_LIDAR_EXTRINSIC None str path to extrinsic JSON for ICP
|
||||
MULTIHMR_BACKEND "pytorch" str "pytorch"/"coreml"/"remote"
|
||||
MULTIHMR_LOOP_FPS 30.0 float Multi-HMR worker loop target fps
|
||||
MULTIHMR_AUTOCAST "0" bool MPS fp16 autocast for Multi-HMR (only "1")
|
||||
MULTIHMR_REMOTE_HOST "192.168.0.175" str remote Multi-HMR server host
|
||||
MULTIHMR_REMOTE_PORT 57140 int remote Multi-HMR server port
|
||||
MULTIHMR_REMOTE_JPEG True bool send JPEG-compressed frames to remote
|
||||
MULTIHMR_REMOTE_JPEG_QUALITY 80 int JPEG quality for remote frames
|
||||
MULTIHMR_REMOTE_ASYNC True bool async pipeline for remote backend
|
||||
MULTIHMR_REID "dino" str reid mode: "dino" or "iou"
|
||||
MULTIHMR_REID_ALPHA 0.5 float DINOv2 weight in hybrid reid score
|
||||
COREML_MLPACKAGE None str path to .mlpackage (None=default cache path)
|
||||
COREML_COMPUTE_UNITS "" str "cpu_only"/"cpu_and_gpu"/"all"/"cpu_and_ne"
|
||||
AVBODY_HOST "127.0.0.1" str host for AVLiveBody skeleton UDP/TCP
|
||||
VDMX_OSC_HOST None str VDMX VJ OSC host (None=disabled)
|
||||
VDMX_OSC_PORT 1234 int VDMX VJ OSC port
|
||||
AV_LIVE_MEDIAPIPE True bool enable MediaPipe Multi worker (!=0 = True)
|
||||
AV_LIVE_APPLE_VISION True bool enable Apple Vision fallback (!=0 = True)
|
||||
AV_LIVE_COREML True bool enable CoreML pose fallback (!=0 = True)
|
||||
AV_LIVE_DETRPOSE False bool enable DETRPose fallback (only "1" = True)
|
||||
AV_LIVE_PARALLEL_POSE "both" str parallel pose backend in multi-hmr mode
|
||||
MESH_RIG "1" bool enable SMPL-X mesh rigger (!=0 = True)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class VizConfig:
|
||||
"""Frozen snapshot of all data_only_viz environment configuration.
|
||||
|
||||
Construct with ``VizConfig.from_env()`` inside ``__init__`` or the
|
||||
consuming method — never at module import time.
|
||||
"""
|
||||
|
||||
# ---- MediaPipe / video -----------------------------------------------
|
||||
mediapipe_delegate: str = "gpu"
|
||||
video_rotate: str = "none"
|
||||
|
||||
# ---- Pose discrimination thresholds ----------------------------------
|
||||
pose_ghost_min_visible: int = 10
|
||||
pose_ghost_min_conf: float = 0.5
|
||||
pose_hand_min_visible: int = 15
|
||||
pose_face_min_visible: int = 50
|
||||
pose_nms_iou: float = 0.7
|
||||
|
||||
# ---- Pose filter chain -----------------------------------------------
|
||||
pose_filter: str | None = None
|
||||
pose_filter_face: str | None = None
|
||||
pose_filter_hand: str | None = None
|
||||
|
||||
# ---- Finger piano / strike -------------------------------------------
|
||||
finger_piano: bool = False
|
||||
finger_strike_vel: float = 0.02
|
||||
finger_strike_refractory_ms: float = 120.0
|
||||
finger_source: str = "auto"
|
||||
finger_debug: bool = False
|
||||
|
||||
# ---- Pinch detection -------------------------------------------------
|
||||
pinch_enable: bool = False
|
||||
pinch_ratio_on: float = 0.50
|
||||
pinch_ratio_off: float = 0.65
|
||||
pinch_refractory_ms: float = 250.0
|
||||
pinch_margin: float = 0.05
|
||||
pinch_ext_ratio: float = 1.35
|
||||
pinch_ext_min: int = 1
|
||||
pinch_debounce_frames: int = 3
|
||||
|
||||
# ---- Hand display / gesture ------------------------------------------
|
||||
hand_conf_min: float = 0.45
|
||||
hand_persist_frames: int = 3
|
||||
hand_persist_grace: int = 2
|
||||
hand_swap_lr: bool = False
|
||||
hand_near_min: float = 0.10
|
||||
hand_near_off: float = 0.08
|
||||
hand_hold_frames: int = 2
|
||||
hand_face_min: float = 0.5
|
||||
arkit_bone_max: float = 0.5
|
||||
arkit_full_skeleton: bool = True
|
||||
|
||||
# ---- HUD -------------------------------------------------------------
|
||||
viz_hud: bool = False
|
||||
# Source-bundle keyboard shortcuts (wxcvbn): OFF by default — a stray
|
||||
# keypress during a matrix performance switches the audio scene
|
||||
# (user-disabled live 2026-07-02). VIZ_SOURCE_KEYS=1 re-enables.
|
||||
viz_source_keys: bool = False
|
||||
# Audio-scene row (qsdfghjkl): same perf-safety gate, same date. The
|
||||
# special m key (matrix mode: scene stop + openpos viz) stays active.
|
||||
viz_audio_keys: bool = False
|
||||
# Fixed viz mode at boot (a viz_mode_names entry, e.g. "voronoi").
|
||||
# "auto" = historical behavior (mode 0 + auto-openpos when persons
|
||||
# appear). A fixed mode also DISABLES the auto-openpos timer so it is
|
||||
# truly always on (user request live 2026-07-02: t/voronoi permanent).
|
||||
viz_default_mode: str = "auto"
|
||||
# Approximate body-skeleton stroke width in pixels (multi-pass offsets;
|
||||
# Metal lines are 1px). 1 = thin historical look.
|
||||
skel_line_width: int = 4
|
||||
|
||||
# ---- iPhone OSC ------------------------------------------------------
|
||||
iphone_osc_port: int = 57128
|
||||
|
||||
# ---- Concert / ARKit -------------------------------------------------
|
||||
concert_mirror: bool = True
|
||||
|
||||
# ---- ICP LiDAR fusion ------------------------------------------------
|
||||
icp_fusion: bool = False
|
||||
icp_lidar_host: str | None = None
|
||||
icp_lidar_port: int = 5500
|
||||
icp_lidar_extrinsic: str | None = None
|
||||
|
||||
# ---- Multi-HMR -------------------------------------------------------
|
||||
multihmr_backend: str = "pytorch"
|
||||
multihmr_loop_fps: float = 30.0
|
||||
multihmr_autocast: bool = False
|
||||
multihmr_remote_host: str = "192.168.0.175"
|
||||
multihmr_remote_port: int = 57140
|
||||
multihmr_remote_jpeg: bool = True
|
||||
multihmr_remote_jpeg_quality: int = 80
|
||||
multihmr_remote_async: bool = True
|
||||
multihmr_reid: str = "dino"
|
||||
multihmr_reid_alpha: float = 0.5
|
||||
|
||||
# ---- CoreML ----------------------------------------------------------
|
||||
coreml_mlpackage: str | None = None
|
||||
coreml_compute_units: str = ""
|
||||
|
||||
# ---- OSC destinations ------------------------------------------------
|
||||
avbody_host: str = "127.0.0.1"
|
||||
vdmx_osc_host: str | None = None
|
||||
vdmx_osc_port: int = 1234
|
||||
|
||||
# ---- main.py worker selection ----------------------------------------
|
||||
av_live_mediapipe: bool = True
|
||||
av_live_apple_vision: bool = True
|
||||
av_live_coreml: bool = True
|
||||
av_live_detrpose: bool = False
|
||||
av_live_parallel_pose: str = "both"
|
||||
mesh_rig: bool = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, environ: Mapping[str, str] | None = None) -> "VizConfig":
|
||||
"""Build a VizConfig by reading the given mapping (default: os.environ).
|
||||
|
||||
Numeric conversion errors fall back to the field default silently.
|
||||
Bool conventions vary by field to match existing code exactly:
|
||||
- _bool_std: not in ("0","","false","False") → True (standard)
|
||||
- _bool_ne0: != "0" → True (empty string is True; None uses default)
|
||||
- _bool_eq1: == "1" only (all other values → False)
|
||||
- _bool_flag: in ("1","true","yes","on") → True (multihmr_remote style)
|
||||
"""
|
||||
env: Mapping[str, str] = environ if environ is not None else os.environ
|
||||
|
||||
_FALSE_STD = frozenset(("0", "", "false", "False"))
|
||||
|
||||
def _bool_std(name: str, default: bool) -> bool:
|
||||
v = env.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v not in _FALSE_STD
|
||||
|
||||
def _bool_ne0(name: str, default: bool) -> bool:
|
||||
"""Matches `env.get(name, "1") != "0"` style."""
|
||||
v = env.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v != "0"
|
||||
|
||||
def _bool_eq1(name: str) -> bool:
|
||||
"""Matches `env.get(name, "0") == "1"` style — only "1" is True."""
|
||||
return env.get(name, "") == "1"
|
||||
|
||||
def _bool_flag(name: str, default: bool) -> bool:
|
||||
"""Matches multihmr_remote _env_flag: in ("1","true","yes","on")."""
|
||||
v = env.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
v = env.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _float(name: str, default: float) -> float:
|
||||
v = env.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _str(name: str, default: str | None = None) -> str | None:
|
||||
v = env.get(name)
|
||||
return v if v is not None else default
|
||||
|
||||
def _str_lower(name: str, default: str) -> str:
|
||||
v = env.get(name)
|
||||
return v.lower() if v is not None else default
|
||||
|
||||
return cls(
|
||||
mediapipe_delegate=_str_lower("MEDIAPIPE_DELEGATE", "gpu"),
|
||||
video_rotate=_str_lower("VIDEO_ROTATE", "none"),
|
||||
pose_ghost_min_visible=_int("POSE_GHOST_MIN_VISIBLE", 10),
|
||||
pose_ghost_min_conf=_float("POSE_GHOST_MIN_CONF", 0.5),
|
||||
pose_hand_min_visible=_int("POSE_HAND_MIN_VISIBLE", 15),
|
||||
pose_face_min_visible=_int("POSE_FACE_MIN_VISIBLE", 50),
|
||||
pose_nms_iou=_float("POSE_NMS_IOU", 0.7),
|
||||
pose_filter=_str("POSE_FILTER"),
|
||||
pose_filter_face=_str("POSE_FILTER_FACE"),
|
||||
pose_filter_hand=_str("POSE_FILTER_HAND"),
|
||||
finger_piano=_bool_std("FINGER_PIANO", False),
|
||||
finger_strike_vel=_float("FINGER_STRIKE_VEL", 0.02),
|
||||
finger_strike_refractory_ms=_float("FINGER_STRIKE_REFRACTORY_MS", 120.0),
|
||||
finger_source=_str_lower("FINGER_SOURCE", "auto"),
|
||||
finger_debug=_bool_std("FINGER_DEBUG", False),
|
||||
pinch_enable=_bool_std("PINCH_ENABLE", False),
|
||||
pinch_ratio_on=_float("PINCH_RATIO_ON", 0.50),
|
||||
pinch_ratio_off=_float("PINCH_RATIO_OFF", 0.65),
|
||||
pinch_refractory_ms=_float("PINCH_REFRACTORY_MS", 250.0),
|
||||
pinch_margin=_float("PINCH_MARGIN", 0.05),
|
||||
pinch_ext_ratio=_float("PINCH_EXT_RATIO", 1.35),
|
||||
pinch_ext_min=_int("PINCH_EXT_MIN", 1),
|
||||
pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3),
|
||||
hand_conf_min=_float("HAND_CONF_MIN", 0.45),
|
||||
hand_persist_frames=_int("HAND_PERSIST_FRAMES", 3),
|
||||
hand_persist_grace=_int("HAND_PERSIST_GRACE", 2),
|
||||
hand_swap_lr=_bool_std("HAND_SWAP_LR", False),
|
||||
hand_near_min=_float("HAND_NEAR_MIN", 0.10),
|
||||
hand_near_off=_float("HAND_NEAR_OFF", 0.08),
|
||||
hand_hold_frames=_int("HAND_HOLD_FRAMES", 2),
|
||||
hand_face_min=_float("HAND_FACE_MIN", 0.5),
|
||||
arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5),
|
||||
# ARKIT_FULL_SKELETON uses != "0" convention (empty = True)
|
||||
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
|
||||
viz_hud=_bool_std("VIZ_HUD", False),
|
||||
viz_source_keys=_bool_std("VIZ_SOURCE_KEYS", False),
|
||||
viz_audio_keys=_bool_std("VIZ_AUDIO_KEYS", False),
|
||||
viz_default_mode=_str("VIZ_DEFAULT_MODE", "auto"),
|
||||
skel_line_width=_int("SKEL_LINE_WIDTH", 4),
|
||||
iphone_osc_port=_int("IPHONE_OSC_PORT", 57128),
|
||||
concert_mirror=_bool_ne0("CONCERT_MIRROR", True),
|
||||
# ICP_FUSION / MULTIHMR_AUTOCAST use strict "1" convention
|
||||
icp_fusion=_bool_eq1("ICP_FUSION"),
|
||||
icp_lidar_host=_str("ICP_LIDAR_HOST"),
|
||||
icp_lidar_port=_int("ICP_LIDAR_PORT", 5500),
|
||||
icp_lidar_extrinsic=_str("ICP_LIDAR_EXTRINSIC"),
|
||||
multihmr_backend=(_str("MULTIHMR_BACKEND", "pytorch") or "pytorch").strip().lower(),
|
||||
multihmr_loop_fps=_float("MULTIHMR_LOOP_FPS", 30.0),
|
||||
multihmr_autocast=_bool_eq1("MULTIHMR_AUTOCAST"),
|
||||
multihmr_remote_host=_str("MULTIHMR_REMOTE_HOST", "192.168.0.175") or "192.168.0.175",
|
||||
multihmr_remote_port=_int("MULTIHMR_REMOTE_PORT", 57140),
|
||||
# MULTIHMR_REMOTE_JPEG/ASYNC use _env_flag convention
|
||||
multihmr_remote_jpeg=_bool_flag("MULTIHMR_REMOTE_JPEG", True),
|
||||
multihmr_remote_jpeg_quality=_int("MULTIHMR_REMOTE_JPEG_QUALITY", 80),
|
||||
multihmr_remote_async=_bool_flag("MULTIHMR_REMOTE_ASYNC", True),
|
||||
multihmr_reid=_str_lower("MULTIHMR_REID", "dino"),
|
||||
multihmr_reid_alpha=_float("MULTIHMR_REID_ALPHA", 0.5),
|
||||
coreml_mlpackage=_str("COREML_MLPACKAGE"),
|
||||
coreml_compute_units=(_str("COREML_COMPUTE_UNITS", "") or "").strip().lower(),
|
||||
avbody_host=_str("AVBODY_HOST", "127.0.0.1") or "127.0.0.1",
|
||||
vdmx_osc_host=_str("VDMX_OSC_HOST"),
|
||||
vdmx_osc_port=_int("VDMX_OSC_PORT", 1234),
|
||||
# AV_LIVE_* use != "0" convention (enabled by default when unset)
|
||||
av_live_mediapipe=_bool_ne0("AV_LIVE_MEDIAPIPE", True),
|
||||
av_live_apple_vision=_bool_ne0("AV_LIVE_APPLE_VISION", True),
|
||||
av_live_coreml=_bool_ne0("AV_LIVE_COREML", True),
|
||||
av_live_detrpose=_bool_eq1("AV_LIVE_DETRPOSE"),
|
||||
av_live_parallel_pose=_str("AV_LIVE_PARALLEL_POSE", "both") or "both",
|
||||
mesh_rig=_bool_ne0("MESH_RIG", True),
|
||||
)
|
||||
+114
-67
@@ -1,9 +1,12 @@
|
||||
"""Air-piano finger strike detection from raw MediaPipe hand joints.
|
||||
"""Air-piano finger strike detection from pre-routed hand slots.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Consumers call route_hands() (hand_slots) once per tick and pass the resulting
|
||||
2-element [left|None, right|None] list to FingerStrikeDetector.step() and
|
||||
PinchDetector.step(). slot 0 = left hand (matrix "MG"), slot 1 = right.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,26 +27,9 @@ MIDDLE_MCP: int = 9
|
||||
PINCH_TIPS: tuple[int, ...] = (8, 12, 16, 20)
|
||||
|
||||
|
||||
def order_hands_by_cx(hands: list, max_slots: int) -> list:
|
||||
"""Return up to max_slots hand-landmark lists ordered leftmost-first
|
||||
(slot 0 = L), matching HandFeatureExtractor / FingerStrikeDetector."""
|
||||
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 [lm for _cx, lm in cand[:max_slots]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrikeEvent:
|
||||
hand: int # 0 = left slot (leftmost cx), 1 = right slot
|
||||
hand: int # 0 = left slot, 1 = right slot
|
||||
finger: int # 0..4 = thumb, index, middle, ring, pinky
|
||||
strike_speed: float
|
||||
z: float
|
||||
@@ -70,27 +56,34 @@ class FingerStrikeDetector:
|
||||
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 reset_slot(self, slot: int) -> None:
|
||||
"""Clear per-finger prev_rel and re-arm for the given slot.
|
||||
|
||||
def step(self, hands: list, t_now: float) -> list[StrikeEvent]:
|
||||
ordered = self._slot_hands(hands)
|
||||
present = set(range(len(ordered)))
|
||||
Call this BEFORE step() when GestureSlotStabilizer.resumed_flags()
|
||||
reports a held -> real transition for that slot. During a Vision hole
|
||||
the stabilizer replays the last hand (frozen coords, vel=0), but when
|
||||
the real hand returns within the hold window the first delta compresses
|
||||
several frames of motion into one large velocity spike, causing a
|
||||
phantom strike. Clearing prev_rel makes the first real frame a
|
||||
no-op prime that restarts tracking from the new position.
|
||||
"""
|
||||
for f in range(5):
|
||||
self._state[slot][f].prev_rel = None
|
||||
self._state[slot][f].armed = True
|
||||
|
||||
def step(self, slotted: list, t_now: float) -> list[StrikeEvent]:
|
||||
"""Process a pre-routed 2-slot hand list [left|None, right|None].
|
||||
|
||||
Absent slots (None) reset their finger state so re-entry does not
|
||||
produce spurious velocity spikes.
|
||||
"""
|
||||
events: list[StrikeEvent] = []
|
||||
for slot, (_cx, lm) in enumerate(ordered):
|
||||
for slot, lm in enumerate(slotted):
|
||||
if lm is None:
|
||||
for f in range(5):
|
||||
self._state[slot][f].prev_rel = None
|
||||
self._state[slot][f].armed = True
|
||||
continue
|
||||
for f in range(5):
|
||||
tip = lm[FINGERTIPS[f]]
|
||||
base = lm[FINGER_BASES[f]]
|
||||
@@ -116,27 +109,23 @@ class FingerStrikeDetector:
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class PinchEvent:
|
||||
hand: int # 0 = left slot (leftmost cx), 1 = right slot
|
||||
hand: int # 0 = left slot, 1 = right slot
|
||||
finger: int # 1..4 = index, middle, ring, pinky (thumb is trigger)
|
||||
state: int = 1 # 1 = engage edge, 0 = release edge
|
||||
|
||||
|
||||
class _PinchState:
|
||||
__slots__ = ("engaged", "last_t")
|
||||
__slots__ = ("engaged", "last_t", "qual")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.engaged: bool = False
|
||||
self.last_t: float = -1e9
|
||||
self.qual: int = 0 # consecutive qualifying frames (debounce)
|
||||
|
||||
|
||||
class PinchDetector:
|
||||
@@ -148,18 +137,53 @@ class PinchDetector:
|
||||
"""
|
||||
|
||||
def __init__(self, ratio_on: float = 0.45, ratio_off: float = 0.65,
|
||||
refractory_ms: float = 250.0, history_slots: int = 2) -> None:
|
||||
refractory_ms: float = 250.0, history_slots: int = 2,
|
||||
margin: float = 0.20, ext_ratio: float = 1.35,
|
||||
ext_min: int = 0, debounce_frames: int = 1) -> None:
|
||||
self.ratio_on = ratio_on
|
||||
self.ratio_off = ratio_off
|
||||
self.refractory_s = refractory_ms / 1000.0
|
||||
# Only the single nearest fingertip may engage, and only if it is at
|
||||
# least `margin` (in size-normalized units) nearer than the runner-up.
|
||||
# Rejects the adjacent-finger ambiguity when fingers curl together.
|
||||
self.margin = margin
|
||||
# Open-hand gate: the winner may engage only when at least ext_min
|
||||
# of the 3 non-pinching fingers are extended (tip-to-wrist distance
|
||||
# above ext_ratio hand-sizes). Rejects relaxed-hand/fist false
|
||||
# pinches during full-body play. ext_min=0 disables the gate; the
|
||||
# live defaults come from the PINCH_EXT_* env vars (action_head_pub),
|
||||
# constructor defaults preserve legacy behavior.
|
||||
self.ext_ratio = ext_ratio
|
||||
self.ext_min = int(ext_min)
|
||||
# Engage fires only after debounce_frames consecutive qualifying
|
||||
# frames; release stays immediate. 1 = no debounce.
|
||||
self.debounce_frames = max(1, int(debounce_frames))
|
||||
self._state = [[_PinchState() for _ in range(4)]
|
||||
for _ in range(history_slots)]
|
||||
|
||||
def step(self, hands: list, t_now: float) -> list[PinchEvent]:
|
||||
ordered = order_hands_by_cx(hands, len(self._state))
|
||||
present = set(range(len(ordered)))
|
||||
def engaged_slots(self) -> tuple[bool, bool]:
|
||||
"""Return (slot0_has_engaged_pinch, slot1_has_engaged_pinch)."""
|
||||
return (
|
||||
any(self._state[0][i].engaged for i in range(4)),
|
||||
any(self._state[1][i].engaged for i in range(4)),
|
||||
)
|
||||
|
||||
def step(self, slotted: list, t_now: float) -> list[PinchEvent]:
|
||||
"""Process a pre-routed 2-slot hand list [left|None, right|None].
|
||||
|
||||
Absent slots synthesise release edges for any engaged fingers and
|
||||
reset debounce state.
|
||||
"""
|
||||
events: list[PinchEvent] = []
|
||||
for slot, lm in enumerate(ordered):
|
||||
for slot, lm in enumerate(slotted):
|
||||
if lm is None:
|
||||
for i in range(4):
|
||||
st = self._state[slot][i]
|
||||
st.qual = 0
|
||||
if st.engaged:
|
||||
st.engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
continue
|
||||
tx = _finite(_coord(lm[THUMB_TIP], "x", 0), 0.5)
|
||||
ty = _finite(_coord(lm[THUMB_TIP], "y", 1), 0.5)
|
||||
wx = _finite(_coord(lm[WRIST], "x", 0), 0.5)
|
||||
@@ -168,21 +192,44 @@ class PinchDetector:
|
||||
my = _finite(_coord(lm[MIDDLE_MCP], "y", 1), 0.5)
|
||||
size = math.hypot(mx - wx, my - wy)
|
||||
size = size if size > 1e-4 else 1e-4
|
||||
for i, tip_idx in enumerate(PINCH_TIPS):
|
||||
ratios = []
|
||||
exts = []
|
||||
for tip_idx in PINCH_TIPS:
|
||||
fx = _finite(_coord(lm[tip_idx], "x", 0), 0.5)
|
||||
fy = _finite(_coord(lm[tip_idx], "y", 1), 0.5)
|
||||
ratio = math.hypot(fx - tx, fy - ty) / size
|
||||
ratios.append(math.hypot(fx - tx, fy - ty) / size)
|
||||
exts.append(math.hypot(fx - wx, fy - wy) / size)
|
||||
# closest-wins + margin: pick the single nearest fingertip, and treat
|
||||
# it as a pinch only if it is clearly nearer than the runner-up.
|
||||
order = sorted(range(4), key=lambda j: ratios[j])
|
||||
nearest, runner = order[0], order[1]
|
||||
# open-hand gate: a deliberate pinch keeps the other fingers
|
||||
# extended; a fist/relaxed hand has them curled at the wrist.
|
||||
open_ok = self.ext_min <= 0 or sum(
|
||||
1 for j in range(4)
|
||||
if j != nearest and exts[j] >= self.ext_ratio
|
||||
) >= self.ext_min
|
||||
winner = nearest if (
|
||||
ratios[nearest] < self.ratio_on
|
||||
and (ratios[runner] - ratios[nearest]) >= self.margin
|
||||
and open_ok
|
||||
) else -1
|
||||
for i in range(4):
|
||||
st = self._state[slot][i]
|
||||
if st.engaged:
|
||||
if ratio > self.ratio_off:
|
||||
# release when this finger opens, or another finger took over.
|
||||
if i != winner or ratios[i] > 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))
|
||||
for slot in range(len(self._state)):
|
||||
if slot not in present:
|
||||
for i in range(4):
|
||||
self._state[slot][i].engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
elif i == winner and (t_now - st.last_t) >= self.refractory_s:
|
||||
# engage only after debounce_frames consecutive qualifying
|
||||
# frames; release below stays edge-immediate.
|
||||
st.qual += 1
|
||||
if st.qual >= self.debounce_frames:
|
||||
st.engaged = True
|
||||
st.last_t = t_now
|
||||
st.qual = 0
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=1))
|
||||
else:
|
||||
st.qual = 0
|
||||
return events
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Pure geometry helpers for hand overlay filtering and panel rendering.
|
||||
|
||||
No Metal, no numpy, no pyobjc — safe to unit-test anywhere.
|
||||
|
||||
Panel frame legend (renderer.py):
|
||||
pid 7 conf=0.25+0.75*q → status 0: absent (q=0); status 1: detected (q≥0.30)
|
||||
pid 8 conf=0.25+0.75*q → status 2: armed (near+facing camera, q≥0.30)
|
||||
pid 9 conf=1.0 (q forced)→ status 3: pinch engaged
|
||||
Thickness: 1 stroke always; +1 at q≥0.50; +1 at q≥0.85 or status==3.
|
||||
See gesture_quality() for the continuous quality formula.
|
||||
|
||||
Position gauges (renderer.py, drawn around each panel):
|
||||
X gauge: horizontal rail BELOW panel (pid 7, conf 0.4 or 0.25 if absent);
|
||||
vertical notch marker at cx position (pid 5/6, conf 1.0).
|
||||
Y gauge: vertical rail on outer side (pid 7, conf 0.4 or 0.25 if absent);
|
||||
horizontal notch marker at cy position (pid 5/6, conf 1.0).
|
||||
See gauge_segments() — marker x is mirror-flipped if mirror=True.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel layout constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Panel square size in normalized HEIGHT units. A panel occupies
|
||||
#: PANEL_SIDE * view_height pixels on both axes (square in pixel space).
|
||||
PANEL_SIDE: float = 0.30
|
||||
|
||||
#: Gap between the screen edge and the near edge of the panel (normalized).
|
||||
PANEL_MARGIN: float = 0.02
|
||||
|
||||
#: Fraction of each panel dimension removed from every side to form the
|
||||
#: inner rect used as the drawing area for hand wireframes.
|
||||
PANEL_INNER: float = 0.10
|
||||
|
||||
|
||||
class _HasXYC(Protocol):
|
||||
x: float
|
||||
y: float
|
||||
c: float
|
||||
|
||||
|
||||
def arkit_2d_fresh(ts_by_pid: dict, now: float, max_age: float = 1.0) -> bool:
|
||||
"""Return True if any pid has a timestamp younger than *max_age* seconds.
|
||||
|
||||
Args:
|
||||
ts_by_pid: mapping pid -> perf_counter timestamp (from
|
||||
``State.persons_arkit_2d_t``).
|
||||
now: current ``time.perf_counter()`` value.
|
||||
max_age: staleness threshold in seconds (exclusive: age < max_age).
|
||||
|
||||
Returns:
|
||||
True if at least one entry satisfies ``now - ts < max_age``.
|
||||
False if the dict is empty or every entry is stale.
|
||||
"""
|
||||
for ts in ts_by_pid.values():
|
||||
if (now - ts) < max_age:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def hand_size(kp: list[_HasXYC]) -> float:
|
||||
"""Euclidean distance wrist(0) -> middle-MCP(9) in normalised image units."""
|
||||
w = kp[0]
|
||||
m = kp[9]
|
||||
return math.hypot(m.x - w.x, m.y - w.y)
|
||||
|
||||
|
||||
def hand_facing(kp: list) -> float:
|
||||
"""Palm-spread ratio for camera-facing detection.
|
||||
|
||||
Returns dist(index-MCP kp[5], pinky-MCP kp[17]) / hand_size(kp).
|
||||
A palm facing the camera spans ~70-100 % of hand_size; a side-on
|
||||
hand collapses to < 40 %. Returns 0.0 when hand_size is ~0.
|
||||
|
||||
Accepts both attribute-style (.x/.y) and index-style ([x, y, ...]) kp.
|
||||
"""
|
||||
def _gx(p: object) -> float:
|
||||
return float(p.x) if hasattr(p, "x") else float(p[0]) # type: ignore[index]
|
||||
|
||||
def _gy(p: object) -> float:
|
||||
return float(p.y) if hasattr(p, "y") else float(p[1]) # type: ignore[index]
|
||||
|
||||
sz = math.hypot(_gx(kp[9]) - _gx(kp[0]), _gy(kp[9]) - _gy(kp[0]))
|
||||
if sz < 1e-6:
|
||||
return 0.0
|
||||
return math.hypot(_gx(kp[17]) - _gx(kp[5]), _gy(kp[17]) - _gy(kp[5])) / sz
|
||||
|
||||
|
||||
def hand_plausible(
|
||||
kp: list[_HasXYC],
|
||||
conf_min: float = 0.3,
|
||||
size_min: float = 0.02,
|
||||
size_max: float = 0.5,
|
||||
) -> bool:
|
||||
"""Return True if the hand landmark list looks like a real hand.
|
||||
|
||||
Rejects:
|
||||
- fewer than 21 landmarks
|
||||
- wrist (kp[0]) outside [-0.1, 1.1] in x or y (out-of-frame ghost anchor)
|
||||
- size (wrist->middle-MCP) outside [size_min, size_max]
|
||||
- mean confidence below conf_min
|
||||
"""
|
||||
if len(kp) < 21:
|
||||
return False
|
||||
w = kp[0]
|
||||
if not (-0.1 <= w.x <= 1.1 and -0.1 <= w.y <= 1.1):
|
||||
return False
|
||||
size = hand_size(kp)
|
||||
if size < size_min or size > size_max:
|
||||
return False
|
||||
mean_c = sum(p.c for p in kp) / len(kp)
|
||||
if mean_c < conf_min:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def segment_ok(
|
||||
A: _HasXYC,
|
||||
B: _HasXYC,
|
||||
size: float,
|
||||
conf_min: float = 0.3,
|
||||
max_bone_ratio: float = 1.2,
|
||||
) -> bool:
|
||||
"""Return True if the bone segment between A and B is plausible.
|
||||
|
||||
Rejects:
|
||||
- min endpoint confidence below conf_min
|
||||
- bone length exceeding max_bone_ratio * size
|
||||
"""
|
||||
if min(A.c, B.c) < conf_min:
|
||||
return False
|
||||
bone_len = math.hypot(B.x - A.x, B.y - A.y)
|
||||
if bone_len > max_bone_ratio * size:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def panel_rect(side: str, aspect: float) -> tuple[float, float, float, float]:
|
||||
"""Normalized [0,1] screen rect of the hand side panel.
|
||||
|
||||
The panel is square in pixel space:
|
||||
pixel_width = norm_width * view_width = (PANEL_SIDE / aspect) * aspect * view_height
|
||||
= PANEL_SIDE * view_height = pixel_height.
|
||||
|
||||
Args:
|
||||
side: "left" or "right".
|
||||
aspect: view width / view height (> 0).
|
||||
|
||||
Returns:
|
||||
(x0, y0, x1, y1) in normalized [0,1] coords, y down, origin top-left.
|
||||
"""
|
||||
norm_w = PANEL_SIDE / aspect # normalized width (square in pixel space)
|
||||
norm_h = PANEL_SIDE # normalized height
|
||||
y0 = 0.5 - norm_h / 2.0
|
||||
y1 = 0.5 + norm_h / 2.0
|
||||
if side == "left":
|
||||
x0 = PANEL_MARGIN
|
||||
x1 = PANEL_MARGIN + norm_w
|
||||
else: # "right"
|
||||
x1 = 1.0 - PANEL_MARGIN
|
||||
x0 = x1 - norm_w
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def panel_frame(side: str, aspect: float) -> list[tuple[float, float, float, float]]:
|
||||
"""Four border segments tracing the panel rect (clock-wise, starting top).
|
||||
|
||||
Returns:
|
||||
List of (ax, ay, bx, by) in normalized [0,1] screen coords, y down.
|
||||
"""
|
||||
x0, y0, x1, y1 = panel_rect(side, aspect)
|
||||
return [
|
||||
(x0, y0, x1, y0), # top edge (left→right)
|
||||
(x1, y0, x1, y1), # right edge (top→bottom)
|
||||
(x1, y1, x0, y1), # bottom edge (right→left)
|
||||
(x0, y1, x0, y0), # left edge (bottom→top)
|
||||
]
|
||||
|
||||
|
||||
def panel_segments(
|
||||
kp: list[_HasXYC],
|
||||
side: str,
|
||||
bones: list[tuple[int, int]],
|
||||
aspect: float,
|
||||
mirror: bool = True,
|
||||
conf_min: float = 0.3,
|
||||
) -> list[tuple[float, float, float, float]]:
|
||||
"""Map hand landmarks into a side panel using uniform pixel-space scale.
|
||||
|
||||
The hand bounding box is fit into the inner panel rect (panel rect shrunk
|
||||
by PANEL_INNER fraction on each side) with a uniform pixel-space scale —
|
||||
meaning the pixel aspect ratio of the hand is preserved regardless of the
|
||||
view aspect ratio.
|
||||
|
||||
Scale derivation:
|
||||
s = min(inner_pixel_w / bbox_pixel_w, inner_pixel_h / bbox_pixel_h)
|
||||
= min(iw / kw, ih / kh)
|
||||
where iw/kw are normalized-coord ratios whose view dimensions cancel.
|
||||
Moving Δkp.x in kp-x corresponds to Δkp.x * s in normalized screen x, and
|
||||
Δkp.y in kp-y corresponds to Δkp.y * s in normalized screen y — preserving
|
||||
the original pixel ratio between any two segment endpoints.
|
||||
|
||||
When mirror=True, X is flipped within the panel so the zoomed hand matches
|
||||
the mirrored video background (i.e. the panel always shows a "front view").
|
||||
|
||||
Args:
|
||||
kp: 21+ keypoints with .x, .y (normalized image coords, y down), .c.
|
||||
side: "left" or "right".
|
||||
bones: List of (index_a, index_b) pairs defining which keypoints to connect.
|
||||
aspect: view width / view height.
|
||||
mirror: If True, flip X within the panel.
|
||||
conf_min: Minimum confidence for hand_plausible and segment_ok checks.
|
||||
|
||||
Returns:
|
||||
List of (ax, ay, bx, by) in normalized [0,1] screen coords, y down.
|
||||
Returns [] if hand_plausible(kp) fails.
|
||||
"""
|
||||
if not hand_plausible(kp, conf_min=conf_min):
|
||||
return []
|
||||
|
||||
x0, y0, x1, y1 = panel_rect(side, aspect)
|
||||
pw = x1 - x0
|
||||
ph = y1 - y0
|
||||
|
||||
# Inner rect: shrink by PANEL_INNER fraction on every side
|
||||
ix0 = x0 + PANEL_INNER * pw
|
||||
ix1 = x1 - PANEL_INNER * pw
|
||||
iy0 = y0 + PANEL_INNER * ph
|
||||
iy1 = y1 - PANEL_INNER * ph
|
||||
iw = ix1 - ix0
|
||||
ih = iy1 - iy0
|
||||
|
||||
# Keypoint bounding box in normalized image coords
|
||||
kx_min = min(p.x for p in kp)
|
||||
kx_max = max(p.x for p in kp)
|
||||
ky_min = min(p.y for p in kp)
|
||||
ky_max = max(p.y for p in kp)
|
||||
kw = kx_max - kx_min
|
||||
kh = ky_max - ky_min
|
||||
|
||||
if kw == 0.0 and kh == 0.0:
|
||||
return []
|
||||
|
||||
# Uniform pixel-space scale factor (view dimensions cancel in the ratio)
|
||||
if kw > 0.0 and kh > 0.0:
|
||||
s = min(iw / kw, ih / kh)
|
||||
elif kw > 0.0:
|
||||
s = iw / kw
|
||||
else:
|
||||
s = ih / kh
|
||||
|
||||
# Centre the scaled hand bbox inside the inner rect
|
||||
offset_x = (iw - kw * s) / 2.0
|
||||
offset_y = (ih - kh * s) / 2.0
|
||||
|
||||
def _map(p: _HasXYC) -> tuple[float, float]:
|
||||
rel_x = (kx_max - p.x) if mirror else (p.x - kx_min)
|
||||
nx = ix0 + offset_x + rel_x * s
|
||||
ny = iy0 + offset_y + (p.y - ky_min) * s
|
||||
return nx, ny
|
||||
|
||||
sz = hand_size(kp)
|
||||
result: list[tuple[float, float, float, float]] = []
|
||||
for a, b in bones:
|
||||
if a >= len(kp) or b >= len(kp):
|
||||
continue
|
||||
A = kp[a]
|
||||
B = kp[b]
|
||||
if not segment_ok(A, B, sz, conf_min=conf_min):
|
||||
continue
|
||||
ax, ay = _map(A)
|
||||
bx, by = _map(B)
|
||||
result.append((ax, ay, bx, by))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hand temporal persistence gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HandPersistenceGate:
|
||||
"""Suppress ghost hand detections that only flash for 1-2 frames.
|
||||
|
||||
Tracks each hand by wrist position (landmark 0). A hand is only
|
||||
drawable once the same wrist position has been matched for
|
||||
*min_frames* consecutive calls.
|
||||
|
||||
With ``grace=0`` (default) a track that goes unmatched for even one
|
||||
frame is immediately dropped (resets the consecutive count). With
|
||||
``grace>0`` an unmatched track survives up to *grace* consecutive
|
||||
missed calls, so a single Vision drop-frame does not wipe the
|
||||
accumulated count and cause a 4-frame visual flicker.
|
||||
|
||||
min_frames=1 disables gating: every incoming hand is drawable on the
|
||||
first call (useful for the single-person MediaPipe fallback path).
|
||||
|
||||
Known limit: matching is greedy nearest-track — two hands crossing
|
||||
within `radius` can inherit each other's track (and a ghost appearing
|
||||
exactly where a real hand just left inherits its count). Grace widens
|
||||
that inheritance window to `grace` frames after the hand leaves, and a
|
||||
ghost re-flashing at one spot every (grace+1)th frame can accumulate to
|
||||
min_frames. Acceptable for a display gate; do not reuse for
|
||||
gesture-state tracking.
|
||||
"""
|
||||
|
||||
def __init__(self, min_frames: int = 3, radius: float = 0.15, grace: int = 0) -> None:
|
||||
self._min_frames = min_frames
|
||||
self._radius = radius
|
||||
self._grace = grace
|
||||
# Each track: [wrist_x, wrist_y, consecutive_count, miss_count]
|
||||
self._tracks: list[list] = []
|
||||
|
||||
def step(self, hands: list) -> list[bool]:
|
||||
"""Update tracks and return a draw flag per hand.
|
||||
|
||||
Args:
|
||||
hands: list of 21-landmark hand objects; landmark 0 is the wrist
|
||||
and must expose .x and .y attributes.
|
||||
|
||||
Returns:
|
||||
list[bool] of length len(hands). True = this hand has been
|
||||
seen at the same position for at least min_frames consecutive
|
||||
calls and should be drawn.
|
||||
"""
|
||||
result: list[bool] = [False] * len(hands)
|
||||
new_tracks: list[list] = []
|
||||
used: set[int] = set()
|
||||
|
||||
for h_idx, hand in enumerate(hands):
|
||||
try:
|
||||
wx = hand[0].x
|
||||
wy = hand[0].y
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
continue # malformed entry: never drawable, no track
|
||||
|
||||
# Find the nearest existing track within radius (greedy).
|
||||
best_dist = float("inf")
|
||||
best_t = -1
|
||||
for t_idx, track in enumerate(self._tracks):
|
||||
if t_idx in used:
|
||||
continue
|
||||
tx, ty = track[0], track[1]
|
||||
d = math.hypot(wx - tx, wy - ty)
|
||||
if d < best_dist and d < self._radius:
|
||||
best_dist = d
|
||||
best_t = t_idx
|
||||
|
||||
if best_t >= 0:
|
||||
used.add(best_t)
|
||||
new_count = self._tracks[best_t][2] + 1
|
||||
new_tracks.append([wx, wy, new_count, 0]) # reset miss
|
||||
result[h_idx] = new_count >= self._min_frames
|
||||
else:
|
||||
# New track — starts at count 1, miss 0.
|
||||
new_tracks.append([wx, wy, 1, 0])
|
||||
result[h_idx] = 1 >= self._min_frames
|
||||
|
||||
# Carry forward unmatched tracks within grace window.
|
||||
for t_idx, track in enumerate(self._tracks):
|
||||
if t_idx not in used:
|
||||
miss = track[3] + 1
|
||||
if miss <= self._grace:
|
||||
new_tracks.append([track[0], track[1], track[2], miss])
|
||||
|
||||
self._tracks = new_tracks
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gauge layout constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Normalised gap between a panel edge and its adjacent gauge rail.
|
||||
GAUGE_GAP: float = 0.012
|
||||
|
||||
#: Half the tick length (total = 2 * GAUGE_TICK_HALF), in normalised coords.
|
||||
GAUGE_TICK_HALF: float = 0.004
|
||||
|
||||
#: Parallel offset for the second stroke of a bold-notch marker.
|
||||
GAUGE_BOLD: float = 0.001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Continuous quality score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gesture_quality(
|
||||
hand: object,
|
||||
*,
|
||||
face_min: float,
|
||||
near_on: float,
|
||||
engaged: bool = False,
|
||||
) -> float:
|
||||
"""Continuous quality score ∈ [0, 1] for one gesture slot.
|
||||
|
||||
Drives panel frame brightness and stroke thickness in renderer.py.
|
||||
|
||||
Args:
|
||||
hand: 21-kp hand object (attribute-style .x/.y) or None.
|
||||
``None`` → 0.0 (slot absent or not yet validated by gates).
|
||||
face_min: palm-spread ratio at which the slot is considered fully
|
||||
armed (from GestureSlotStabilizer; default env 0.5).
|
||||
near_on: wrist-to-middle-MCP distance (normalised) at which the slot
|
||||
is fully near (from GestureSlotStabilizer; default 0.10).
|
||||
engaged: True when a pinch is held for this slot → forces 1.0.
|
||||
|
||||
Returns:
|
||||
quality ∈ [0, 1]:
|
||||
* 0.0 when ``hand`` is None.
|
||||
* 1.0 when ``engaged`` is True.
|
||||
* else: 0.30·established + 0.35·facing_norm + 0.35·near_norm
|
||||
where established = 1.0 (hand passed plausibility + temporal gates),
|
||||
facing_norm = clamp((hand_facing(hand) − 0.25) / (face_min − 0.25), 0, 1),
|
||||
near_norm = clamp((hand_size(hand) − 0.5·near_on) / (0.5·near_on), 0, 1).
|
||||
"""
|
||||
if hand is None:
|
||||
return 0.0
|
||||
if engaged:
|
||||
return 1.0
|
||||
# facing_norm: 0 at facing = 0.25, 1 at facing = face_min.
|
||||
denom_f = face_min - 0.25
|
||||
if denom_f > 1e-9:
|
||||
facing_norm = min(1.0, max(0.0, (hand_facing(hand) - 0.25) / denom_f))
|
||||
else:
|
||||
# face_min ≤ 0.25 — any spread qualifies.
|
||||
facing_norm = 1.0
|
||||
# near_norm: 0 at hand_size = 0.5·near_on, 1 at hand_size = near_on.
|
||||
half_on = 0.5 * near_on
|
||||
if half_on > 1e-9:
|
||||
near_norm = min(1.0, max(0.0, (hand_size(hand) - half_on) / half_on))
|
||||
else:
|
||||
near_norm = 1.0
|
||||
return 0.30 + 0.35 * facing_norm + 0.35 * near_norm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position gauges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gauge_segments(
|
||||
cx: float | None,
|
||||
cy: float | None,
|
||||
side: str,
|
||||
aspect: float,
|
||||
mirror: bool = False,
|
||||
*,
|
||||
content_pid: int = 5,
|
||||
rail_pid: int = 7,
|
||||
) -> list[tuple[float, float, float, float, float, int]]:
|
||||
"""Rail + marker segments for the X and Y position gauges around a panel.
|
||||
|
||||
X gauge: a horizontal rail placed GAUGE_GAP below the panel bottom edge,
|
||||
spanning the full panel width. A bold vertical notch marks the
|
||||
hand's cx position on the rail.
|
||||
|
||||
Y gauge: a vertical rail placed GAUGE_GAP outside the panel's outer edge
|
||||
(left of the left panel, right of the right panel), spanning the
|
||||
full panel height. A bold horizontal notch marks cy.
|
||||
|
||||
Args:
|
||||
cx: Normalised hand centre-x ∈ [0, 1], or None when absent.
|
||||
cy: Normalised hand centre-y ∈ [0, 1], or None when absent.
|
||||
side: "left" or "right".
|
||||
aspect: View width / view height (> 0).
|
||||
mirror: If True, flip the cx marker position (1 − cx) so it moves
|
||||
in the same direction as the mirrored video. cy is never
|
||||
flipped.
|
||||
content_pid: pid for marker segments (5 = left slot, 6 = right slot).
|
||||
rail_pid: pid for rail segments (default 7).
|
||||
|
||||
Returns:
|
||||
List of (ax, ay, bx, by, conf, pid) tuples ready to feed
|
||||
``push_panel`` in the renderer. When cx/cy is None the rails are
|
||||
drawn dim (conf 0.25) with no marker; otherwise rails use conf 0.4
|
||||
and markers use conf 1.0.
|
||||
"""
|
||||
x0, y0, x1, y1 = panel_rect(side, aspect)
|
||||
has_data = cx is not None and cy is not None
|
||||
rail_conf = 0.4 if has_data else 0.25
|
||||
out: list[tuple[float, float, float, float, float, int]] = []
|
||||
|
||||
# ---- X gauge: horizontal rail below panel ----
|
||||
xg_y = y1 + GAUGE_GAP
|
||||
out.append((x0, xg_y, x1, xg_y, rail_conf, rail_pid))
|
||||
if has_data:
|
||||
assert cx is not None and cy is not None # narrowing for type checkers
|
||||
cx_eff = 1.0 - cx if mirror else cx
|
||||
mx = x0 + cx_eff * (x1 - x0)
|
||||
# Bold vertical notch: two parallel vertical ticks
|
||||
out.append((mx, xg_y - GAUGE_TICK_HALF, mx, xg_y + GAUGE_TICK_HALF,
|
||||
1.0, content_pid))
|
||||
out.append((mx + GAUGE_BOLD, xg_y - GAUGE_TICK_HALF,
|
||||
mx + GAUGE_BOLD, xg_y + GAUGE_TICK_HALF,
|
||||
1.0, content_pid))
|
||||
|
||||
# ---- Y gauge: vertical rail on outer side ----
|
||||
if side == "left":
|
||||
yg_x = x0 - GAUGE_GAP
|
||||
else:
|
||||
yg_x = x1 + GAUGE_GAP
|
||||
out.append((yg_x, y0, yg_x, y1, rail_conf, rail_pid))
|
||||
if has_data:
|
||||
assert cx is not None and cy is not None
|
||||
my = y0 + cy * (y1 - y0)
|
||||
# Bold horizontal notch: two parallel horizontal ticks
|
||||
out.append((yg_x - GAUGE_TICK_HALF, my, yg_x + GAUGE_TICK_HALF, my,
|
||||
1.0, content_pid))
|
||||
out.append((yg_x - GAUGE_TICK_HALF, my + GAUGE_BOLD,
|
||||
yg_x + GAUGE_TICK_HALF, my + GAUGE_BOLD,
|
||||
1.0, content_pid))
|
||||
|
||||
return out
|
||||
@@ -10,10 +10,18 @@ from collections import deque
|
||||
|
||||
WRIST, THUMB_TIP, MIDDLE_MCP, PINKY_TIP = 0, 4, 9, 20
|
||||
|
||||
NEUTRAL_HAND = {"cx": 0.5, "cy": 0.5, "openness": 0.0, "speed": 0.0}
|
||||
# index/middle/ring/pinky tips + their MCP knuckles, for the "all fingers
|
||||
# curled" fist metric (thumb excluded — a fist curls the four fingers).
|
||||
_FINGER_TIPS = (8, 12, 16, 20)
|
||||
_FINGER_MCPS = (5, 9, 13, 17)
|
||||
|
||||
NEUTRAL_HAND = {"cx": 0.5, "cy": 0.5, "openness": 0.0, "speed": 0.0, "fist": 0.0}
|
||||
|
||||
# openness calibration: (span/size) maps fist~0.3 -> 0, open~2.0 -> 1
|
||||
_OPEN_LO, _OPEN_HI = 0.5, 2.0
|
||||
# fist calibration: per-finger tip->MCP distance / hand size. curled ~0.25,
|
||||
# extended ~0.75. closedness = 1 at/below LO, 0 at/above HI.
|
||||
_EXT_LO, _EXT_HI = 0.25, 0.75
|
||||
|
||||
|
||||
def _finite(v: float, fallback: float) -> float:
|
||||
@@ -35,23 +43,46 @@ def _clamp(v: float, lo: float, hi: float) -> float:
|
||||
|
||||
|
||||
class HandFeatureExtractor:
|
||||
def __init__(self, history: int = 5):
|
||||
def __init__(self, history: int = 5, fist_enable: bool | None = None):
|
||||
self._history = max(2, history)
|
||||
# two screen slots: 0 = leftmost (L), 1 = rightmost (R)
|
||||
# two routed slots (hand_slots.route_hands): 0 = user's left (L),
|
||||
# 1 = user's right (R); cx fallback when chirality is absent.
|
||||
self._buf = [deque(maxlen=self._history), deque(maxlen=self._history)]
|
||||
# Master fist kill-switch: user-disabled live 2026-07-02 (too many
|
||||
# false held-fist actions). fist stays 0.0 for BOTH hands whatever
|
||||
# the per-slot fist_enabled kwarg says. FIST_ENABLE=1 re-enables.
|
||||
# TODO: migrate the env read to VizConfig once config.py is free.
|
||||
if fist_enable is None:
|
||||
import os
|
||||
fist_enable = os.environ.get("FIST_ENABLE", "0") not in (
|
||||
"0", "", "false", "False",
|
||||
)
|
||||
self._fist_enable = fist_enable
|
||||
|
||||
def _features(self, lm: list) -> dict:
|
||||
xs = [_finite(_coord(p, "x", 0), 0.5) for p in lm[:21]]
|
||||
ys = [_finite(_coord(p, "y", 1), 0.5) for p in lm[:21]]
|
||||
cx = _clamp(sum(xs) / len(xs), 0.0, 1.0)
|
||||
cy = _clamp(sum(ys) / len(ys), 0.0, 1.0)
|
||||
# cx/cy anchor = the WRIST (kp 0), not the landmark centroid: the
|
||||
# centroid shifts when fingers open/pinch, cross-talking gestures
|
||||
# into the X/Y voice mods. The wrist decouples them (user request
|
||||
# live 2026-07-02).
|
||||
cx = _clamp(xs[WRIST], 0.0, 1.0)
|
||||
cy = _clamp(ys[WRIST], 0.0, 1.0)
|
||||
size = math.hypot(xs[MIDDLE_MCP] - xs[WRIST], ys[MIDDLE_MCP] - ys[WRIST])
|
||||
size = size if size > 1e-4 else 1e-4
|
||||
span = math.hypot(xs[THUMB_TIP] - xs[PINKY_TIP],
|
||||
ys[THUMB_TIP] - ys[PINKY_TIP])
|
||||
openness = _clamp((span / size - _OPEN_LO) / (_OPEN_HI - _OPEN_LO),
|
||||
0.0, 1.0)
|
||||
return {"cx": cx, "cy": cy, "openness": openness}
|
||||
# fist = 1.0 only when ALL four fingers are curled to the palm (min over
|
||||
# fingers): one extended finger drops it toward 0, so a true fist is
|
||||
# required rather than just a small thumb-pinky span.
|
||||
fist = 1.0
|
||||
for tip, mcp in zip(_FINGER_TIPS, _FINGER_MCPS):
|
||||
ext = math.hypot(xs[tip] - xs[mcp], ys[tip] - ys[mcp]) / size
|
||||
curled = _clamp(1.0 - (ext - _EXT_LO) / (_EXT_HI - _EXT_LO), 0.0, 1.0)
|
||||
fist = min(fist, curled)
|
||||
return {"cx": cx, "cy": cy, "openness": openness, "fist": fist}
|
||||
|
||||
def _speed(self, slot: int, cx: float, cy: float) -> float:
|
||||
buf = self._buf[slot]
|
||||
@@ -62,31 +93,46 @@ class HandFeatureExtractor:
|
||||
buf.append((cx, cy))
|
||||
return _clamp(spd, 0.0, 1.0)
|
||||
|
||||
def step(self, hands: list) -> dict:
|
||||
feats = []
|
||||
for lm in hands:
|
||||
try:
|
||||
valid = lm is not None and len(lm) >= 21
|
||||
except TypeError:
|
||||
continue
|
||||
if valid:
|
||||
feats.append(self._features(lm))
|
||||
if not feats:
|
||||
def step(self, hands: list, chirality=None, swap: bool = False,
|
||||
fist_enabled: tuple = (True, True), mirror: bool = False) -> dict:
|
||||
from data_only_viz.hand_slots import route_hands
|
||||
# swap must mirror the gesture path (HAND_SWAP_LR) or per-voice
|
||||
# L/R mods contradict pinch/strike in the same performance.
|
||||
# mirror must match state.mirror_2d so the cx fallback slot assignment
|
||||
# stays coherent with the renderer panel layout on chirality dropout.
|
||||
left_lm, right_lm = route_hands(hands, chirality, swap=swap, mirror=mirror)
|
||||
|
||||
if left_lm is None and right_lm is None:
|
||||
self._buf[0].clear()
|
||||
self._buf[1].clear()
|
||||
return {"L": None, "R": None, "dist": 0.0}
|
||||
feats.sort(key=lambda f: f["cx"]) # leftmost first
|
||||
left = feats[0]
|
||||
right = feats[-1] if len(feats) > 1 else None
|
||||
left["speed"] = self._speed(0, left["cx"], left["cy"])
|
||||
out_l = left
|
||||
|
||||
out_l = None
|
||||
out_r = None
|
||||
dist = 0.0
|
||||
if right is not None:
|
||||
right["speed"] = self._speed(1, right["cx"], right["cy"])
|
||||
out_r = right
|
||||
dist = _clamp(math.hypot(right["cx"] - left["cx"],
|
||||
right["cy"] - left["cy"]), 0.0, 1.0)
|
||||
|
||||
if left_lm is not None:
|
||||
lf = self._features(left_lm)
|
||||
if not (self._fist_enable and fist_enabled[0]):
|
||||
lf["fist"] = 0.0
|
||||
lf["speed"] = self._speed(0, lf["cx"], lf["cy"])
|
||||
out_l = lf
|
||||
else:
|
||||
self._buf[0].clear()
|
||||
|
||||
if right_lm is not None:
|
||||
rf = self._features(right_lm)
|
||||
if not (self._fist_enable and fist_enabled[1]):
|
||||
rf["fist"] = 0.0
|
||||
rf["speed"] = self._speed(1, rf["cx"], rf["cy"])
|
||||
out_r = rf
|
||||
else:
|
||||
self._buf[1].clear()
|
||||
|
||||
dist = 0.0
|
||||
if out_l is not None and out_r is not None:
|
||||
dist = _clamp(
|
||||
math.hypot(out_r["cx"] - out_l["cx"], out_r["cy"] - out_l["cy"]),
|
||||
0.0, 1.0,
|
||||
)
|
||||
|
||||
return {"L": out_l, "R": out_r, "dist": dist}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Hand L/R slotting: route raw hands to a fixed [left|None, right|None] pair.
|
||||
|
||||
Chirality path (iPhone ARBodyTracker Vision): if `chirality` is aligned with
|
||||
`hands` (same length), route chir==0 -> slot 0 (left), chir==1 -> slot 1 (right).
|
||||
First match per slot wins; extras dropped.
|
||||
|
||||
Fallback (chirality absent/misaligned): sort valid hands by screen cx ascending
|
||||
(flipped when mirror=True) and assign leftmost to slot 0, next to slot 1.
|
||||
|
||||
`swap=True`: inverts the chirality INTERPRETATION only (chir 0 -> right slot,
|
||||
1 -> left slot). Safety knob for a source delivering flipped chirality;
|
||||
ARBodyTracker chirality was validated CORRECT live 2026-07-02, so
|
||||
HAND_SWAP_LR defaults to 0 in the consumers. The cx fallback is
|
||||
screen-relative and is never swapped.
|
||||
|
||||
`near_min > 0`: after routing, replace a slot with None when the hand's
|
||||
wrist-to-middle-MCP distance (normalised) is below `near_min`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# ---- micro-helpers imported from hand_features (acyclic: hand_features only
|
||||
# imports hand_slots lazily inside step(), so a top-level import here is safe) ---
|
||||
from .hand_features import _finite, _coord, _clamp
|
||||
from .hand_display import hand_facing as _hand_facing
|
||||
|
||||
|
||||
def _hand_size(hand) -> float:
|
||||
"""Wrist(0) -> middle-MCP(9) distance; works for both attr and list kp formats."""
|
||||
w = hand[0]
|
||||
m = hand[9]
|
||||
wx = _finite(_coord(w, "x", 0), 0.5)
|
||||
wy = _finite(_coord(w, "y", 1), 0.5)
|
||||
mx = _finite(_coord(m, "x", 0), 0.5)
|
||||
my = _finite(_coord(m, "y", 1), 0.5)
|
||||
return math.hypot(mx - wx, my - wy)
|
||||
|
||||
|
||||
def _is_valid(hand) -> bool:
|
||||
try:
|
||||
return hand is not None and len(hand) >= 21
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def route_hands(
|
||||
hands,
|
||||
chirality=None,
|
||||
*,
|
||||
mirror: bool = False,
|
||||
swap: bool = False,
|
||||
near_min: float = 0.0,
|
||||
) -> list: # always [left|None, right|None]
|
||||
"""Route hands to a 2-element [left, right] slot list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hands: raw hand list (any length, may contain None / short entries).
|
||||
chirality: int list aligned 1-to-1 with `hands` (0=left, 1=right).
|
||||
None / wrong length -> cx fallback.
|
||||
mirror: flip cx for the fallback path so screen-left stays slot 0.
|
||||
swap: invert the chirality mapping (0 -> right, 1 -> left). Safety
|
||||
knob for a source whose Vision chirality arrives inverted.
|
||||
Never touches the cx fallback (already screen-relative).
|
||||
near_min: replace a routed slot with None when hand_size < near_min.
|
||||
"""
|
||||
chir_aligned = (
|
||||
chirality is not None
|
||||
and len(chirality) == len(hands)
|
||||
and len(chirality) > 0
|
||||
)
|
||||
|
||||
# Build valid (chirality_value_or_None, hand) pairs — BEFORE filtering invalids
|
||||
# so chirality[i] stays paired with hands[i] even when hands[j] is skipped.
|
||||
valid_pairs: list[tuple] = []
|
||||
for i, hand in enumerate(hands):
|
||||
if not _is_valid(hand):
|
||||
continue
|
||||
c = chirality[i] if chir_aligned else None
|
||||
valid_pairs.append((c, hand))
|
||||
|
||||
slots: list = [None, None]
|
||||
|
||||
if chir_aligned and any(c is not None for c, _ in valid_pairs):
|
||||
# ---- Chirality path: chir 0 -> slot 0 (left), 1 -> slot 1 (right);
|
||||
# swap inverts the mapping when the source chirality is flipped ----
|
||||
for c, hand in valid_pairs:
|
||||
target = c if c in (0, 1) else None
|
||||
if target is not None and swap:
|
||||
target = 1 - target
|
||||
if target is not None and slots[target] is None:
|
||||
slots[target] = hand
|
||||
else:
|
||||
# ---- cx fallback: leftmost on screen -> slot 0 ----
|
||||
def _cx(h):
|
||||
xs = [_finite(_coord(p, "x", 0), 0.5) for p in h[:21]]
|
||||
raw = _clamp(sum(xs) / len(xs), 0.0, 1.0)
|
||||
return (1.0 - raw) if mirror else raw
|
||||
|
||||
ordered = sorted((h for _, h in valid_pairs), key=_cx)
|
||||
if ordered:
|
||||
slots[0] = ordered[0]
|
||||
if len(ordered) >= 2:
|
||||
slots[1] = ordered[1]
|
||||
|
||||
# ---- near_min gate (applied after routing, both paths) ----
|
||||
if near_min > 0.0:
|
||||
for i in range(2):
|
||||
if slots[i] is not None and _hand_size(slots[i]) < near_min:
|
||||
slots[i] = None
|
||||
|
||||
return slots
|
||||
|
||||
|
||||
class GestureSlotStabilizer:
|
||||
"""Stateful per-slot stabilizer for the gesture detection path.
|
||||
|
||||
Applied AFTER route_hands (with near_min=0.0) and BEFORE the gesture
|
||||
detectors (FingerStrikeDetector, PinchDetector).
|
||||
|
||||
Two behaviours:
|
||||
- **Hold-last**: when a slot is None this call but held a hand within the
|
||||
last ``hold_frames`` calls, output the LAST seen hand for that slot.
|
||||
Fills 1-2 frame Vision holes so pinch debounce never sees a spurious
|
||||
absence. After ``hold_frames`` consecutive misses the slot yields None
|
||||
for real.
|
||||
- **Near hysteresis**: a slot ACTIVATES when hand_size >= ``near_on`` and
|
||||
stays active until hand_size < ``near_off``. Inactive slot -> None output
|
||||
(but hold-last still tracks so re-activation is instant).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hold_frames: int = 2,
|
||||
near_on: float = 0.10,
|
||||
near_off: float = 0.08,
|
||||
face_min: float = 0.5,
|
||||
) -> None:
|
||||
self._hold = hold_frames
|
||||
self._near_on = near_on
|
||||
self._near_off = near_off
|
||||
self._face_min = face_min
|
||||
# face_off uses 0.8× hysteresis (mirrors the near on/off pattern) so that
|
||||
# a slot active at face_min does not flap when facing oscillates near the
|
||||
# threshold — it only deactivates when facing drops below face_off.
|
||||
self._face_off = 0.8 * face_min
|
||||
# Per-slot state
|
||||
self._last: list = [None, None] # last seen hand object
|
||||
self._miss: list[int] = [0, 0] # consecutive miss count
|
||||
self._near_active: list[bool] = [False, False]
|
||||
self._face_active: list[bool] = [False, False]
|
||||
self._out: list = [None, None] # last step() output (for active_flags)
|
||||
# Hold-tracking: True when previous step() output was a held replay.
|
||||
self._held_flag: list[bool] = [False, False]
|
||||
# Resumed: True when this step() transitioned held -> real for that slot.
|
||||
self._resumed: list[bool] = [False, False]
|
||||
|
||||
def step(self, slotted: list) -> list:
|
||||
"""Stabilize a 2-element [left|None, right|None] slot list.
|
||||
|
||||
Args:
|
||||
slotted: output of route_hands (near_min=0.0) — length 2.
|
||||
|
||||
Returns:
|
||||
Stabilized 2-element list; each slot is the stabilized hand or None.
|
||||
"""
|
||||
out: list = [None, None]
|
||||
for i in range(2):
|
||||
hand = slotted[i] if i < len(slotted) else None
|
||||
prev_held = self._held_flag[i]
|
||||
if hand is not None:
|
||||
# Real hand present this frame.
|
||||
# Transition held -> real: mark resumed so callers can reset
|
||||
# per-finger state before consuming this step's output.
|
||||
self._resumed[i] = prev_held
|
||||
self._held_flag[i] = False
|
||||
# Update near hysteresis
|
||||
size = _hand_size(hand)
|
||||
if self._near_active[i]:
|
||||
if size < self._near_off:
|
||||
self._near_active[i] = False
|
||||
else:
|
||||
if size >= self._near_on:
|
||||
self._near_active[i] = True
|
||||
# Update face hysteresis (face_off = 0.8 * face_min)
|
||||
facing = _hand_facing(hand)
|
||||
if self._face_active[i]:
|
||||
if facing < self._face_off:
|
||||
self._face_active[i] = False
|
||||
else:
|
||||
if facing >= self._face_min:
|
||||
self._face_active[i] = True
|
||||
# Track the hand regardless of near/face state
|
||||
self._last[i] = hand
|
||||
self._miss[i] = 0
|
||||
# Output only if near-active AND facing the camera
|
||||
out[i] = hand if (self._near_active[i] and self._face_active[i]) else None
|
||||
else:
|
||||
# Hand absent this frame
|
||||
self._resumed[i] = False
|
||||
self._miss[i] += 1
|
||||
if self._miss[i] <= self._hold:
|
||||
# Hold: output last hand only if it was near+face-active
|
||||
if (self._near_active[i] and self._face_active[i]
|
||||
and self._last[i] is not None):
|
||||
self._held_flag[i] = True
|
||||
out[i] = self._last[i]
|
||||
else:
|
||||
# Not active: no hold replay; slot stays None
|
||||
self._held_flag[i] = False
|
||||
else:
|
||||
# Past hold limit — clear both gates so next appearance
|
||||
# must re-qualify from near_on and face_min.
|
||||
self._held_flag[i] = False
|
||||
self._near_active[i] = False
|
||||
self._face_active[i] = False
|
||||
out[i] = None
|
||||
self._out = out
|
||||
return out
|
||||
|
||||
def active_flags(self) -> tuple:
|
||||
"""Return (slot0_active, slot1_active).
|
||||
|
||||
True when the last step() yielded a non-None for that slot — meaning the
|
||||
hand is established (held or present) AND within the near zone. Used by
|
||||
action_head_pub._emit_hands to gate the fist feature.
|
||||
"""
|
||||
return (self._out[0] is not None, self._out[1] is not None)
|
||||
|
||||
def resumed_flags(self) -> tuple[bool, bool]:
|
||||
"""Return (slot0_resumed, slot1_resumed).
|
||||
|
||||
True for a slot when the current step() transitioned from a "held
|
||||
replay" (Vision hole, last hand replayed) back to a real hand. Callers
|
||||
that track per-frame deltas (e.g. FingerStrikeDetector) should reset
|
||||
their state for that slot BEFORE consuming the step output to avoid
|
||||
phantom events caused by the compressed motion accumulated during the
|
||||
hold window.
|
||||
|
||||
Semantics: True only for the ONE step where the transition occurs;
|
||||
False on all subsequent frames where the hand stays real, and False
|
||||
after an expired hold (miss > hold_frames, slot went None) since the
|
||||
absent-slot logic in gesture detectors already resets per-finger state.
|
||||
"""
|
||||
return (self._resumed[0], self._resumed[1])
|
||||
@@ -18,47 +18,95 @@ import cv2
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.scripts.iphone_usb_bridge import (
|
||||
connect_device, decode_skeleton, iter_frames, TAG_SKELETON,
|
||||
connect_device, decode_skeleton, decode_skeleton2D,
|
||||
iter_frames, TAG_SKELETON, TAG_SKELETON2D,
|
||||
)
|
||||
from data_only_viz.arkit_topology import decode_topology
|
||||
from data_only_viz.state import PoseKp
|
||||
|
||||
TAG_VIDEO = 2
|
||||
TAG_HANDS = 4
|
||||
TAG_TOPOLOGY = 7
|
||||
LOG = logging.getLogger("iphone_usb_source")
|
||||
|
||||
_HAND_BYTES = 1 + 21 * 12 # chirality:u8 + 21 × (x,y,z) × 4 bytes BE f32
|
||||
_ARKIT_JOINTS = 91
|
||||
|
||||
|
||||
def _make_point(x: float, y: float, z: float) -> PoseKp:
|
||||
"""Wrap raw floats from the wire into a PoseKp landmark."""
|
||||
return PoseKp(x=x, y=y, z=z, c=1.0)
|
||||
# Normalized-coordinate counterpart of multi._apply_video_rotate (np.rot90
|
||||
# conventions): the iPhone computes 2D landmarks on the UNROTATED sensor
|
||||
# frame, so when VIDEO_ROTATE turns the displayed video the 2D points must
|
||||
# turn the same way or overlays/panels/gestures go misaligned. ARKit 3D
|
||||
# world joints are gravity-aligned and unaffected.
|
||||
_ROT_XY = {
|
||||
"ccw": lambda x, y: (y, 1.0 - x),
|
||||
"cw": lambda x, y: (1.0 - y, x),
|
||||
"180": lambda x, y: (1.0 - x, 1.0 - y),
|
||||
}
|
||||
|
||||
|
||||
def _decode_hands(payload: bytes):
|
||||
"""Decode TAG_HANDS payload → list[list[PoseKp]] (may be empty) or None.
|
||||
def rotate_norm_xy(x: float, y: float, mode: str) -> "tuple[float, float]":
|
||||
"""Rotate a normalized (x, y) like np.rot90 rotates the frame."""
|
||||
f = _ROT_XY.get(mode)
|
||||
return f(x, y) if f else (x, y)
|
||||
|
||||
|
||||
def effective_point_rotate(mode: str, mirror: bool) -> str:
|
||||
"""Point-rotation mode equivalent to the video pipeline's transform.
|
||||
|
||||
The VIDEO is horizontally flipped at decode (mirror) THEN rotated by
|
||||
multi.py; the POINTS are rotated here THEN flipped at display time by
|
||||
the renderer. Flip and 90-degree rotations do not commute: under the
|
||||
mirror the point rotation must be the mirror-conjugate of the video
|
||||
rotation (cw <-> ccw swapped; 180 commutes)."""
|
||||
if mirror and mode in ("cw", "ccw"):
|
||||
return "cw" if mode == "ccw" else "ccw"
|
||||
return mode
|
||||
|
||||
|
||||
def _make_point(x: float, y: float, z: float, conf: float) -> PoseKp:
|
||||
"""Wrap raw floats from the wire into a PoseKp landmark.
|
||||
|
||||
z receives the raw wire value; c is clamped to [0, 1] from conf (≈ Vision
|
||||
confidence, transmitted in the z slot of the wire format).
|
||||
"""
|
||||
return PoseKp(x=x, y=y, z=z, c=max(0.0, min(1.0, conf)))
|
||||
|
||||
|
||||
def _decode_hands(
|
||||
payload: bytes,
|
||||
) -> "tuple[list[list[PoseKp]], list[int]] | None":
|
||||
"""Decode TAG_HANDS payload.
|
||||
|
||||
Returns ``(hands, chirality)`` where *chirality[i]* is 0=left / 1=right
|
||||
(wire byte 1=right, any other value → 0=left). Returns ``([], [])`` when
|
||||
count==0. Returns ``None`` only on empty payload.
|
||||
|
||||
Wire layout: count:u8, then per hand: chirality:u8 (1=right) followed by
|
||||
21 × (x, y, z) big-endian f32. x,y are normalized image coords; z≈confidence.
|
||||
Returns None only on empty payload; an empty list when count==0.
|
||||
Landmark .z = raw wire z; .c = clamp(z, 0, 1).
|
||||
"""
|
||||
if not payload:
|
||||
return None
|
||||
n = payload[0]
|
||||
if n == 0:
|
||||
return []
|
||||
return ([], [])
|
||||
off = 1
|
||||
hands: list[list[PoseKp]] = []
|
||||
chirality: list[int] = []
|
||||
for _ in range(n):
|
||||
if off + _HAND_BYTES > len(payload):
|
||||
break
|
||||
off += 1 # skip chirality byte (not used by HandFeatureExtractor)
|
||||
chir_byte = payload[off]
|
||||
chirality.append(1 if chir_byte == 1 else 0)
|
||||
off += 1
|
||||
pts = struct.unpack(">" + "f" * 63, payload[off:off + 21 * 12])
|
||||
off += 21 * 12
|
||||
hand = [_make_point(pts[i * 3], pts[i * 3 + 1], pts[i * 3 + 2])
|
||||
hand = [_make_point(pts[i * 3], pts[i * 3 + 1], pts[i * 3 + 2],
|
||||
pts[i * 3 + 2])
|
||||
for i in range(21)]
|
||||
hands.append(hand)
|
||||
return hands
|
||||
return (hands, chirality)
|
||||
|
||||
|
||||
def _to_annexb(data: bytes) -> bytes:
|
||||
@@ -71,6 +119,36 @@ def _to_annexb(data: bytes) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def apply_skeleton_joints(
|
||||
prev_arr: "np.ndarray | None",
|
||||
joints: "list[tuple[float, float, float, bool]]",
|
||||
n_joints: int = _ARKIT_JOINTS,
|
||||
) -> np.ndarray:
|
||||
"""Return a NEW array with valid joints from *joints* applied.
|
||||
|
||||
Joints not marked valid this frame keep their values from *prev_arr*
|
||||
(copy semantics — never mutates *prev_arr*). Callers holding a
|
||||
reference to the previous array continue to see a consistent snapshot
|
||||
even without a lock, eliminating the tearing race described in B4.
|
||||
|
||||
Args:
|
||||
prev_arr: Array currently stored for this pid, or None / wrong shape.
|
||||
joints: Iterable of (x, y, z, valid) from decode_skeleton().
|
||||
n_joints: Expected joint count (default _ARKIT_JOINTS = 91).
|
||||
|
||||
Returns:
|
||||
A fresh np.ndarray of shape (n_joints, 3), dtype float32.
|
||||
"""
|
||||
if prev_arr is None or prev_arr.shape != (n_joints, 3):
|
||||
new_arr = np.zeros((n_joints, 3), dtype=np.float32)
|
||||
else:
|
||||
new_arr = prev_arr.copy()
|
||||
for i, (x, y, z, valid) in enumerate(joints):
|
||||
if valid:
|
||||
new_arr[i] = (x, y, z)
|
||||
return new_arr
|
||||
|
||||
|
||||
class IphoneUSBSource:
|
||||
def __init__(self, state=None, target_size=(640, 480),
|
||||
write_hands: bool = True, mirror: bool = True) -> None:
|
||||
@@ -80,8 +158,22 @@ class IphoneUSBSource:
|
||||
# Mirror the video horizontally so the performer facing the camera
|
||||
# interacts naturally (move right -> goes right; raise right arm ->
|
||||
# the right side responds). Env CONCERT_MIRROR=0 disables it.
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self._mirror = mirror and _cfg.concert_mirror
|
||||
# The iPhone app delivers BOTH 2D streams in an already-uprighted
|
||||
# frame in portrait: Vision hands run with a hardcoded orientation
|
||||
# (.right, ARBodySession.swift) and the ARKit skeleton2D projection
|
||||
# follows the interface orientation. VIDEO_ROTATE therefore only
|
||||
# rotates the VIDEO (multi.py); the 2D points are NOT rotated by
|
||||
# default. Two independent knobs remain for exotic app/device
|
||||
# combinations (mirror-conjugated, see effective_point_rotate):
|
||||
# IPHONE_SKEL_ROTATE and IPHONE_HANDS_ROTATE (none/ccw/cw/180).
|
||||
import os as _os
|
||||
self._mirror = mirror and (_os.environ.get("CONCERT_MIRROR", "1") != "0")
|
||||
self._skel_rotate = effective_point_rotate(
|
||||
_os.environ.get("IPHONE_SKEL_ROTATE", "none"), self._mirror)
|
||||
self._hands_rotate = effective_point_rotate(
|
||||
_os.environ.get("IPHONE_HANDS_ROTATE", "none"), self._mirror)
|
||||
self._codec = av.codec.CodecContext.create("hevc", "r") if av is not None else None
|
||||
self._lock = threading.Lock()
|
||||
self._frame = None # latest BGR np.ndarray
|
||||
@@ -91,12 +183,17 @@ class IphoneUSBSource:
|
||||
self._opened = False
|
||||
|
||||
def start(self) -> bool:
|
||||
# Try an immediate connect, but do NOT give up if the iPhone app isn't
|
||||
# streaming yet: start the read thread regardless. _run retries the
|
||||
# connect (with backoff) until the app appears, so a startup race (app
|
||||
# not ready / phone locked at launch) recovers the same way a mid-session
|
||||
# drop does -- no data_only_viz restart needed.
|
||||
sock = connect_device()
|
||||
if sock is None:
|
||||
LOG.error("iphone usb: no device / connect failed")
|
||||
return False
|
||||
self._sock = sock
|
||||
self._opened = True
|
||||
LOG.warning("iphone usb: no device yet -- thread will keep retrying")
|
||||
else:
|
||||
self._sock = sock
|
||||
self._opened = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, args=(sock,), name="iphone_usb_src", daemon=True)
|
||||
self._thread.start()
|
||||
@@ -106,7 +203,17 @@ class IphoneUSBSource:
|
||||
return self._opened
|
||||
|
||||
def _run(self, sock) -> None:
|
||||
if self.state is not None:
|
||||
with self.state.lock():
|
||||
self.state.mirror_2d = self._mirror
|
||||
while not self._stop.is_set():
|
||||
if sock is None: # no connection yet (startup or post-drop)
|
||||
sock = self._reconnect()
|
||||
if sock is None: # release() was called while waiting
|
||||
break
|
||||
self._sock = sock
|
||||
self._opened = True
|
||||
LOG.info("iphone usb connected")
|
||||
try:
|
||||
for tag, pid, payload in iter_frames(sock):
|
||||
if self._stop.is_set():
|
||||
@@ -128,23 +235,48 @@ class IphoneUSBSource:
|
||||
joints = decode_skeleton(payload)
|
||||
if joints is not None:
|
||||
with self.state.lock():
|
||||
arr = self.state.persons_arkit_joints.get(pid)
|
||||
if arr is None or arr.shape != (_ARKIT_JOINTS, 3):
|
||||
arr = np.zeros((_ARKIT_JOINTS, 3), dtype=np.float32)
|
||||
for i, (x, y, z, valid) in enumerate(joints):
|
||||
if valid:
|
||||
arr[i] = (x, y, z)
|
||||
self.state.persons_arkit_joints[pid] = arr
|
||||
prev = self.state.persons_arkit_joints.get(pid)
|
||||
new_arr = apply_skeleton_joints(prev, joints)
|
||||
self.state.persons_arkit_joints[pid] = new_arr
|
||||
self.state.persons_arkit_last_t[pid] = time.perf_counter()
|
||||
elif tag == TAG_SKELETON2D and self.state is not None:
|
||||
pts = decode_skeleton2D(payload)
|
||||
if pts is not None:
|
||||
_rot = self._skel_rotate
|
||||
arr = np.array(
|
||||
[rotate_norm_xy(x, y, _rot)
|
||||
for (x, y, _v) in pts],
|
||||
dtype=np.float32)
|
||||
valid = np.array([v for (_x, _y, v) in pts],
|
||||
dtype=bool)
|
||||
with self.state.lock():
|
||||
self.state.persons_arkit_2d[pid] = arr
|
||||
self.state.persons_arkit_2d_valid[pid] = valid
|
||||
self.state.persons_arkit_2d_t[pid] = \
|
||||
time.perf_counter()
|
||||
elif tag == TAG_TOPOLOGY and self.state is not None:
|
||||
topo = decode_topology(payload)
|
||||
if topo is not None:
|
||||
names, parents = topo
|
||||
with self.state.lock():
|
||||
self.state.arkit_joint_names = names
|
||||
self.state.arkit_parents = parents
|
||||
elif tag == TAG_HANDS:
|
||||
hands = _decode_hands(payload)
|
||||
if hands is not None and self.state is not None:
|
||||
result = _decode_hands(payload)
|
||||
if result is not None and self.state is not None:
|
||||
hands, chirality = result
|
||||
if self._hands_rotate != "none":
|
||||
for hand in hands:
|
||||
for p in hand:
|
||||
p.x, p.y = rotate_norm_xy(
|
||||
p.x, p.y, self._hands_rotate)
|
||||
with self.state.lock():
|
||||
# Always expose iPhone Vision hands (stable,
|
||||
# rotation-invariant) for the air-piano.
|
||||
self.state.persons_hands_iphone = hands
|
||||
self.state.persons_hands_iphone_t = \
|
||||
time.perf_counter()
|
||||
self.state.persons_hands_chirality = chirality
|
||||
if self._write_hands:
|
||||
self.state.persons_hands = hands
|
||||
except OSError as e:
|
||||
@@ -160,16 +292,18 @@ class IphoneUSBSource:
|
||||
# USB hiccup ends iter_frames; we re-establish :7000).
|
||||
with self._lock:
|
||||
self._frame = None
|
||||
# Clear stale hand state so the renderer doesn't freeze on the
|
||||
# last known hands after a stream drop.
|
||||
if self.state is not None:
|
||||
with self.state.lock():
|
||||
self.state.persons_hands_iphone = []
|
||||
self.state.persons_hands_chirality = []
|
||||
if self._write_hands:
|
||||
self.state.persons_hands = []
|
||||
self._opened = False
|
||||
if self._stop.is_set():
|
||||
break
|
||||
LOG.info("iphone usb stream ended — reconnecting...")
|
||||
sock = self._reconnect()
|
||||
if sock is None:
|
||||
break
|
||||
self._sock = sock
|
||||
self._opened = True
|
||||
LOG.info("iphone usb reconnected")
|
||||
sock = None # forces the reconnect at the loop top
|
||||
if not self._stop.is_set():
|
||||
LOG.info("iphone usb stream ended -- reconnecting...")
|
||||
self._opened = False
|
||||
|
||||
def _reconnect(self):
|
||||
|
||||
@@ -54,7 +54,8 @@ def load_extrinsic(path: Path | None = None) -> Extrinsic:
|
||||
|
||||
|
||||
def _path_from_env() -> Path:
|
||||
p = os.environ.get("ICP_LIDAR_EXTRINSIC")
|
||||
from .config import VizConfig
|
||||
p = VizConfig.from_env().icp_lidar_extrinsic
|
||||
return Path(p) if p else DEFAULT_EXTRINSIC_PATH
|
||||
|
||||
|
||||
|
||||
+102
-55
@@ -71,6 +71,10 @@ class AppDelegate(NSObject):
|
||||
# Sender vers sclang pour les changements de scene depuis le clavier
|
||||
self._scClient = SimpleUDPClient("127.0.0.1", opts.sclang_port)
|
||||
self._pose_worker = None
|
||||
from .config import VizConfig as _VizConfig
|
||||
_kcfg = _VizConfig.from_env()
|
||||
self._source_keys = _kcfg.viz_source_keys
|
||||
self._audio_keys = _kcfg.viz_audio_keys
|
||||
return self
|
||||
|
||||
def applicationDidFinishLaunching_(self, notification): # noqa: N802
|
||||
@@ -161,23 +165,39 @@ class AppDelegate(NSObject):
|
||||
self._window.makeFirstResponder_(self._container)
|
||||
LOG.info("window shown + key focus forced + floating level")
|
||||
|
||||
# 2b) HUD : overlay NSTextView semi-transparent au-dessus du MTKView.
|
||||
# NSTextView prend en charge le rendu CoreText sans avoir a passer
|
||||
# par un MTLBuffer texte. On le pose comme subview du MTKView.
|
||||
self._hud = NSTextView.alloc().initWithFrame_(
|
||||
NSMakeRect(12, 12, 340, 240))
|
||||
self._hud.setEditable_(False)
|
||||
self._hud.setSelectable_(False)
|
||||
self._hud.setDrawsBackground_(True)
|
||||
self._hud.setBackgroundColor_(
|
||||
NSColor.colorWithCalibratedRed_green_blue_alpha_(0, 0, 0, 0.45))
|
||||
self._hud.setFont_(NSFont.fontWithName_size_("Menlo", 11))
|
||||
self._hud.setTextColor_(NSColor.whiteColor())
|
||||
self._hud.setAutoresizingMask_(NSViewHeightSizable)
|
||||
self._mtkview.addSubview_(self._hud)
|
||||
# Timer 10 Hz, rafraichit le texte avec les valeurs du State.
|
||||
self._hudTimer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
0.1, self, "refreshHud:", None, True)
|
||||
# 2b.1) Keyboard monitors — installed here so they are always active
|
||||
# regardless of which pose backend is chosen (or even when --pose is
|
||||
# omitted). The original placement at the END of _start_pose_worker
|
||||
# was unreachable because every normal backend path returns early.
|
||||
# Global monitor is read-only; we ignore its return to avoid
|
||||
# double-triggering alongside the local monitor.
|
||||
self._kb_monitor = NSEvent.addLocalMonitorForEventsMatchingMask_handler_(
|
||||
NSEventMaskKeyDown, self._on_key)
|
||||
self._kb_global = NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(
|
||||
NSEventMaskKeyDown, self._on_key_global)
|
||||
LOG.info("keyboard monitors installed (local + global)")
|
||||
|
||||
# 2b.2) HUD : overlay NSTextView semi-transparent au-dessus du MTKView.
|
||||
# OFF by default (performance display); VIZ_HUD=1 brings the debug
|
||||
# panel back. It would also collide with the left-hand side panel.
|
||||
self._hud = None
|
||||
self._hudTimer = None
|
||||
from .config import VizConfig as _VizConfig
|
||||
if _VizConfig.from_env().viz_hud:
|
||||
self._hud = NSTextView.alloc().initWithFrame_(
|
||||
NSMakeRect(12, 12, 340, 240))
|
||||
self._hud.setEditable_(False)
|
||||
self._hud.setSelectable_(False)
|
||||
self._hud.setDrawsBackground_(True)
|
||||
self._hud.setBackgroundColor_(
|
||||
NSColor.colorWithCalibratedRed_green_blue_alpha_(0, 0, 0, 0.45))
|
||||
self._hud.setFont_(NSFont.fontWithName_size_("Menlo", 11))
|
||||
self._hud.setTextColor_(NSColor.whiteColor())
|
||||
self._hud.setAutoresizingMask_(NSViewHeightSizable)
|
||||
self._mtkview.addSubview_(self._hud)
|
||||
# Timer 10 Hz, rafraichit le texte avec les valeurs du State.
|
||||
self._hudTimer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
0.1, self, "refreshHud:", None, True)
|
||||
|
||||
# 2c) Timer webcam update (NSImageView fullscreen deja cree en 2a)
|
||||
self._camTimer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
@@ -186,9 +206,19 @@ class AppDelegate(NSObject):
|
||||
# 2d) Auto-engage du mode openpos (#9) quand des personnes sont
|
||||
# detectees. Si l'utilisateur a force un mode au clavier dans
|
||||
# les 8 dernieres secondes, on n'override pas (lock manuel).
|
||||
# VIZ_DEFAULT_MODE=<name> fixes the boot mode and DISABLES the
|
||||
# auto-switch entirely (user request: t/voronoi always active).
|
||||
self._user_viz_lock_t = 0.0 # set par _on_key, lu par autoOpenpos
|
||||
self._autoOpenposTimer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
0.5, self, "autoOpenpos:", None, True)
|
||||
from .config import VizConfig as _VC
|
||||
_vmode = _VC.from_env().viz_default_mode
|
||||
self._autoOpenposTimer = None
|
||||
if _vmode != "auto" and _vmode in self._state.viz_mode_names:
|
||||
with self._state.lock():
|
||||
self._state.viz_mode = self._state.viz_mode_names.index(_vmode)
|
||||
LOG.info("[viz] fixed default mode %s (auto-openpos disabled)", _vmode)
|
||||
else:
|
||||
self._autoOpenposTimer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
0.5, self, "autoOpenpos:", None, True)
|
||||
|
||||
if self._opts.fullscreen:
|
||||
self._window.toggleFullScreen_(None)
|
||||
@@ -257,15 +287,17 @@ class AppDelegate(NSObject):
|
||||
# 2. Apple Vision body pose (fallback si MediaPipe casse)
|
||||
# 3. CoreML pose, DETRPose, Holistic, YOLO — fallbacks
|
||||
import os as _os
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
# iPhone ARBodyTracker (option 2 LiDAR fusion) : always-on
|
||||
# listener on :57128. Harmless if no iPhone is broadcasting ;
|
||||
# state.persons_arkit_joints stays empty and the arkit_fuse
|
||||
# stage no-ops. Activated via POSE_FILTER=...+arkit_fuse.
|
||||
try:
|
||||
from .iphone_osc_listener import IphoneOSCListener
|
||||
self._iphone_osc = IphoneOSCListener(self._state)
|
||||
self._iphone_osc = IphoneOSCListener(self._state, port=_cfg.iphone_osc_port)
|
||||
self._iphone_osc.start()
|
||||
LOG.info("worker: + iPhone OSC listener :57128")
|
||||
LOG.info("worker: + iPhone OSC listener :%d", _cfg.iphone_osc_port)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("iphone OSC listener start failed (%s)", e)
|
||||
# ICP LiDAR fusion (opt-in via ICP_FUSION=1). Parallel to the
|
||||
@@ -273,8 +305,8 @@ class AppDelegate(NSObject):
|
||||
# joints. Requires a calibrated extrinsic on disk (see
|
||||
# scripts/calibrate_lidar.py) and an iPhone LiDAR stream
|
||||
# broadcasting on ICP_LIDAR_HOST:ICP_LIDAR_PORT.
|
||||
if _os.environ.get("ICP_FUSION", "0") == "1":
|
||||
host = _os.environ.get("ICP_LIDAR_HOST")
|
||||
if _cfg.icp_fusion:
|
||||
host = _cfg.icp_lidar_host
|
||||
if not host:
|
||||
LOG.warning("ICP_FUSION=1 but ICP_LIDAR_HOST unset — "
|
||||
"fusion disabled")
|
||||
@@ -284,11 +316,11 @@ class AppDelegate(NSObject):
|
||||
self._icp_fusion = IcpFusionThread(
|
||||
self._state,
|
||||
host=host,
|
||||
port=int(_os.environ.get("ICP_LIDAR_PORT", "5500")),
|
||||
port=_cfg.icp_lidar_port,
|
||||
)
|
||||
self._icp_fusion.start()
|
||||
LOG.info("worker: + ICP LiDAR fusion -> %s:%s", host,
|
||||
_os.environ.get("ICP_LIDAR_PORT", "5500"))
|
||||
LOG.info("worker: + ICP LiDAR fusion -> %s:%d",
|
||||
host, _cfg.icp_lidar_port)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("icp fusion start failed (%s)", e)
|
||||
# 0. Multi-HMR (SMPL-X 10475 verts mesh dense) — opt-in via flag
|
||||
@@ -304,8 +336,7 @@ class AppDelegate(NSObject):
|
||||
# the freshest frame and drain the freshest result.
|
||||
self._pose_worker = MultiHMRWorker(
|
||||
self._state, num_persons=4,
|
||||
target_fps=float(_os.environ.get(
|
||||
"MULTIHMR_LOOP_FPS", "30.0")),
|
||||
target_fps=_cfg.multihmr_loop_fps,
|
||||
device=getattr(self._opts, "pose_device", "mps"),
|
||||
det_thresh=getattr(self._opts, "det_thresh", 0.15),
|
||||
nms_kernel_size=getattr(
|
||||
@@ -319,7 +350,7 @@ class AppDelegate(NSObject):
|
||||
# issues introduced by the hybrid rigging path).
|
||||
self._smplx_tcp = SMPLXTCPSender(
|
||||
self._state,
|
||||
enable_rigging=os.environ.get("MESH_RIG", "1") != "0",
|
||||
enable_rigging=_cfg.mesh_rig,
|
||||
)
|
||||
self._smplx_tcp.start()
|
||||
LOG.info("worker: Multi-HMR + SMPL-X (mesh dense)")
|
||||
@@ -335,8 +366,7 @@ class AppDelegate(NSObject):
|
||||
# AV_LIVE_PARALLEL_POSE=apple_vision pour ne garder que
|
||||
# le path ANE (face/hand fin disparait), ou =mediapipe
|
||||
# pour ne garder que CPU.
|
||||
parallel = _os.environ.get(
|
||||
"AV_LIVE_PARALLEL_POSE", "both")
|
||||
parallel = _cfg.av_live_parallel_pose
|
||||
if parallel in ("apple_vision", "both"):
|
||||
try:
|
||||
from .apple_vision_pose import AppleVisionPoseWorker
|
||||
@@ -371,12 +401,18 @@ class AppDelegate(NSObject):
|
||||
# 1. MediaPipe Multi : DEFAUT pour le mapping sonore + openpos
|
||||
# (33 body + 478 face + 21x2 hands × 4 personnes). Skip via
|
||||
# AV_LIVE_MEDIAPIPE=0 si on prefere body-only ANE-accelere.
|
||||
if _os.environ.get("AV_LIVE_MEDIAPIPE") != "0":
|
||||
if _cfg.av_live_mediapipe:
|
||||
try:
|
||||
from .multi import MultiWorker
|
||||
_iphone_usb = getattr(self._opts, "iphone_usb", False)
|
||||
# iphone-usb path: camera decode + ARKit conversion is cheap
|
||||
# (no MediaPipe inference on the Mac). Frame-based gesture
|
||||
# calibrations (debounce/hold/grace/persist) assume 30 Hz.
|
||||
# Keep 18 Hz for the webcam MediaPipe path (M5 CPU protection).
|
||||
self._pose_worker = MultiWorker(
|
||||
self._state, num_persons=4,
|
||||
iphone_usb=getattr(self._opts, "iphone_usb", False))
|
||||
iphone_usb=_iphone_usb,
|
||||
target_fps=30.0 if _iphone_usb else 18.0)
|
||||
self._pose_worker.start()
|
||||
LOG.info("worker: MediaPipe Multi (Pose+Face+Hand × 4)")
|
||||
return
|
||||
@@ -384,7 +420,7 @@ class AppDelegate(NSObject):
|
||||
LOG.warning("MediaPipe Multi unavailable (%s) — fallback", e)
|
||||
# 2. Apple Vision body pose : fallback si MediaPipe casse.
|
||||
# Body only, 13 joints, pas de face/hands.
|
||||
if _os.environ.get("AV_LIVE_APPLE_VISION") != "0":
|
||||
if _cfg.av_live_apple_vision:
|
||||
try:
|
||||
from .apple_vision_pose import AppleVisionPoseWorker
|
||||
if AppleVisionPoseWorker.is_available():
|
||||
@@ -398,7 +434,7 @@ class AppDelegate(NSObject):
|
||||
"(macOS < 11 ?) — fallback")
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("Apple Vision pose indisponible (%s) — fallback", e)
|
||||
if _os.environ.get("AV_LIVE_COREML") != "0":
|
||||
if _cfg.av_live_coreml:
|
||||
try:
|
||||
from .coreml_pose import CoreMLPoseWorker
|
||||
if CoreMLPoseWorker.is_available():
|
||||
@@ -413,7 +449,7 @@ class AppDelegate(NSObject):
|
||||
"puis relancer pour activer le pipeline ANE")
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("CoreML pose indisponible (%s) — fallback", e)
|
||||
if _os.environ.get("AV_LIVE_DETRPOSE") == "1":
|
||||
if _cfg.av_live_detrpose:
|
||||
try:
|
||||
from .detrpose import DETRPoseWorker, is_available
|
||||
if is_available():
|
||||
@@ -442,20 +478,6 @@ class AppDelegate(NSObject):
|
||||
self._state, device=self._opts.pose_device)
|
||||
self._pose_worker.start()
|
||||
|
||||
# 4) Hook clavier : local (app au focus) + global (app au fond).
|
||||
# Le global monitor est read-only mais permet de garder le pilotage
|
||||
# quand l'utilisateur a une autre app au premier plan (IDE,
|
||||
# browser). On ignore le retour pour le global (sinon double-trigger).
|
||||
self._kb_monitor = NSEvent.addLocalMonitorForEventsMatchingMask_handler_(
|
||||
NSEventMaskKeyDown, self._on_key)
|
||||
self._kb_global = NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(
|
||||
NSEventMaskKeyDown, self._on_key_global)
|
||||
# Force le focus initial pour que le local monitor reçoive
|
||||
# tout de suite les touches sans nécessiter un clic.
|
||||
NSApp().activateIgnoringOtherApps_(True)
|
||||
self._window.makeKeyAndOrderFront_(None)
|
||||
self._window.makeFirstResponder_(self._container)
|
||||
|
||||
_cam_log_count = 0
|
||||
|
||||
def refreshCam_(self, _timer): # noqa: N802
|
||||
@@ -485,6 +507,8 @@ class AppDelegate(NSObject):
|
||||
def refreshHud_(self, _timer): # noqa: N802
|
||||
"""Format le HUD avec la valeur courante des flux + correspondance
|
||||
avec le rendu visuel. Appele a 10 Hz par NSTimer."""
|
||||
if self._hud is None:
|
||||
return
|
||||
s = self._state
|
||||
with s.lock():
|
||||
mode = s.viz_mode
|
||||
@@ -590,7 +614,9 @@ class AppDelegate(NSObject):
|
||||
if key == "F":
|
||||
self._window.toggleFullScreen_(None); return None
|
||||
if key == "H":
|
||||
self._hud.setHidden_(not self._hud.isHidden()); return None
|
||||
if self._hud is not None:
|
||||
self._hud.setHidden_(not self._hud.isHidden())
|
||||
return None
|
||||
if key == "C": # Shift+C : toggle webcam overlay
|
||||
self._cam.setHidden_(not self._cam.isHidden()); return None
|
||||
# azertyuiop -> video (viz mode)
|
||||
@@ -607,16 +633,37 @@ class AppDelegate(NSObject):
|
||||
self._user_viz_lock_t = _t.monotonic()
|
||||
LOG.info("[video] viz -> %s (%d) (lock 8s)", name, idx)
|
||||
return None
|
||||
# qsdfghjklm -> audio (scene SC)
|
||||
for kk, scene in KEYMAP_AUDIO:
|
||||
# m = matrix performance mode: stop the auto data-synth scene AND
|
||||
# force the openpos viz (the matrix is not a scene — it keeps
|
||||
# playing). User request live 2026-07-02.
|
||||
if k == "m":
|
||||
self._scClient.send_message("/control/doScene", ["stop"])
|
||||
names = list(self._state.viz_mode_names)
|
||||
idx = names.index("openpos") if "openpos" in names else 0
|
||||
with self._state.lock():
|
||||
self._state.viz_mode = idx
|
||||
self._state.active_scene = "stop"
|
||||
import time as _t
|
||||
self._user_viz_lock_t = _t.monotonic()
|
||||
LOG.info("[key] m -> scene stop + viz openpos (matrix mode)")
|
||||
return None
|
||||
# qsdfghjkl -> audio (scene SC): gated OFF by default like wxcvbn
|
||||
# (VIZ_AUDIO_KEYS=1 re-enables) — stray keys fire auto-synth scenes
|
||||
# mid-performance. The m matrix-mode shortcut above stays active.
|
||||
_audio_maps = KEYMAP_AUDIO if getattr(self, "_audio_keys", False) else ()
|
||||
for kk, scene in _audio_maps:
|
||||
if k == kk:
|
||||
self._scClient.send_message("/control/doScene", [scene])
|
||||
with self._state.lock():
|
||||
self._state.active_scene = scene
|
||||
LOG.info("[audio] scene -> %s", scene)
|
||||
return None
|
||||
# wxcvbn + 0-9 -> preset bundle (source + scene audio + viz video)
|
||||
for kk, source, scene, viz in (*KEYMAP_SOURCE, *KEYMAP_SOURCE_NUM):
|
||||
# wxcvbn + 0-9 -> preset bundles: ALL gated OFF by default
|
||||
# (VIZ_SOURCE_KEYS=1 re-enables) — stray keypresses mid-performance
|
||||
# switch the audio scene (user-disabled live 2026-07-02).
|
||||
_src_maps = ((*KEYMAP_SOURCE, *KEYMAP_SOURCE_NUM)
|
||||
if getattr(self, "_source_keys", False) else ())
|
||||
for kk, source, scene, viz in _src_maps:
|
||||
if key == kk or k == kk:
|
||||
# Audio : envoie a sclang
|
||||
self._scClient.send_message("/control/doScene", [scene])
|
||||
|
||||
+136
-91
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
from .action_head_pub import ActionHeadPublisher
|
||||
from .arkit_joint_map import arkit_body_2d, arkit_body_3d
|
||||
from .euro_filter import SkeletonFilter
|
||||
from .pose_bridge import PoseSoundBridge
|
||||
from .pose_filter import PoseFilterChain
|
||||
@@ -131,12 +132,13 @@ class MultiWorker:
|
||||
self._pid_missing: dict[int, int] = {}
|
||||
self._pid_last_bbox: dict[int, tuple[float, float, float, float]] = {}
|
||||
# Discrimination thresholds — tunable via env.
|
||||
import os as _os
|
||||
self._ghost_min_visible = int(_os.environ.get("POSE_GHOST_MIN_VISIBLE", "10"))
|
||||
self._ghost_min_conf = float(_os.environ.get("POSE_GHOST_MIN_CONF", "0.5"))
|
||||
self._hand_min_visible = int(_os.environ.get("POSE_HAND_MIN_VISIBLE", "15"))
|
||||
self._face_min_visible = int(_os.environ.get("POSE_FACE_MIN_VISIBLE", "50"))
|
||||
self._nms_iou = float(_os.environ.get("POSE_NMS_IOU", "0.7"))
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self._ghost_min_visible = _cfg.pose_ghost_min_visible
|
||||
self._ghost_min_conf = _cfg.pose_ghost_min_conf
|
||||
self._hand_min_visible = _cfg.pose_hand_min_visible
|
||||
self._face_min_visible = _cfg.pose_face_min_visible
|
||||
self._nms_iou = _cfg.pose_nms_iou
|
||||
# Counters exposed for debug.
|
||||
self._n_ghost_dropped = 0
|
||||
self._n_hand_dropped = 0
|
||||
@@ -304,72 +306,86 @@ class MultiWorker:
|
||||
self._stop.set()
|
||||
|
||||
def _run(self) -> None:
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
_rot = _cfg.video_rotate
|
||||
LOG.info("video rotate = %s (env VIDEO_ROTATE: none/ccw/cw/180)", _rot)
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks.python import BaseOptions
|
||||
from mediapipe.tasks.python.vision import (
|
||||
PoseLandmarker, PoseLandmarkerOptions,
|
||||
FaceLandmarker, FaceLandmarkerOptions,
|
||||
HandLandmarker, HandLandmarkerOptions,
|
||||
RunningMode,
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
LOG.error("deps manquantes : %s — uv sync --extra pose", e)
|
||||
LOG.error("deps manquantes (cv2) : %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
pose_p = _ensure_model("pose")
|
||||
face_p = _ensure_model("face")
|
||||
hand_p = _ensure_model("hand")
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.error("download models failed: %s", e)
|
||||
return
|
||||
# MediaPipe landmarkers: only loaded when Mac webcam is the source.
|
||||
# Under --iphone-usb, body+face come from ARKit; loading MP here
|
||||
# would waste RAM and slow concert boot.
|
||||
pose = face = hand = _deleg = _mp = None
|
||||
if not self.iphone_usb:
|
||||
try:
|
||||
import mediapipe as _mp
|
||||
from mediapipe.tasks.python import BaseOptions
|
||||
from mediapipe.tasks.python.vision import (
|
||||
PoseLandmarker, PoseLandmarkerOptions,
|
||||
FaceLandmarker, FaceLandmarkerOptions,
|
||||
HandLandmarker, HandLandmarkerOptions,
|
||||
RunningMode,
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
LOG.error("deps manquantes : %s — uv sync --extra pose", e)
|
||||
return
|
||||
|
||||
# GPU delegate (Metal sur macOS) : libere le CPU pour OSC, state,
|
||||
# mesh_rigger. Multi-HMR remote macm1 + MediaPipe GPU M5 =
|
||||
# workload distribue. Toggle via MEDIAPIPE_DELEGATE=cpu si plante.
|
||||
import os as _os
|
||||
_deleg_name = _os.environ.get("MEDIAPIPE_DELEGATE", "gpu").lower()
|
||||
_deleg = (BaseOptions.Delegate.GPU if _deleg_name == "gpu"
|
||||
else BaseOptions.Delegate.CPU)
|
||||
LOG.info("MediaPipe delegate = %s (env MEDIAPIPE_DELEGATE)",
|
||||
_deleg.name)
|
||||
_rot = _os.environ.get("VIDEO_ROTATE", "none").lower()
|
||||
LOG.info("video rotate = %s (env VIDEO_ROTATE: none/ccw/cw/180)", _rot)
|
||||
pose = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(pose_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_poses=self.num_persons,
|
||||
min_pose_detection_confidence=self.min_conf,
|
||||
min_pose_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
face = FaceLandmarker.create_from_options(FaceLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(face_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_faces=self.num_persons,
|
||||
min_face_detection_confidence=self.min_conf,
|
||||
min_face_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
hand = HandLandmarker.create_from_options(HandLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(hand_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_hands=self.num_persons * 2,
|
||||
min_hand_detection_confidence=self.min_conf,
|
||||
min_hand_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
LOG.info("3 landmarkers prets (num=%d, delegate=%s)",
|
||||
self.num_persons, _deleg.name)
|
||||
try:
|
||||
pose_p = _ensure_model("pose")
|
||||
face_p = _ensure_model("face")
|
||||
hand_p = _ensure_model("hand")
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.error("download models failed: %s", e)
|
||||
return
|
||||
|
||||
# GPU delegate (Metal sur macOS) : libere le CPU pour OSC, state,
|
||||
# mesh_rigger. Toggle via MEDIAPIPE_DELEGATE=cpu si plante.
|
||||
_deleg_name = _cfg.mediapipe_delegate
|
||||
_deleg = (BaseOptions.Delegate.GPU if _deleg_name == "gpu"
|
||||
else BaseOptions.Delegate.CPU)
|
||||
LOG.info("MediaPipe delegate = %s (env MEDIAPIPE_DELEGATE)",
|
||||
_deleg.name)
|
||||
pose = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(pose_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_poses=self.num_persons,
|
||||
min_pose_detection_confidence=self.min_conf,
|
||||
min_pose_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
face = FaceLandmarker.create_from_options(FaceLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(face_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_faces=self.num_persons,
|
||||
min_face_detection_confidence=self.min_conf,
|
||||
min_face_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
hand = HandLandmarker.create_from_options(HandLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(hand_p),
|
||||
delegate=_deleg),
|
||||
running_mode=RunningMode.VIDEO,
|
||||
num_hands=self.num_persons * 2,
|
||||
min_hand_detection_confidence=self.min_conf,
|
||||
min_hand_presence_confidence=self.min_conf,
|
||||
min_tracking_confidence=self.min_conf,
|
||||
))
|
||||
LOG.info("3 landmarkers prets (num=%d, delegate=%s)",
|
||||
self.num_persons, _deleg.name)
|
||||
|
||||
if self.iphone_usb:
|
||||
from .iphone_usb_source import IphoneUSBSource # noqa: PLC0415
|
||||
cap = IphoneUSBSource(self.state, write_hands=False)
|
||||
# write_hands=True: the iPhone Vision hands ARE the hand source under
|
||||
# --iphone-usb (rendering + /pose/hands openness + pinch). The Mac
|
||||
# MediaPipe hand detector is skipped below.
|
||||
cap = IphoneUSBSource(self.state, write_hands=True)
|
||||
if not cap.start():
|
||||
LOG.error("iphone USB source unavailable (app running? phone unlocked?)")
|
||||
return
|
||||
@@ -394,27 +410,31 @@ class MultiWorker:
|
||||
# et l'encodage JPEG : detection + overlay + affichage coherents.
|
||||
frame_bgr = _apply_video_rotate(frame_bgr, _rot)
|
||||
h, w = frame_bgr.shape[:2]
|
||||
# MediaPipe GPU delegate on macOS uploads via CVPixelBuffer
|
||||
# which only accepts 4-channel formats. SRGB (3ch) crashes
|
||||
# in gpu_buffer_storage_cv_pixel_buffer.cc with
|
||||
# "unsupported ImageFrame format: 1". Use SRGBA when on GPU.
|
||||
if _deleg == BaseOptions.Delegate.GPU:
|
||||
frame_rgba = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA)
|
||||
mp_img = mp.Image(image_format=mp.ImageFormat.SRGBA,
|
||||
data=frame_rgba)
|
||||
else:
|
||||
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||||
mp_img = mp.Image(image_format=mp.ImageFormat.SRGB,
|
||||
data=frame_rgb)
|
||||
ts = int(time.monotonic() * 1000) - t0_ms
|
||||
try:
|
||||
pose_res = pose.detect_for_video(mp_img, ts)
|
||||
face_res = face.detect_for_video(mp_img, ts)
|
||||
hand_res = hand.detect_for_video(mp_img, ts)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("inference: %s", e)
|
||||
time.sleep(self.period)
|
||||
continue
|
||||
if self.iphone_usb:
|
||||
# body+face come from ARKit (IphoneUSBSource); skip MP inference.
|
||||
pose_res = face_res = hand_res = None
|
||||
else:
|
||||
# MediaPipe GPU delegate on macOS uploads via CVPixelBuffer
|
||||
# which only accepts 4-channel formats. SRGB (3ch) crashes
|
||||
# in gpu_buffer_storage_cv_pixel_buffer.cc with
|
||||
# "unsupported ImageFrame format: 1". Use SRGBA when on GPU.
|
||||
if _deleg == BaseOptions.Delegate.GPU:
|
||||
frame_rgba = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA)
|
||||
mp_img = _mp.Image(image_format=_mp.ImageFormat.SRGBA,
|
||||
data=frame_rgba)
|
||||
else:
|
||||
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||||
mp_img = _mp.Image(image_format=_mp.ImageFormat.SRGB,
|
||||
data=frame_rgb)
|
||||
try:
|
||||
pose_res = pose.detect_for_video(mp_img, ts)
|
||||
face_res = face.detect_for_video(mp_img, ts)
|
||||
hand_res = hand.detect_for_video(mp_img, ts)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("inference: %s", e)
|
||||
time.sleep(self.period)
|
||||
continue
|
||||
|
||||
# Encode webcam JPEG pour overlay
|
||||
ok2, jpg = cv2.imencode(".jpg", frame_bgr,
|
||||
@@ -425,7 +445,7 @@ class MultiWorker:
|
||||
# fournit aussi z, plus precis que rien). pose_world_landmarks
|
||||
# donnerait des metres mais on garde un repere coherent avec face/hands.
|
||||
bodies = []
|
||||
pose_list = pose_res.pose_landmarks or []
|
||||
pose_list = (pose_res.pose_landmarks if pose_res is not None else None) or []
|
||||
for landmarks_list in pose_list:
|
||||
kp_list = []
|
||||
for lm in landmarks_list[:33]:
|
||||
@@ -439,7 +459,7 @@ class MultiWorker:
|
||||
# Aligned 1:1 with pose_landmarks order. Empty fallback if
|
||||
# the MediaPipe build doesn't populate it.
|
||||
bodies3d: list[list[Kp3D]] = []
|
||||
world_list = getattr(pose_res, "pose_world_landmarks", None) or []
|
||||
world_list = (getattr(pose_res, "pose_world_landmarks", None) if pose_res is not None else None) or []
|
||||
for landmarks_list in world_list:
|
||||
kp3_list: list[Kp3D] = []
|
||||
for lm in landmarks_list[:33]:
|
||||
@@ -451,7 +471,7 @@ class MultiWorker:
|
||||
bodies3d.append(kp3_list)
|
||||
|
||||
faces = []
|
||||
for landmarks_list in (face_res.face_landmarks or []):
|
||||
for landmarks_list in ((face_res.face_landmarks if face_res is not None else None) or []):
|
||||
kp_list = []
|
||||
for lm in landmarks_list[:478]:
|
||||
z = float(lm.z) if lm.z is not None else 0.0
|
||||
@@ -459,8 +479,21 @@ class MultiWorker:
|
||||
x=float(lm.x), y=float(lm.y), z=z, c=1.0))
|
||||
faces.append(kp_list)
|
||||
|
||||
# iphone-usb: bodies + bodies3d come from ARKit skeleton (2D + 3D).
|
||||
# MP inference was skipped above; rebuild from state.persons_arkit_*.
|
||||
# faces stays [] — ARKit has no face landmarks at this layer.
|
||||
if self.iphone_usb:
|
||||
with self.state.lock():
|
||||
_a2d = dict(self.state.persons_arkit_2d)
|
||||
_a3d = dict(self.state.persons_arkit_joints)
|
||||
_pid = min(_a2d.keys()) if _a2d else None
|
||||
if _pid is not None:
|
||||
bodies = [arkit_body_2d(_a2d[_pid])]
|
||||
_arr3d = _a3d.get(_pid)
|
||||
bodies3d = [arkit_body_3d(_arr3d)] if _arr3d is not None else []
|
||||
|
||||
hands = []
|
||||
for landmarks_list in (hand_res.hand_landmarks or []):
|
||||
for landmarks_list in ((hand_res.hand_landmarks if hand_res is not None else None) or []):
|
||||
kp_list = []
|
||||
for lm in landmarks_list[:21]:
|
||||
z = float(lm.z) if lm.z is not None else 0.0
|
||||
@@ -503,20 +536,27 @@ class MultiWorker:
|
||||
if not hasattr(self, "_dbg_b3d_t") or t_now - self._dbg_b3d_t > 5.0:
|
||||
LOG.info("body3d: n=%d (pose_world_landmarks)", len(bodies3d))
|
||||
self._dbg_b3d_t = t_now
|
||||
# iphone-usb: action_head_pub emits /pose/hands from the iPhone hands
|
||||
# (in persons_hands); don't double-emit a (skipped) MP hand set here.
|
||||
self._sound_bridge.send(
|
||||
bodies, ids_body, t_now,
|
||||
persons_face=faces, persons_face_ids=ids_face,
|
||||
persons_hands=hands, persons_hands_ids=ids_hand,
|
||||
persons_hands=(None if self.iphone_usb else hands),
|
||||
persons_hands_ids=(None if self.iphone_usb else ids_hand),
|
||||
persons_body3d=bodies3d, persons_body3d_ids=ids_body3d)
|
||||
|
||||
with self.state.lock():
|
||||
self.state.persons_body = bodies
|
||||
self.state.persons_face = faces
|
||||
self.state.persons_hands = hands
|
||||
# iphone-usb: persons_hands is owned by IphoneUSBSource (iPhone
|
||||
# Vision hands); don't clobber it with the skipped Mac MP result.
|
||||
if not self.iphone_usb:
|
||||
self.state.persons_hands = hands
|
||||
self.state.persons_body_ids = ids_body
|
||||
self.state.persons_body3d = bodies3d
|
||||
self.state.persons_face_ids = ids_face
|
||||
self.state.persons_hands_ids = ids_hand
|
||||
if not self.iphone_usb:
|
||||
self.state.persons_hands_ids = ids_hand
|
||||
# Compat single-person (1ere personne)
|
||||
if bodies:
|
||||
self.state.body_present = True
|
||||
@@ -540,5 +580,10 @@ class MultiWorker:
|
||||
if dt < self.period:
|
||||
time.sleep(self.period - dt)
|
||||
cap.release()
|
||||
pose.close(); face.close(); hand.close()
|
||||
if pose is not None:
|
||||
pose.close()
|
||||
if face is not None:
|
||||
face.close()
|
||||
if hand is not None:
|
||||
hand.close()
|
||||
LOG.info("multi worker stopped")
|
||||
|
||||
@@ -83,9 +83,8 @@ class MultiHMRWorker:
|
||||
# backend: 'pytorch' (default) or 'coreml'. CoreML uses the
|
||||
# .mlpackage at COREML_MLPACKAGE, bypasses MPS torch, and runs
|
||||
# on ANE/GPU/CPU via CoreML.framework natively (3-4x faster).
|
||||
self.backend = (backend
|
||||
or os.environ.get("MULTIHMR_BACKEND", "pytorch")
|
||||
).strip().lower()
|
||||
from .config import VizConfig as _VizConfig
|
||||
self.backend = (backend or _VizConfig.from_env().multihmr_backend).strip().lower()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._smooth_shape = [
|
||||
@@ -107,7 +106,8 @@ class MultiHMRWorker:
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
backend = os.environ.get("MULTIHMR_BACKEND", "pytorch").strip().lower()
|
||||
from .config import VizConfig as _VizConfig
|
||||
backend = _VizConfig.from_env().multihmr_backend
|
||||
if backend == "coreml":
|
||||
return COREML_MLPACKAGE.exists()
|
||||
if backend == "remote":
|
||||
@@ -264,9 +264,10 @@ class MultiHMRWorker:
|
||||
# autocast MPS teste 2026-05-13 : plus lent (400ms vs 270ms
|
||||
# baseline) car overhead de cast dans le forward. Defaut OFF.
|
||||
# Opt-in via MULTIHMR_AUTOCAST=1.
|
||||
from .config import VizConfig as _VizConfig
|
||||
self._use_autocast = (
|
||||
device == "mps"
|
||||
and os.environ.get("MULTIHMR_AUTOCAST", "0") == "1")
|
||||
and _VizConfig.from_env().multihmr_autocast)
|
||||
if self._use_autocast:
|
||||
LOG.info("Multi-HMR PyTorch : MPS autocast (fp16) enabled")
|
||||
# torch.compile teste 2026-05-13 : plante en runtime avec
|
||||
@@ -575,13 +576,14 @@ class MultiHMRWorker:
|
||||
return
|
||||
try:
|
||||
if remote:
|
||||
from .config import VizConfig as _VizConfig
|
||||
from .multihmr_remote import MultiHMRRemoteBackend
|
||||
host = os.environ.get(
|
||||
"MULTIHMR_REMOTE_HOST", "192.168.0.175")
|
||||
port = int(os.environ.get(
|
||||
"MULTIHMR_REMOTE_PORT", "57140"))
|
||||
backend = MultiHMRRemoteBackend(host=host, port=port)
|
||||
LOG.info("Multi-HMR remote backend (%s:%d)", host, port)
|
||||
_rc = _VizConfig.from_env()
|
||||
backend = MultiHMRRemoteBackend(
|
||||
host=_rc.multihmr_remote_host,
|
||||
port=_rc.multihmr_remote_port)
|
||||
LOG.info("Multi-HMR remote backend (%s:%d)",
|
||||
_rc.multihmr_remote_host, _rc.multihmr_remote_port)
|
||||
else:
|
||||
from .multihmr_coreml import MultiHMRCoreMLBackend
|
||||
backend = MultiHMRCoreMLBackend(COREML_MLPACKAGE)
|
||||
|
||||
@@ -184,7 +184,8 @@ class MultiHMRCoreMLBackend:
|
||||
# Standalone (no contention) FP32 = 139 ms = 7.2 fps. Default
|
||||
# stays CPU+GPU. Override with COREML_COMPUTE_UNITS env var
|
||||
# (`all`, `cpu_and_gpu`, `cpu_and_ne`, `cpu_only`) for A/B testing.
|
||||
cu_env = os.environ.get("COREML_COMPUTE_UNITS", "").strip().lower()
|
||||
from .config import VizConfig as _VizConfig
|
||||
cu_env = _VizConfig.from_env().coreml_compute_units
|
||||
cu_map = {"cpu_only": 0, "cpu_and_gpu": 1, "all": 2,
|
||||
"cpu_and_ne": 3}
|
||||
cu = cu_map.get(cu_env, 1)
|
||||
|
||||
@@ -232,10 +232,11 @@ class MultiHMRRemoteBackend:
|
||||
self._sock: socket.socket | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self.use_jpeg = _env_flag("MULTIHMR_REMOTE_JPEG", True)
|
||||
self.jpeg_quality = int(os.environ.get(
|
||||
"MULTIHMR_REMOTE_JPEG_QUALITY", "80"))
|
||||
self.use_async = _env_flag("MULTIHMR_REMOTE_ASYNC", True)
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self.use_jpeg = _cfg.multihmr_remote_jpeg
|
||||
self.jpeg_quality = _cfg.multihmr_remote_jpeg_quality
|
||||
self.use_async = _cfg.multihmr_remote_async
|
||||
|
||||
# Async pipeline state.
|
||||
# Multi-buffer queues (2 in / 3 out) absorb jitter without
|
||||
@@ -290,10 +291,10 @@ class MultiHMRRemoteBackend:
|
||||
@staticmethod
|
||||
def is_available(host: str | None = None, port: int | None = None
|
||||
) -> bool:
|
||||
host = host or os.environ.get(
|
||||
"MULTIHMR_REMOTE_HOST", "192.168.0.175")
|
||||
port = port or int(os.environ.get(
|
||||
"MULTIHMR_REMOTE_PORT", "57140"))
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
host = host or _cfg.multihmr_remote_host
|
||||
port = port or _cfg.multihmr_remote_port
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1.0)
|
||||
|
||||
@@ -10,6 +10,7 @@ Routes emises :
|
||||
/pose/head <pid> <x> <y> <c> position du nez (visage)
|
||||
/pose/sho_span <pid> <dx> ecart epaules (estime distance camera)
|
||||
/pose/limb_span <pid> <span> envergure brassse (poignet a poignet)
|
||||
/pose/mouth <pid> <open> mouth openness 0..1
|
||||
/face/count <n> nombre de visages detectes
|
||||
/face/kp <pid> <idx> <x> <y> <z> <c> 68-pt subset (dlib mapping)
|
||||
/hand/count <n_left> <n_right> nombre de mains gauche / droite
|
||||
@@ -85,16 +86,15 @@ class PoseSoundBridge:
|
||||
self._client = SimpleUDPClient(sclang_host, sclang_port)
|
||||
# Broadcast secondaire vers AV-Live-Body (Swift) pour overlay
|
||||
# skeleton dans la fenetre RealityKit. Silent si pas connecte.
|
||||
import os as _os
|
||||
_avbody_host = _os.environ.get("AVBODY_HOST", "127.0.0.1")
|
||||
self._avbody = SimpleUDPClient(_avbody_host, 57126)
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self._avbody = SimpleUDPClient(_cfg.avbody_host, 57126)
|
||||
# Optional VDMX (VJ) OSC mirror. Off by default; when VDMX_OSC_HOST is
|
||||
# set, the VJ-useful pose signals (hands/kin/center/action/count) are
|
||||
# forwarded to VDMX's OSC input so the body can drive any VDMX param.
|
||||
_vdmx_host = _os.environ.get("VDMX_OSC_HOST")
|
||||
self._vdmx = (
|
||||
SimpleUDPClient(_vdmx_host, int(_os.environ.get("VDMX_OSC_PORT", "1234")))
|
||||
if _vdmx_host else None
|
||||
SimpleUDPClient(_cfg.vdmx_osc_host, _cfg.vdmx_osc_port)
|
||||
if _cfg.vdmx_osc_host else None
|
||||
)
|
||||
self._period = 1.0 / max(1.0, throttle_hz)
|
||||
self._last_t = 0.0
|
||||
@@ -350,6 +350,15 @@ class PoseSoundBridge:
|
||||
self._client.send_message("/pose/kin", kin_args)
|
||||
self._vj("/pose/kin", kin_args)
|
||||
|
||||
def send_mouth(self, pid: int, mouth_open: float) -> None:
|
||||
"""Send mouth openness via /pose/mouth OSC route.
|
||||
|
||||
Sends: [pid (int), mouth_open (float)] — mouth_open is 0..1.
|
||||
"""
|
||||
args = [int(pid), float(mouth_open)]
|
||||
self._client.send_message("/pose/mouth", args)
|
||||
self._vj("/pose/mouth", args)
|
||||
|
||||
@staticmethod
|
||||
def _hand_slot(h):
|
||||
if not h:
|
||||
@@ -388,8 +397,8 @@ class PoseSoundBridge:
|
||||
self._vj("/pose/finger", args)
|
||||
|
||||
def send_pinch(self, ev) -> None:
|
||||
"""Emit one thumb-to-finger pinch event (clip toggle trigger)."""
|
||||
args = [0, int(ev.hand), int(ev.finger)]
|
||||
"""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))]
|
||||
try:
|
||||
self._client.send_message("/pose/pinch", args)
|
||||
except OSError:
|
||||
|
||||
@@ -457,7 +457,8 @@ class IKConstraints:
|
||||
# --------------------------- chain wrapper ------------------------------
|
||||
|
||||
def _parse_env_stages() -> tuple[str, ...]:
|
||||
raw = os.environ.get("POSE_FILTER")
|
||||
from .config import VizConfig
|
||||
raw = VizConfig.from_env().pose_filter
|
||||
if raw is None:
|
||||
return DEFAULT_STAGES
|
||||
raw = raw.strip().lower()
|
||||
@@ -709,7 +710,8 @@ class ArkitFuse:
|
||||
# (median + Kalman 2D + lookahead) — no IK, no spring.
|
||||
|
||||
def _parse_env_face_stages() -> tuple[str, ...]:
|
||||
raw = os.environ.get("POSE_FILTER_FACE")
|
||||
from .config import VizConfig
|
||||
raw = VizConfig.from_env().pose_filter_face
|
||||
if raw is None:
|
||||
return ("median", "kalman", "lookahead")
|
||||
raw = raw.strip().lower()
|
||||
@@ -720,7 +722,8 @@ def _parse_env_face_stages() -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _parse_env_hand_stages() -> tuple[str, ...]:
|
||||
raw = os.environ.get("POSE_FILTER_HAND")
|
||||
from .config import VizConfig
|
||||
raw = VizConfig.from_env().pose_filter_hand
|
||||
if raw is None:
|
||||
return ("median", "kalman", "lookahead")
|
||||
raw = raw.strip().lower()
|
||||
|
||||
+256
-20
@@ -15,6 +15,7 @@ sous lock a chaque frame (60 fps).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -42,14 +43,29 @@ from Metal import (
|
||||
# pyobjc detecte automatiquement l'implementation par signature.
|
||||
from MetalKit import MTKView # noqa: F401 (utilise par d'autres modules)
|
||||
|
||||
from .arkit_skeleton import arkit_segments, finger_joint_mask
|
||||
from .hand_display import (
|
||||
HandPersistenceGate,
|
||||
arkit_2d_fresh,
|
||||
gauge_segments,
|
||||
hand_plausible,
|
||||
hand_size,
|
||||
segment_ok,
|
||||
panel_frame,
|
||||
panel_segments,
|
||||
)
|
||||
from .hand_slots import route_hands
|
||||
from .mesh_topology import (
|
||||
BODY_TRIANGLES,
|
||||
FACE_TRIANGLES,
|
||||
HAND_TRIANGLES,
|
||||
build_face_triangles_dynamic,
|
||||
)
|
||||
from .state import State
|
||||
|
||||
# Draw the full ARKit 91-joint body (topology-driven) instead of the
|
||||
# MP33-reduced body. Set ARKIT_FULL_SKELETON=0 to fall back to MP33.
|
||||
ARKIT_FULL = os.environ.get("ARKIT_FULL_SKELETON", "1") != "0"
|
||||
|
||||
LOG = logging.getLogger("renderer")
|
||||
|
||||
# Triangle primitive constant (Metal MTLPrimitiveType.triangle = 3)
|
||||
@@ -145,6 +161,17 @@ class MetalRenderer(NSObject):
|
||||
self._mesh_buf = self._device.newBufferWithLength_options_(
|
||||
MESH_MAX_TRIS * 3 * MESH_VERT_FLOATS * 4, MTLResourceStorageModeShared)
|
||||
self._mp_bones = _mediapipe_bones() # None si pas dispo
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self._hand_conf_min = _cfg.hand_conf_min
|
||||
self._arkit_bone_max = _cfg.arkit_bone_max
|
||||
self._hand_gate = HandPersistenceGate(min_frames=_cfg.hand_persist_frames, grace=_cfg.hand_persist_grace)
|
||||
# Safety knob: invert Vision chirality interpretation.
|
||||
# Chirality validated correct live 2026-07-02; keep False unless a
|
||||
# future iPhone app build flips it.
|
||||
self._hand_swap_lr = _cfg.hand_swap_lr
|
||||
self._arkit_full = _cfg.arkit_full_skeleton
|
||||
self._skel_width = max(1, int(_cfg.skel_line_width))
|
||||
self._init_skel_cpu_buffer()
|
||||
self._init_mesh_cpu_buffer()
|
||||
self._build_pipelines()
|
||||
@@ -314,11 +341,19 @@ class MetalRenderer(NSObject):
|
||||
|
||||
Priorise MediaPipe (body 33 + face 478 + 2 mains 21) si disponible
|
||||
et present ; sinon fallback COCO 17 keypoints YOLO."""
|
||||
if not s.pose_alive():
|
||||
return 0
|
||||
_arkit_now = time.perf_counter()
|
||||
use_arkit = (getattr(self, '_arkit_full', ARKIT_FULL) and bool(s.arkit_parents)
|
||||
and arkit_2d_fresh(s.persons_arkit_2d_t, _arkit_now))
|
||||
# NO early-return when nothing is detected: the panel frames and the
|
||||
# X/Y gauges are permanent screen furniture (user request: indicators
|
||||
# must stay visible with no hands). The overlay loops below iterate
|
||||
# empty collections at negligible cost.
|
||||
|
||||
buf = self._skel_cpu_buf
|
||||
segs = 0
|
||||
# Mirror overlays in X to match the (already mirrored) iPhone video
|
||||
# background. Display-only: gesture data is untouched.
|
||||
mirror = bool(getattr(s, "mirror_2d", False))
|
||||
|
||||
def push(A, B, conf, pid):
|
||||
"""Empile un segment (2 verts) dans le buffer CPU prealloque."""
|
||||
@@ -327,12 +362,93 @@ class MetalRenderer(NSObject):
|
||||
return False
|
||||
ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
|
||||
bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
|
||||
if mirror:
|
||||
ax = -ax; bx = -bx
|
||||
i = segs * 10
|
||||
buf[i+0] = ax; buf[i+1] = ay; buf[i+2] = float(A.z); buf[i+3] = conf; buf[i+4] = float(pid)
|
||||
buf[i+5] = bx; buf[i+6] = by; buf[i+7] = float(B.z); buf[i+8] = conf; buf[i+9] = float(pid)
|
||||
segs += 1
|
||||
return True
|
||||
|
||||
def push_seg(ax, ay, bx, by, conf, pid):
|
||||
"""Like push() but takes raw normalized [0,1] coords."""
|
||||
nonlocal segs
|
||||
if segs >= SKEL_MAX_SEGS:
|
||||
return False
|
||||
cax = ax * 2.0 - 1.0; cay = 1.0 - ay * 2.0
|
||||
cbx = bx * 2.0 - 1.0; cby = 1.0 - by * 2.0
|
||||
if mirror:
|
||||
cax = -cax; cbx = -cbx
|
||||
i = segs * 10
|
||||
buf[i+0] = cax; buf[i+1] = cay; buf[i+2] = 0.0
|
||||
buf[i+3] = conf; buf[i+4] = float(pid)
|
||||
buf[i+5] = cbx; buf[i+6] = cby; buf[i+7] = 0.0
|
||||
buf[i+8] = conf; buf[i+9] = float(pid)
|
||||
segs += 1
|
||||
return True
|
||||
|
||||
def push_panel(ax, ay, bx, by, conf, pid):
|
||||
"""Like push_seg but NEVER applies mirror.
|
||||
|
||||
Panel coords are final screen coords [0,1] — they must not be
|
||||
flipped again even when the video background is mirrored.
|
||||
"""
|
||||
nonlocal segs
|
||||
if segs >= SKEL_MAX_SEGS:
|
||||
return False
|
||||
cax = ax * 2.0 - 1.0; cay = 1.0 - ay * 2.0
|
||||
cbx = bx * 2.0 - 1.0; cby = 1.0 - by * 2.0
|
||||
i = segs * 10
|
||||
buf[i+0] = cax; buf[i+1] = cay; buf[i+2] = 0.0
|
||||
buf[i+3] = conf; buf[i+4] = float(pid)
|
||||
buf[i+5] = cbx; buf[i+6] = cby; buf[i+7] = 0.0
|
||||
buf[i+8] = conf; buf[i+9] = float(pid)
|
||||
segs += 1
|
||||
return True
|
||||
|
||||
# Gate called once per frame on the full persons_hands list.
|
||||
# Used for both the overlay loop and the side panels below.
|
||||
# getattr: unit tests build the renderer via __new__ (no Metal init).
|
||||
_gate = getattr(self, "_hand_gate", None)
|
||||
_hand_draw_flags = (_gate.step(s.persons_hands) if _gate is not None
|
||||
else [True] * len(s.persons_hands))
|
||||
|
||||
if use_arkit:
|
||||
parents = s.arkit_parents
|
||||
# Hide ARKit's per-finger joints ("false hands"): the display
|
||||
# shows the filtered Vision hands instead. Mask cached until the
|
||||
# topology changes.
|
||||
fmask = getattr(self, "_finger_mask", None)
|
||||
if fmask is None or len(fmask) != len(s.arkit_joint_names):
|
||||
fmask = np.asarray(
|
||||
finger_joint_mask(s.arkit_joint_names), dtype=bool)
|
||||
self._finger_mask = fmask
|
||||
for pid, arr2d in s.persons_arkit_2d.items():
|
||||
ts = s.persons_arkit_2d_t.get(pid)
|
||||
if ts is None or (_arkit_now - ts) >= 1.0:
|
||||
continue # skip stale or unknown pid
|
||||
valid = s.persons_arkit_2d_valid.get(pid)
|
||||
if len(fmask):
|
||||
if valid is None:
|
||||
valid = ~fmask
|
||||
elif len(valid) == len(fmask):
|
||||
valid = valid & ~fmask
|
||||
# Bold strokes: Metal lines are 1px, so each bone is drawn
|
||||
# in concentric offset passes (SKEL_LINE_WIDTH approx px).
|
||||
_w = getattr(self, "_skel_width", 1)
|
||||
_o = 1.0 / max(1, s.height or 720)
|
||||
_offsets = [(0.0, 0.0)]
|
||||
for _r in range(1, _w):
|
||||
_d = _r * _o
|
||||
_offsets += [(_d, 0.0), (-_d, 0.0), (0.0, _d), (0.0, -_d)]
|
||||
for (ax, ay, bx, by) in arkit_segments(
|
||||
arr2d, valid, parents, max_len=self._arkit_bone_max
|
||||
):
|
||||
for _dx, _dy in _offsets:
|
||||
if not push_seg(ax + _dx, ay + _dy,
|
||||
bx + _dx, by + _dy, 1.0, pid):
|
||||
break
|
||||
|
||||
if self._mp_bones is not None and (
|
||||
s.persons_body or s.persons_face or s.persons_hands or
|
||||
s.body_present or s.face_present or s.hands_present
|
||||
@@ -343,7 +459,7 @@ class MetalRenderer(NSObject):
|
||||
ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
|
||||
ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
|
||||
ids_h = s.persons_hands_ids or list(range(len(s.persons_hands)))
|
||||
for i, body_kp in enumerate(s.persons_body):
|
||||
for i, body_kp in enumerate([] if use_arkit else s.persons_body):
|
||||
pid = ids_b[i] if i < len(ids_b) else i
|
||||
for a, b in body_bones:
|
||||
if a >= len(body_kp) or b >= len(body_kp): continue
|
||||
@@ -356,15 +472,35 @@ class MetalRenderer(NSObject):
|
||||
if a >= len(face_kp) or b >= len(face_kp): continue
|
||||
if not push(face_kp[a], face_kp[b], 1.0, pid): break
|
||||
if segs >= SKEL_MAX_SEGS: break
|
||||
# Bold hand strokes: same concentric-offset technique as the
|
||||
# ARKit body (SKEL_LINE_WIDTH), one step thinner so small hands
|
||||
# do not blob.
|
||||
_hw = max(1, getattr(self, "_skel_width", 1) - 1)
|
||||
_ho = 1.0 / max(1, s.height or 720)
|
||||
_hoffs = [(0.0, 0.0)]
|
||||
for _r in range(1, _hw):
|
||||
_hd = _r * _ho
|
||||
_hoffs += [(_hd, 0.0), (-_hd, 0.0), (0.0, _hd), (0.0, -_hd)]
|
||||
for i, hand_kp in enumerate(s.persons_hands):
|
||||
if not _hand_draw_flags[i]:
|
||||
continue
|
||||
pid = ids_h[i] if i < len(ids_h) else i
|
||||
if not hand_plausible(hand_kp, conf_min=self._hand_conf_min):
|
||||
continue
|
||||
size = hand_size(hand_kp)
|
||||
for a, b in lhand_bones:
|
||||
if a >= len(hand_kp) or b >= len(hand_kp): continue
|
||||
A = hand_kp[a]; B = hand_kp[b]
|
||||
if not segment_ok(A, B, size, conf_min=self._hand_conf_min): continue
|
||||
# Decalage palette mains (+5) pour les distinguer
|
||||
if not push(hand_kp[a], hand_kp[b], 1.0, pid + 5): break
|
||||
_c = min(A.c, B.c)
|
||||
for _dx, _dy in _hoffs:
|
||||
if not push_seg(A.x + _dx, A.y + _dy,
|
||||
B.x + _dx, B.y + _dy, _c, pid + 5):
|
||||
break
|
||||
# ----- FALLBACK single-person si persons_* vides -----
|
||||
if not (s.persons_body or s.persons_face or s.persons_hands):
|
||||
if s.body_present:
|
||||
if s.body_present and not use_arkit:
|
||||
for a, b in body_bones:
|
||||
if a >= len(s.body_kp) or b >= len(s.body_kp): continue
|
||||
A = s.body_kp[a]; B = s.body_kp[b]
|
||||
@@ -378,9 +514,14 @@ class MetalRenderer(NSObject):
|
||||
for kp_list in (s.left_hand_kp, s.right_hand_kp):
|
||||
if not any(p.x != 0.0 or p.y != 0.0 for p in kp_list):
|
||||
continue
|
||||
if not hand_plausible(kp_list, conf_min=self._hand_conf_min):
|
||||
continue
|
||||
size = hand_size(kp_list)
|
||||
for a, b in lhand_bones:
|
||||
if a >= len(kp_list) or b >= len(kp_list): continue
|
||||
if not push(kp_list[a], kp_list[b], 1.0, 0): break
|
||||
A = kp_list[a]; B = kp_list[b]
|
||||
if not segment_ok(A, B, size, conf_min=self._hand_conf_min): continue
|
||||
if not push(A, B, min(A.c, B.c), 0): break
|
||||
else:
|
||||
# Fallback COCO 17 (YOLO legacy)
|
||||
for a, b in COCO_BONES:
|
||||
@@ -388,6 +529,105 @@ class MetalRenderer(NSObject):
|
||||
if A.c < 0.2 or B.c < 0.2: continue
|
||||
if not push(A, B, min(A.c, B.c), 0): break
|
||||
|
||||
# ---- SIDE PANELS: left/right hand front-view zoomed wireframe ----
|
||||
# Gated on self._mp_bones (panels need _mp_bones for hand bone topology,
|
||||
# even when use_arkit is True). Hands from s.persons_hands only.
|
||||
if self._mp_bones is not None:
|
||||
_body_b, _face_b, lhand_bones_p, _ = self._mp_bones
|
||||
# Filter hands by persistence gate + plausibility before routing
|
||||
# to panels (a ghost hand must not steal a slot from a real one).
|
||||
keep = [
|
||||
ok and hand_plausible(h, conf_min=self._hand_conf_min)
|
||||
for h, ok in zip(s.persons_hands, _hand_draw_flags)
|
||||
]
|
||||
gated_hands = [h for h, k in zip(s.persons_hands, keep) if k]
|
||||
chir_src = getattr(s, "persons_hands_chirality", None) or []
|
||||
# Keep chirality aligned with the gated hands (same filter mask).
|
||||
if chir_src and len(chir_src) == len(s.persons_hands):
|
||||
gated_chir = [c for c, k in zip(chir_src, keep) if k]
|
||||
else:
|
||||
gated_chir = None
|
||||
asp = float(s.width) / float(s.height) if s.width and s.height else 1.0
|
||||
|
||||
# Route to L/R panels. mirror= passed for cx fallback path;
|
||||
# no near_min — panels show far hands as user proximity feedback.
|
||||
slotted = route_hands(
|
||||
gated_hands, gated_chir,
|
||||
mirror=mirror,
|
||||
swap=self._hand_swap_lr,
|
||||
)
|
||||
left_kp, right_kp = slotted
|
||||
|
||||
# Panel frame: status drives hue (pid 7/8/9), quality drives
|
||||
# brightness (0.25+0.75*q) and thickness (1..3 stroke passes).
|
||||
# pid 7 conf=0.25+0.75*q → status 0: absent (q=0)
|
||||
# → status 1: detected (q≥0.30)
|
||||
# pid 8 conf=0.25+0.75*q → status 2: armed (near+facing)
|
||||
# pid 9 conf=1.0 (q=1.0) → status 3: pinch engaged
|
||||
# Thickness: pass 1 always; pass 2 at q≥0.50; pass 3 at q≥0.85 or status==3.
|
||||
_slot_status = getattr(s, "gesture_slot_status", [0, 0])
|
||||
_slot_quality = getattr(s, "gesture_slot_quality", [0.0, 0.0])
|
||||
_hf_all = getattr(s, "hand_feats", None) or {}
|
||||
for _si, (side_name, h_kp, pid_s) in enumerate((
|
||||
("left", left_kp, 5),
|
||||
("right", right_kp, 6),
|
||||
)):
|
||||
_st = _slot_status[_si] if _si < len(_slot_status) else 0
|
||||
_q = float(_slot_quality[_si]) if _si < len(_slot_quality) else 0.0
|
||||
_fconf = 0.25 + 0.75 * _q
|
||||
# Status drives hue (pid selection)
|
||||
_fpid = 9 if _st == 3 else (8 if _st == 2 else 7)
|
||||
_frame_segs = panel_frame(side_name, asp)
|
||||
# Pass 1: always
|
||||
for seg in _frame_segs:
|
||||
push_panel(*seg, _fconf, _fpid)
|
||||
# Pass 2: thicker when quality ≥ 0.50
|
||||
if _q >= 0.5:
|
||||
for ax, ay, bx, by in _frame_segs:
|
||||
push_panel(ax + 0.001, ay, bx + 0.001, by, _fconf, _fpid)
|
||||
# Pass 3: boldest when quality ≥ 0.85 or pinch engaged
|
||||
if _q >= 0.85 or _st == 3:
|
||||
for ax, ay, bx, by in _frame_segs:
|
||||
push_panel(ax + 0.002, ay, bx + 0.002, by, _fconf, _fpid)
|
||||
# Position gauges (X below / Y outer-side). SC's mod cache
|
||||
# HOLDS the last received value when a hand vanishes, so the
|
||||
# markers must stay visible at that last value too (user:
|
||||
# "X et Y toujours visibles") — live = full conf, held = dim.
|
||||
_slot_key = "L" if side_name == "left" else "R"
|
||||
_slot_hf = _hf_all.get(_slot_key)
|
||||
if not hasattr(self, "_gauge_hold"):
|
||||
self._gauge_hold = {"L": (None, None), "R": (None, None)}
|
||||
_live = _slot_hf is not None
|
||||
if _live:
|
||||
self._gauge_hold[_slot_key] = (_slot_hf["cx"],
|
||||
_slot_hf["cy"])
|
||||
_g_cx, _g_cy = self._gauge_hold[_slot_key]
|
||||
_mk_conf = 1.0 if _live else 0.45
|
||||
for ax, ay, bx, by, gconf, gpid in gauge_segments(
|
||||
_g_cx, _g_cy, side_name, asp, mirror,
|
||||
content_pid=pid_s,
|
||||
):
|
||||
push_panel(ax, ay, bx, by,
|
||||
min(gconf, _mk_conf) if gpid == pid_s
|
||||
else gconf, gpid)
|
||||
# Wrist marker ON the video: the X/Y voice mods are anchored
|
||||
# to the wrist, so its control point stays visible whenever
|
||||
# a value exists (held at the last position when the hand
|
||||
# drops, matching what SC hears). push_seg applies the
|
||||
# video mirror like the overlay.
|
||||
if _g_cx is not None and _g_cy is not None:
|
||||
_mx = 0.015 / asp
|
||||
push_seg(_g_cx - _mx, _g_cy, _g_cx + _mx, _g_cy,
|
||||
_mk_conf, pid_s)
|
||||
push_seg(_g_cx, _g_cy - 0.015, _g_cx, _g_cy + 0.015,
|
||||
_mk_conf, pid_s)
|
||||
if h_kp is not None:
|
||||
for seg in panel_segments(
|
||||
h_kp, side_name, lhand_bones_p, asp,
|
||||
mirror=mirror, conf_min=self._hand_conf_min,
|
||||
):
|
||||
push_panel(*seg, 1.0, pid_s)
|
||||
|
||||
if segs == 0:
|
||||
return 0
|
||||
data = self._skel_cpu_buf[: segs * 2 * SKEL_VERT_FLOATS].tobytes()
|
||||
@@ -396,7 +636,7 @@ class MetalRenderer(NSObject):
|
||||
return segs
|
||||
|
||||
def _update_mesh(self, s: State) -> int:
|
||||
"""Remplit self._mesh_buf avec des triangles face/hand/body.
|
||||
"""Remplit self._mesh_buf avec des triangles face/body.
|
||||
|
||||
Retourne le nombre de triangles ecrits (chacun = 3 vertices).
|
||||
Filtre les triangles dont au moins un sommet a confiance < 0.3.
|
||||
@@ -446,10 +686,15 @@ class MetalRenderer(NSObject):
|
||||
|
||||
ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
|
||||
ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
|
||||
ids_h = s.persons_hands_ids or list(range(len(s.persons_hands)))
|
||||
|
||||
# Body
|
||||
for i, body_kp in enumerate(s.persons_body):
|
||||
# Body — SKIPPED whenever ARKit is the body source (iphone-usb: the
|
||||
# ARKit topology arrived). persons_body is then the ARKit->MP33
|
||||
# conversion with c forced to 1.0, so garbage/stale joints would pass
|
||||
# the confidence filter and paint solid triangles anywhere on screen
|
||||
# (the "blue blob"). The body is the gated ARKit wireframe instead.
|
||||
arkit_body = (getattr(self, "_arkit_full", ARKIT_FULL)
|
||||
and bool(s.arkit_parents))
|
||||
for i, body_kp in enumerate([] if arkit_body else s.persons_body):
|
||||
pid = ids_b[i] if i < len(ids_b) else i
|
||||
for a, b, c in BODY_TRIANGLES:
|
||||
if not push_tri(body_kp, a, b, c, pid):
|
||||
@@ -469,15 +714,6 @@ class MetalRenderer(NSObject):
|
||||
if n_verts >= MESH_MAX_VERTS:
|
||||
break
|
||||
|
||||
# Hands — decalage palette +5 comme dans le skel
|
||||
for i, hand_kp in enumerate(s.persons_hands):
|
||||
pid = (ids_h[i] if i < len(ids_h) else i) + 5
|
||||
for a, b, c in HAND_TRIANGLES:
|
||||
if not push_tri(hand_kp, a, b, c, pid):
|
||||
break
|
||||
if n_verts >= MESH_MAX_VERTS:
|
||||
break
|
||||
|
||||
if n_verts == 0:
|
||||
return 0
|
||||
# Slice is exact — stale floats beyond n_verts*MESH_VERT_FLOATS never reach the GPU.
|
||||
|
||||
@@ -44,6 +44,9 @@ TAG_SKELETON = 1
|
||||
JOINT_COUNT = 91
|
||||
SKEL_FLOAT_BYTES = JOINT_COUNT * 3 * 4 # 1092
|
||||
SKEL_BYTES = SKEL_FLOAT_BYTES + JOINT_COUNT # 1183
|
||||
TAG_SKELETON2D = 6 # wire: skeleton2D (face=5 taken)
|
||||
SKEL2D_FLOAT_BYTES = JOINT_COUNT * 2 * 4 # 728
|
||||
SKEL2D_BYTES = SKEL2D_FLOAT_BYTES + JOINT_COUNT # 819
|
||||
MAX_PAYLOAD = 8 * 1024 * 1024
|
||||
|
||||
LOG = logging.getLogger("iphone_usb_bridge")
|
||||
@@ -169,6 +172,19 @@ def decode_skeleton(payload: bytes):
|
||||
]
|
||||
|
||||
|
||||
def decode_skeleton2D(payload: bytes):
|
||||
"""Return [(x, y, valid), ...] of length 91 (normalized 0..1), or None.
|
||||
|
||||
Wire layout: 91 × (x f32 BE, y f32 BE) = 728 bytes, then 91 validity bytes.
|
||||
Total: SKEL2D_BYTES = 819.
|
||||
"""
|
||||
if len(payload) != SKEL2D_BYTES:
|
||||
return None
|
||||
floats = struct.unpack(">" + "f" * (JOINT_COUNT * 2), payload[:SKEL2D_FLOAT_BYTES])
|
||||
valid = payload[SKEL2D_FLOAT_BYTES:]
|
||||
return [(floats[i * 2], floats[i * 2 + 1], valid[i] != 0) for i in range(JOINT_COUNT)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# main loop
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -46,8 +46,12 @@ class SMPLXTCPSender:
|
||||
port: int = PORT, target_fps: float = 30.0,
|
||||
enable_rigging: bool = True) -> None:
|
||||
import os as _os
|
||||
from .config import VizConfig as _VizConfig
|
||||
_cfg = _VizConfig.from_env()
|
||||
self.state = state
|
||||
self.host = _os.environ.get("AVBODY_HOST", host)
|
||||
# AVBODY_HOST env wins; otherwise honor the caller-supplied host
|
||||
# (pre-VizConfig contract: environ.get("AVBODY_HOST", host)).
|
||||
self.host = _cfg.avbody_host if "AVBODY_HOST" in _os.environ else host
|
||||
self.port = port
|
||||
self.period = 1.0 / max(1.0, target_fps)
|
||||
self._stop = threading.Event()
|
||||
@@ -57,7 +61,7 @@ class SMPLXTCPSender:
|
||||
# on translate le mesh via le delta pelvis Apple Vision (30 fps).
|
||||
# MULTIHMR_REID: 'dino' (try DINOv2 + IoU fusion, fallback IoU) /
|
||||
# 'iou' (pure IoU). Default: 'dino' if mlpackage exists.
|
||||
reid_mode = os.environ.get("MULTIHMR_REID", "dino").lower()
|
||||
reid_mode = _cfg.multihmr_reid
|
||||
dino = None
|
||||
if enable_rigging and reid_mode == "dino" and DinoReid is not None:
|
||||
try:
|
||||
@@ -70,7 +74,7 @@ class SMPLXTCPSender:
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("MeshRigger: dino load failed (%s), IoU only", e)
|
||||
dino = None
|
||||
dino_weight = float(os.environ.get("MULTIHMR_REID_ALPHA", "0.5"))
|
||||
dino_weight = _cfg.multihmr_reid_alpha
|
||||
self._rigger = MeshRigger(
|
||||
state, dino_weight=dino_weight,
|
||||
dino_reid=dino) if enable_rigging else None
|
||||
|
||||
@@ -107,6 +107,10 @@ class State:
|
||||
# so the air-piano can prefer this stabler, rotation-invariant source.
|
||||
persons_hands_iphone: list[list[PoseKp]] = field(default_factory=list)
|
||||
persons_hands_iphone_t: float = 0.0
|
||||
# Chirality for each entry in persons_hands_iphone: 0=left, 1=right.
|
||||
# Aligned 1:1 with persons_hands_iphone (same length after each TAG_HANDS update).
|
||||
# Not valid for MediaPipe-written persons_hands (stays empty on that path).
|
||||
persons_hands_chirality: list[int] = field(default_factory=list)
|
||||
hand_feats: dict | None = None
|
||||
# MediaPipe pose_world_landmarks per person : 33 keypoints in meters,
|
||||
# relative to the hip-center. Optional companion of persons_body
|
||||
@@ -144,6 +148,15 @@ class State:
|
||||
# (metres, hip-centered). Fresh = updated within < 1 s.
|
||||
persons_arkit_joints: dict = field(default_factory=dict)
|
||||
persons_arkit_last_t: dict = field(default_factory=dict)
|
||||
# ARKit 2D projected skeleton (Task 3): 91×2 float32 arrays (normalized
|
||||
# screen coords 0..1) per pid. Updated by IphoneUSBSource on TAG_SKELETON2D.
|
||||
persons_arkit_2d: dict[int, "np.ndarray"] = field(default_factory=dict)
|
||||
persons_arkit_2d_t: dict[int, float] = field(default_factory=dict)
|
||||
persons_arkit_2d_valid: dict = field(default_factory=dict)
|
||||
arkit_joint_names: list = field(default_factory=list)
|
||||
arkit_parents: list = field(default_factory=list)
|
||||
# iPhone video is mirrored (CONCERT_MIRROR); renderer mirrors overlays to match.
|
||||
mirror_2d: bool = False
|
||||
|
||||
# ---- LiDAR / ICP mesh fusion (Task 8 - 2026-05-14) ----
|
||||
# Set by the LidarTCPReader poller; consumed by FusionWorker.run_once.
|
||||
@@ -163,6 +176,13 @@ class State:
|
||||
latest_bgr_id: int = 0
|
||||
latest_bgr_t: float = 0.0
|
||||
|
||||
# Gesture slot status per hand slot (written by action_head_pub, read by renderer):
|
||||
# 0=absent, 1=detected(plausible+established not armed), 2=armed(near+facing), 3=pinch engaged
|
||||
gesture_slot_status: list[int] = field(default_factory=lambda: [0, 0])
|
||||
# Continuous quality score per slot ∈ [0, 1] (written alongside gesture_slot_status).
|
||||
# Drives panel frame brightness (0.25+0.75*q) and stroke thickness.
|
||||
gesture_slot_quality: list[float] = field(default_factory=lambda: [0.0, 0.0])
|
||||
|
||||
# Renderer
|
||||
width: int = 1280
|
||||
height: int = 720
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from data_only_viz.finger_strike import FINGERTIPS, FINGER_BASES
|
||||
@@ -10,16 +11,38 @@ from data_only_viz.finger_strike import FINGERTIPS, FINGER_BASES
|
||||
class _FakeState:
|
||||
def __init__(self) -> None:
|
||||
self.persons_hands = []
|
||||
self.mirror_2d = False
|
||||
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]
|
||||
class _Kp:
|
||||
"""Landmark with .x .y .c attrs and index fallback for _coord."""
|
||||
__slots__ = ("x", "y", "z", "c")
|
||||
|
||||
def __init__(self, x: float, y: float, z: float = 0.0, c: float = 1.0) -> None:
|
||||
self.x, self.y, self.z, self.c = x, y, z, c
|
||||
|
||||
def __getitem__(self, i: int) -> float:
|
||||
return (self.x, self.y, self.z)[i]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return 3
|
||||
|
||||
|
||||
def _hand(index_tip_y: float, base_y: float = 0.4, c: float = 1.0):
|
||||
lm = [_Kp(0.3, base_y, 0.0, c) for _ in range(21)]
|
||||
lm[0] = _Kp(0.3, base_y + 0.15, 0.0, c) # wrist 0.15 below center -> hand_size 0.15
|
||||
lm[9] = _Kp(0.3, base_y, 0.0, c) # middle-MCP at center -> size 0.15 > near_min
|
||||
lm[FINGERTIPS[1]] = _Kp(0.3, index_tip_y, 0.0, c)
|
||||
lm[FINGER_BASES[1]] = _Kp(0.3, base_y, 0.0, c)
|
||||
# Spread index-MCP(5) and pinky-MCP(17) AFTER FINGER_BASES[1]=5 assignment
|
||||
# so lm[5] is not overwritten. facing = 0.12/0.15 = 0.8 >= face_min=0.5.
|
||||
# Only x changes; y=base_y is preserved so FingerStrikeDetector base_y is correct.
|
||||
lm[5] = _Kp(0.3 - 0.06, base_y, 0.0, c)
|
||||
lm[17] = _Kp(0.3 + 0.06, base_y, 0.0, c)
|
||||
return lm
|
||||
|
||||
|
||||
@@ -27,6 +50,8 @@ 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")
|
||||
# Instant establishment so legacy strike/pinch tests need no warmup ticks.
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", "1")
|
||||
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)
|
||||
@@ -52,3 +77,228 @@ def test_emit_fingers_noop_when_disabled(monkeypatch):
|
||||
pub.state.persons_hands = [_hand(0.46)]
|
||||
pub._emit_fingers(0.04)
|
||||
assert not pub.bridge.send_finger.called
|
||||
|
||||
|
||||
def _pub_envs(monkeypatch, finger_piano: str, pinch_enable: str):
|
||||
"""Build a publisher with both gates set independently."""
|
||||
monkeypatch.setenv("FINGER_PIANO", finger_piano)
|
||||
monkeypatch.setenv("PINCH_ENABLE", pinch_enable)
|
||||
monkeypatch.setenv("FINGER_STRIKE_VEL", "0.02")
|
||||
monkeypatch.setenv("FINGER_STRIKE_REFRACTORY_MS", "120")
|
||||
# Instant establishment so legacy tests need no warmup ticks.
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", "1")
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
pub = ActionHeadPublisher.__new__(ActionHeadPublisher)
|
||||
pub.state = _FakeState()
|
||||
pub.bridge = MagicMock()
|
||||
pub._init_finger_piano()
|
||||
return pub
|
||||
|
||||
|
||||
def test_emit_pinch_fires_when_pinch_enabled(monkeypatch):
|
||||
# PINCH_ENABLE on, FINGER_PIANO off -> pinches still emit (decoupled).
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
pub._pinch_det = MagicMock()
|
||||
pub._pinch_det.step.return_value = [MagicMock(hand=0, finger=1)]
|
||||
pub.state.persons_hands = [_hand(0.40)]
|
||||
pub._emit_pinch(0.0)
|
||||
assert pub.bridge.send_pinch.called
|
||||
|
||||
|
||||
def test_emit_pinch_off_when_pinch_disabled_even_with_finger_piano(monkeypatch):
|
||||
# Decouple: FINGER_PIANO on but PINCH_ENABLE off -> no pinch emitted.
|
||||
pub = _pub_envs(monkeypatch, finger_piano="1", pinch_enable="0")
|
||||
pub._pinch_det = MagicMock()
|
||||
pub._pinch_det.step.return_value = [MagicMock(hand=0, finger=1)]
|
||||
pub.state.persons_hands = [_hand(0.40)]
|
||||
pub._emit_pinch(0.0)
|
||||
assert not pub.bridge.send_pinch.called
|
||||
|
||||
|
||||
def test_emit_fingers_off_when_only_pinch_enabled(monkeypatch):
|
||||
# Decouple: PINCH_ENABLE on, FINGER_PIANO off -> air-piano stays silent.
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
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
|
||||
|
||||
|
||||
def test_pinch_gate_env_plumbing(monkeypatch):
|
||||
monkeypatch.setenv("PINCH_EXT_RATIO", "1.5")
|
||||
monkeypatch.setenv("PINCH_EXT_MIN", "3")
|
||||
monkeypatch.setenv("PINCH_DEBOUNCE_FRAMES", "5")
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
assert pub._pinch_det.ext_ratio == 1.5
|
||||
assert pub._pinch_det.ext_min == 3
|
||||
assert pub._pinch_det.debounce_frames == 5
|
||||
|
||||
|
||||
def test_pinch_gate_env_defaults_are_strict(monkeypatch):
|
||||
# Live defaults: gate ON (2 extended fingers) + 3-frame debounce.
|
||||
for k in ("PINCH_EXT_RATIO", "PINCH_EXT_MIN", "PINCH_DEBOUNCE_FRAMES"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
assert pub._pinch_det.ext_ratio == 1.35
|
||||
assert pub._pinch_det.ext_min == 1 # 1 since 2026-07-02 (inner-finger fix)
|
||||
assert pub._pinch_det.debounce_frames == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item 1a: mirror_2d coherence — _emit_hands passes mirror to extractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_hand_row(cx: float, c: float = 1.0) -> list:
|
||||
"""21 _Kp landmarks with enough geometry for features."""
|
||||
lm = [_Kp(cx, 0.5, 0.0, c) for _ in range(21)]
|
||||
lm[0] = _Kp(cx, 0.6, 0.0, c) # wrist
|
||||
lm[9] = _Kp(cx, 0.45, 0.0, c) # middle-MCP -> hand_size 0.15 > near threshold
|
||||
return lm
|
||||
|
||||
|
||||
def test_emit_hands_mirror_2d_flips_cx_slot_assignment(monkeypatch):
|
||||
"""With state.mirror_2d=True, the cx fallback in _emit_hands assigns
|
||||
the RIGHT-screen hand to the L slot — coherent with the mirrored renderer."""
|
||||
from data_only_viz.hand_features import HandFeatureExtractor
|
||||
pub = _pub(monkeypatch, enabled=False)
|
||||
pub._hand_ext = HandFeatureExtractor() # not set by _pub/_init_finger_piano
|
||||
|
||||
h_low_cx = _make_hand_row(0.2) # screen-left
|
||||
h_high_cx = _make_hand_row(0.8) # screen-right
|
||||
pub.state.persons_hands = [h_low_cx, h_high_cx]
|
||||
# No chirality -> cx fallback path
|
||||
# (persons_hands_chirality absent -> getattr returns None -> [] -> None)
|
||||
|
||||
# Without mirror: screen-left (cx=0.2) -> slot L
|
||||
pub.state.mirror_2d = False
|
||||
pub._emit_hands(0.0)
|
||||
feats_plain = pub.state.hand_feats
|
||||
assert feats_plain["L"] is not None
|
||||
assert feats_plain["L"]["cx"] == pytest.approx(0.2, abs=0.05)
|
||||
|
||||
# With mirror: cx fallback flips -> screen-right (cx=0.8) -> slot L
|
||||
pub.state.mirror_2d = True
|
||||
pub._emit_hands(0.0)
|
||||
feats_mirror = pub.state.hand_feats
|
||||
assert feats_mirror["L"] is not None
|
||||
assert feats_mirror["L"]["cx"] == pytest.approx(0.8, abs=0.05)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gesture input validation tests (spec: a, b, c, e, f)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pub_gate(monkeypatch, persist_frames: int = 3, conf_min: str = "0.45"):
|
||||
"""Build a publisher with explicit gate params, finger + pinch enabled."""
|
||||
monkeypatch.setenv("FINGER_PIANO", "1")
|
||||
monkeypatch.setenv("PINCH_ENABLE", "1")
|
||||
monkeypatch.setenv("FINGER_STRIKE_VEL", "0.001") # very sensitive
|
||||
monkeypatch.setenv("FINGER_STRIKE_REFRACTORY_MS", "0")
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", str(persist_frames))
|
||||
monkeypatch.setenv("HAND_CONF_MIN", conf_min)
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
pub = ActionHeadPublisher.__new__(ActionHeadPublisher)
|
||||
pub.state = _FakeState()
|
||||
pub.bridge = MagicMock()
|
||||
pub._init_finger_piano()
|
||||
return pub
|
||||
|
||||
|
||||
def test_gesture_gate_blocks_low_confidence_hand(monkeypatch):
|
||||
"""(a) c=0.1 < HAND_CONF_MIN=0.45: plausibility gate blocks hand before detectors."""
|
||||
pub = _pub_gate(monkeypatch, persist_frames=1, conf_min="0.45")
|
||||
low_hand = _hand(0.40, c=0.1)
|
||||
for _ in range(10):
|
||||
pub.state.persons_hands = [low_hand]
|
||||
pub._emit_fingers(0.0)
|
||||
pub._emit_pinch(0.0)
|
||||
assert not pub.bridge.send_finger.called
|
||||
assert not pub.bridge.send_pinch.called
|
||||
|
||||
|
||||
def test_gesture_gate_plausible_hand_needs_min_frames(monkeypatch):
|
||||
"""(b) Plausible hand feeds gestures only after HAND_PERSIST_FRAMES ticks."""
|
||||
pub = _pub_gate(monkeypatch, persist_frames=3)
|
||||
good = _hand(0.40) # c=1.0, plausible
|
||||
|
||||
# Ticks 1 and 2: gate count < min_frames → hand not established → no slot
|
||||
for _ in range(2):
|
||||
pub.state.persons_hands = [good]
|
||||
pub._emit_fingers(0.0)
|
||||
assert not pub.bridge.send_finger.called
|
||||
|
||||
# Tick 3: established; move finger to trigger a strike
|
||||
pub.state.persons_hands = [good]
|
||||
pub._emit_fingers(0.0) # prime with hand in slot
|
||||
pub.state.persons_hands = [_hand(0.50)]
|
||||
pub._emit_fingers(0.1)
|
||||
assert pub.bridge.send_finger.called
|
||||
|
||||
|
||||
def test_gesture_gate_1_frame_ghost_never_fires(monkeypatch):
|
||||
"""(c) A confident ghost appearing for only 1-2 frames never triggers anything."""
|
||||
pub = _pub_gate(monkeypatch, persist_frames=3)
|
||||
ghost = _hand(0.40) # c=1.0 but short-lived
|
||||
|
||||
# One-frame flash then absence
|
||||
pub.state.persons_hands = [ghost]
|
||||
pub._emit_fingers(0.0)
|
||||
pub.state.persons_hands = []
|
||||
pub._emit_fingers(0.1)
|
||||
assert not pub.bridge.send_finger.called
|
||||
|
||||
# Two-frame flash then absence
|
||||
pub.state.persons_hands = [ghost]
|
||||
pub._emit_fingers(0.2)
|
||||
pub._emit_fingers(0.3) # count=2 still < 3
|
||||
pub.state.persons_hands = []
|
||||
pub._emit_fingers(0.4)
|
||||
assert not pub.bridge.send_finger.called
|
||||
|
||||
|
||||
def test_gesture_gate_mediapipe_hand_passes_through(monkeypatch):
|
||||
"""(e) MediaPipe c=1.0 hand passes plausibility gate and fires gestures normally."""
|
||||
pub = _pub_gate(monkeypatch, persist_frames=1, conf_min="0.45")
|
||||
mp_hand = _hand(0.40) # c=1.0, models MediaPipe c=1.0 landmark
|
||||
pub.state.persons_hands = [mp_hand]
|
||||
pub._emit_fingers(0.00) # prime (established on first tick: min_frames=1)
|
||||
pub.state.persons_hands = [_hand(0.50)]
|
||||
pub._emit_fingers(0.10) # strike
|
||||
assert pub.bridge.send_finger.called
|
||||
|
||||
|
||||
def test_gesture_gate_chirality_aligned_after_plausibility_mask(monkeypatch):
|
||||
"""(f) Invalid(idx=0) + valid(idx=1, chir=1) → only right slot filled."""
|
||||
import time as _time
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", "1")
|
||||
monkeypatch.setenv("HAND_CONF_MIN", "0.45")
|
||||
monkeypatch.setenv("FINGER_SOURCE", "iphone")
|
||||
monkeypatch.setenv("FINGER_PIANO", "0")
|
||||
monkeypatch.setenv("PINCH_ENABLE", "0")
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
|
||||
class _FakeStateIphone(_FakeState):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.persons_hands_iphone: list = []
|
||||
self.persons_hands_iphone_t: float = 0.0
|
||||
self.persons_hands_chirality: list = []
|
||||
|
||||
pub = ActionHeadPublisher.__new__(ActionHeadPublisher)
|
||||
pub.state = _FakeStateIphone()
|
||||
pub.bridge = MagicMock()
|
||||
pub._init_finger_piano()
|
||||
|
||||
invalid = _hand(0.40, c=0.1) # low-conf → filtered by plausibility gate
|
||||
valid = _hand(0.40, c=1.0) # passes gate
|
||||
|
||||
pub.state.persons_hands_iphone = [invalid, valid]
|
||||
pub.state.persons_hands_chirality = [0, 1] # invalid=left, valid=right
|
||||
pub.state.persons_hands_iphone_t = _time.perf_counter()
|
||||
|
||||
slotted = pub._step_stab()
|
||||
|
||||
# invalid (chir=0) was removed before route_hands; valid (chir=1) → slot R
|
||||
assert slotted[0] is None # left slot empty
|
||||
assert slotted[1] is not None # right slot has valid hand
|
||||
|
||||
@@ -28,10 +28,22 @@ class _FakeState:
|
||||
|
||||
|
||||
def _make_smplx_person(pid: int, seed: int = 0) -> dict:
|
||||
"""Build a legacy dict-format person (backward-compat test fixture)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
return {"pid": pid, "v3d": rng.normal(size=(10475, 3)).astype(np.float32)}
|
||||
|
||||
|
||||
def _make_real_smplx_person(pid: int, seed: int = 0):
|
||||
"""Build a real SMPLXPerson dataclass (as emitted by multi_hmr_worker)."""
|
||||
from data_only_viz.state import SMPLXPerson
|
||||
rng = np.random.default_rng(seed)
|
||||
return SMPLXPerson(
|
||||
pid=pid,
|
||||
vertices_3d=rng.normal(size=(10475, 3)).astype(np.float32),
|
||||
expression=np.zeros(10, dtype=np.float32),
|
||||
)
|
||||
|
||||
|
||||
def test_publisher_smplx_source_emits_osc() -> None:
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
@@ -158,18 +170,27 @@ def test_publisher_passes_hands_kp_to_step() -> None:
|
||||
# Task-3 tests: _emit_hands wired into _tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fake_hand_21(seed: int = 0):
|
||||
"""Return a list of 21 objects with .x and .y float attributes."""
|
||||
def _fake_hand_21(seed: int = 0, c: float = 1.0):
|
||||
"""Return a list of 21 landmark-like objects with .x .y .c attrs.
|
||||
|
||||
Wrist (slot 0) at (0.3, 0.6) and middle-MCP (slot 9) at (0.3, 0.45)
|
||||
so hand_plausible passes: size=0.15 > 0.02, c=1.0 >= 0.45, in-frame.
|
||||
"""
|
||||
import types
|
||||
rng = __import__("random").Random(seed)
|
||||
return [
|
||||
types.SimpleNamespace(x=rng.random(), y=rng.random())
|
||||
lm = [
|
||||
types.SimpleNamespace(x=rng.random() * 0.4 + 0.2,
|
||||
y=rng.random() * 0.4 + 0.2, c=c)
|
||||
for _ in range(21)
|
||||
]
|
||||
lm[0] = types.SimpleNamespace(x=0.3, y=0.6, c=c) # wrist in frame
|
||||
lm[9] = types.SimpleNamespace(x=0.3, y=0.45, c=c) # middle-MCP, size=0.15
|
||||
return lm
|
||||
|
||||
|
||||
def test_emit_hands_called_once_per_tick_with_hands() -> None:
|
||||
def test_emit_hands_called_once_per_tick_with_hands(monkeypatch) -> None:
|
||||
"""`bridge.send_hands` must be called once per tick with L/R/dist keys."""
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", "1") # instant establishment
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
bridge = MagicMock()
|
||||
@@ -240,3 +261,127 @@ def test_emit_hands_once_for_two_pids() -> None:
|
||||
pub._tick(t_now=0.0)
|
||||
|
||||
assert bridge.send_hands.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B3 contract: real SMPLXPerson dataclass (multi_hmr_worker output)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_publisher_smplx_real_dataclass_emits_osc() -> None:
|
||||
"""SMPLXPerson dataclass (vertices_3d, not v3d) must not raise AttributeError."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
state.persons_smplx = [_make_real_smplx_person(7)]
|
||||
state.smplx_last_t = 1.0
|
||||
pub._tick(t_now=0.0)
|
||||
actions = bridge.send_action.call_args_list
|
||||
assert len(actions) == 1
|
||||
assert actions[0].kwargs.get("pid", actions[0].args[0]) == 7
|
||||
bridge.send_enter.assert_called_with(pid=7)
|
||||
|
||||
|
||||
def test_publisher_smplx_real_dataclass_expression_used() -> None:
|
||||
"""Expression from SMPLXPerson.expression (ndarray) must not crash."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
p = _make_real_smplx_person(0)
|
||||
p.expression = np.ones(10, dtype=np.float32) * 0.5
|
||||
state.persons_smplx = [p]
|
||||
state.smplx_last_t = 1.0
|
||||
pub._tick(t_now=0.0)
|
||||
bridge.send_action.assert_called_once()
|
||||
|
||||
|
||||
def test_publisher_dict_format_still_works() -> None:
|
||||
"""Legacy dict format {"pid": ..., "v3d": ...} must remain supported."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
state.persons_smplx = [_make_smplx_person(99)]
|
||||
state.smplx_last_t = 1.0
|
||||
pub._tick(t_now=0.0)
|
||||
actions = bridge.send_action.call_args_list
|
||||
assert len(actions) == 1
|
||||
assert actions[0].kwargs.get("pid", actions[0].args[0]) == 99
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gesture validation: feature path requires established hands (spec test d)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_emit_hands_features_require_establishment(monkeypatch) -> None:
|
||||
"""(d) _emit_hands yields None for hands not yet established, non-None after.
|
||||
|
||||
Ghost = plausible hand present for 1-2 ticks only (never established).
|
||||
Real = same plausible hand held for HAND_PERSIST_FRAMES ticks.
|
||||
"""
|
||||
monkeypatch.setenv("HAND_PERSIST_FRAMES", "3")
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
|
||||
good_hand = _fake_hand_21(0)
|
||||
state.persons_hands = [good_hand]
|
||||
state.persons_smplx = [_make_smplx_person(0)]
|
||||
|
||||
# Ticks 1-2: hand count < 3 → not yet established → features are None
|
||||
for t in range(1, 3):
|
||||
state.smplx_last_t = float(t)
|
||||
pub._tick(t_now=float(t))
|
||||
feats_early, _ = bridge.send_hands.call_args_list[-1][0]
|
||||
assert feats_early["L"] is None # not established yet
|
||||
|
||||
# Tick 3: count reaches min_frames → established → features populated
|
||||
state.smplx_last_t = 3.0
|
||||
pub._tick(t_now=3.0)
|
||||
feats_est, _ = bridge.send_hands.call_args_list[-1][0]
|
||||
assert feats_est["L"] is not None # now established
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gesture slot status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gesture_status_defaults_to_zero_with_no_hands() -> None:
|
||||
"""With no hands, _update_gesture_slot_status() writes [0, 0]."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
state.gesture_slot_status = [99, 99] # sentinel: should be overwritten
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
pub._update_gesture_slot_status()
|
||||
assert state.gesture_slot_status == [0, 0]
|
||||
|
||||
|
||||
def test_gesture_status_armed_when_stab_active() -> None:
|
||||
"""gesture_slot_status[slot]=2 when stabilizer output is non-None (arm state)."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
state.gesture_slot_status = [0, 0]
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
# Directly mark slot 0 as active in the stabilizer's output cache
|
||||
pub._stab._out[0] = object()
|
||||
pub._update_gesture_slot_status()
|
||||
assert state.gesture_slot_status[0] == 2, "armed slot must report status 2"
|
||||
assert state.gesture_slot_status[1] == 0, "idle slot must report status 0"
|
||||
|
||||
|
||||
def test_gesture_status_engaged_when_pinch_active() -> None:
|
||||
"""gesture_slot_status[slot]=3 when a pinch is engaged in that slot."""
|
||||
from data_only_viz.action_head_pub import ActionHeadPublisher
|
||||
state = _FakeState()
|
||||
state.gesture_slot_status = [0, 0]
|
||||
bridge = MagicMock()
|
||||
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
|
||||
pub._stab._out[1] = object() # slot 1 armed
|
||||
pub._pinch_det._state[1][0].engaged = True # slot 1 finger engaged
|
||||
pub._update_gesture_slot_status()
|
||||
assert state.gesture_slot_status[0] == 0, "slot 0: idle"
|
||||
assert state.gesture_slot_status[1] == 3, "slot 1: engaged pinch = status 3"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Task 3: decode_skeleton2D round-trip tests.
|
||||
|
||||
Wire layout (TAG_SKELETON2D = 6):
|
||||
91 joints * (x: f32 BE, y: f32 BE) = 728 bytes of floats
|
||||
+ 91 validity bytes
|
||||
= 819 bytes total
|
||||
"""
|
||||
import struct
|
||||
|
||||
from data_only_viz.scripts.iphone_usb_bridge import decode_skeleton2D, JOINT_COUNT
|
||||
|
||||
|
||||
def test_decode_skeleton2d_roundtrip():
|
||||
floats = []
|
||||
for i in range(JOINT_COUNT):
|
||||
floats += [i * 0.001, i * 0.002]
|
||||
payload = struct.pack(">" + "f" * (JOINT_COUNT * 2), *floats)
|
||||
payload += bytes(1 if i % 2 == 0 else 0 for i in range(JOINT_COUNT))
|
||||
out = decode_skeleton2D(payload)
|
||||
assert out is not None and len(out) == JOINT_COUNT
|
||||
assert abs(out[10][0] - 0.010) < 1e-5 and out[10][2] is True
|
||||
assert out[11][2] is False
|
||||
|
||||
|
||||
def test_decode_skeleton2d_wrong_length_returns_none():
|
||||
assert decode_skeleton2D(b"\x00" * 10) is None
|
||||
assert decode_skeleton2D(b"\x00" * 818) is None
|
||||
assert decode_skeleton2D(b"\x00" * 820) is None
|
||||
assert decode_skeleton2D(b"") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 4: ARKit → MediaPipe-33 body builders
|
||||
# ---------------------------------------------------------------------------
|
||||
import numpy as np
|
||||
from data_only_viz.arkit_joint_map import arkit_body_2d, ARKIT91_TO_MP33
|
||||
|
||||
|
||||
def test_arkit_body_2d_maps_slots():
|
||||
arr = np.zeros((91, 2), dtype=np.float32)
|
||||
for ai, _mp in ARKIT91_TO_MP33:
|
||||
arr[ai] = [0.3 + ai * 0.001, 0.4]
|
||||
body = arkit_body_2d(arr)
|
||||
assert len(body) == 33
|
||||
# a mapped slot is confident; an unmapped face slot is not
|
||||
mapped_mp = ARKIT91_TO_MP33[0][1]
|
||||
assert body[mapped_mp].c > 0.5
|
||||
assert body[1].c < 0.5 # MP slot 1 (eye) has no ARKit source
|
||||
|
||||
|
||||
def test_arkit_body_3d_maps_slots():
|
||||
from data_only_viz.arkit_joint_map import arkit_body_3d
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
for ai, _mp in ARKIT91_TO_MP33:
|
||||
arr[ai] = [float(ai) * 0.01, float(ai) * 0.02, float(ai) * 0.03]
|
||||
body = arkit_body_3d(arr)
|
||||
assert len(body) == 33
|
||||
mapped_mp = ARKIT91_TO_MP33[0][1]
|
||||
assert body[mapped_mp].c > 0.5
|
||||
assert body[1].c < 0.5
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Pure ARKit skeleton -> line segment builder."""
|
||||
from data_only_viz.arkit_skeleton import arkit_segments, bones_from_parents
|
||||
|
||||
|
||||
def test_bones_skip_root():
|
||||
parents = [-1, 0, 1]
|
||||
assert bones_from_parents(parents) == [(1, 0), (2, 1)]
|
||||
|
||||
|
||||
def test_bones_skip_out_of_range():
|
||||
parents = [-1, 0, 99] # 99 has no joint
|
||||
assert bones_from_parents(parents) == [(1, 0)]
|
||||
|
||||
|
||||
def test_segments_basic():
|
||||
# Realistic bone lengths < 0.5 (horizontal spine-like chain)
|
||||
arr2d = [(0.0, 0.0), (0.2, 0.0), (0.4, 0.0)]
|
||||
valid = [True, True, True]
|
||||
parents = [-1, 0, 1]
|
||||
segs = arkit_segments(arr2d, valid, parents)
|
||||
assert segs == [(0.2, 0.0, 0.0, 0.0), (0.4, 0.0, 0.2, 0.0)]
|
||||
|
||||
|
||||
def test_segments_drop_invalid_endpoint():
|
||||
arr2d = [(0.0, 0.0), (0.2, 0.0), (0.4, 0.0)]
|
||||
valid = [True, False, True] # joint 1 invalid
|
||||
parents = [-1, 0, 1]
|
||||
# bone (1,0) drops (1 invalid); bone (2,1) drops (1 invalid)
|
||||
assert arkit_segments(arr2d, valid, parents) == []
|
||||
|
||||
|
||||
def test_segments_valid_none_keeps_all():
|
||||
arr2d = [(0.0, 0.0), (0.3, 0.0)] # bone length 0.3 < max_len=0.5
|
||||
parents = [-1, 0]
|
||||
assert arkit_segments(arr2d, None, parents) == [(0.3, 0.0, 0.0, 0.0)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New filter tests (bounds + max_len)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_segments_out_of_bounds_endpoint_skipped():
|
||||
"""Endpoint outside [-bounds, 1+bounds] → bone skipped."""
|
||||
# Joint 1 at x=-0.2 is outside [-0.1, 1.1]
|
||||
arr2d = [(0.5, 0.5), (-0.2, 0.5), (0.7, 0.5)]
|
||||
valid = [True, True, True]
|
||||
parents = [-1, 0, 1]
|
||||
# bone (1,0): joint 1 out of bounds → skip
|
||||
# bone (2,1): joint 1 out of bounds → skip
|
||||
segs = arkit_segments(arr2d, valid, parents, bounds=0.1)
|
||||
assert segs == []
|
||||
|
||||
|
||||
def test_segments_overlong_bone_skipped():
|
||||
"""Bone length > max_len → bone skipped."""
|
||||
# Bone from (0.0, 0.5) to (1.0, 0.5): length=1.0 > max_len=0.5
|
||||
arr2d = [(0.0, 0.5), (1.0, 0.5)]
|
||||
valid = [True, True]
|
||||
parents = [-1, 0]
|
||||
segs = arkit_segments(arr2d, valid, parents, max_len=0.5)
|
||||
assert segs == []
|
||||
|
||||
|
||||
def test_segments_normal_skeleton_passes_with_defaults():
|
||||
"""Realistic bones (length < 0.5, coords in [0,1]) pass all filters."""
|
||||
arr2d = [(0.5, 0.3), (0.5, 0.6), (0.5, 0.8)] # vertical spine-like
|
||||
valid = [True, True, True]
|
||||
parents = [-1, 0, 1]
|
||||
segs = arkit_segments(arr2d, valid, parents)
|
||||
# Both bones length = 0.3 < max_len=0.5; all coords in [0,1] ⊂ [-0.1,1.1]
|
||||
assert len(segs) == 2
|
||||
assert segs[0] == (0.5, 0.6, 0.5, 0.3)
|
||||
assert segs[1] == (0.5, 0.8, 0.5, 0.6)
|
||||
|
||||
|
||||
def test_segments_in_bounds_endpoint_passes():
|
||||
"""Endpoint exactly at boundary (0.0, 1.0) is kept."""
|
||||
arr2d = [(0.5, 0.5), (0.5, 0.2)] # bone length 0.3 < 0.5
|
||||
valid = [True, True]
|
||||
parents = [-1, 0]
|
||||
segs = arkit_segments(arr2d, valid, parents, bounds=0.1)
|
||||
assert len(segs) == 1
|
||||
|
||||
|
||||
def test_segments_legacy_diagonal_bone_now_filtered():
|
||||
"""Regression pin: the pre-filter test fixtures used a full-diagonal
|
||||
bone (0,0)->(0.5,0.5), length ~0.707. The max_len=0.5 default now
|
||||
REJECTS it — this is the intended behavior change of the v2 filter,
|
||||
made explicit here instead of silently retuning old fixtures."""
|
||||
arr2d = [(0.0, 0.0), (0.5, 0.5)]
|
||||
valid = [True, True]
|
||||
parents = [-1, 0]
|
||||
assert arkit_segments(arr2d, valid, parents) == []
|
||||
# ...and the env-tunable knob restores it for close-up shots.
|
||||
assert len(arkit_segments(arr2d, valid, parents, max_len=0.8)) == 1
|
||||
|
||||
|
||||
def test_finger_joint_mask_hides_fingers_keeps_wrist():
|
||||
from data_only_viz.arkit_skeleton import finger_joint_mask
|
||||
names = ["root", "left_hand_joint", "left_handIndexStart_joint",
|
||||
"left_handThumb1_joint", "right_handMid2_joint",
|
||||
"right_handPinkyEnd_joint", "head_joint", "right_hand_joint",
|
||||
"left_handRing3_joint"]
|
||||
mask = finger_joint_mask(names)
|
||||
assert mask == [False, False, True, True, True, True, False, False, True]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""AVLiveWire topology payload round-trip (mirror of Swift TopologyPayload)."""
|
||||
from data_only_viz.arkit_topology import decode_topology, encode_topology
|
||||
|
||||
|
||||
def test_round_trip():
|
||||
names = ["root_joint", "hips_joint", "left_arm_joint"]
|
||||
parents = [-1, 0, 1]
|
||||
payload = encode_topology(names, parents)
|
||||
out = decode_topology(payload)
|
||||
assert out is not None
|
||||
got_names, got_parents = out
|
||||
assert got_names == names
|
||||
assert got_parents == parents
|
||||
|
||||
|
||||
def test_empty():
|
||||
payload = encode_topology([], [])
|
||||
out = decode_topology(payload)
|
||||
assert out == ([], [])
|
||||
|
||||
|
||||
def test_rejects_truncated():
|
||||
# Claims 3 joints, provides no body.
|
||||
assert decode_topology(b"\x00\x03") is None
|
||||
|
||||
|
||||
def test_rejects_too_short():
|
||||
assert decode_topology(b"\x00") is None
|
||||
|
||||
|
||||
def test_rejects_truncated_parents():
|
||||
# 1 joint "a", parents section missing.
|
||||
assert decode_topology(b"\x00\x01\x01a") is None
|
||||
@@ -0,0 +1,14 @@
|
||||
"""State carries ARKit topology + 2D validity for the renderer."""
|
||||
from data_only_viz.state import State
|
||||
|
||||
|
||||
def test_state_has_topology_fields():
|
||||
s = State()
|
||||
assert s.arkit_joint_names == []
|
||||
assert s.arkit_parents == []
|
||||
assert s.persons_arkit_2d_valid == {}
|
||||
|
||||
|
||||
def test_topology_tag_constant():
|
||||
from data_only_viz.iphone_usb_source import TAG_TOPOLOGY
|
||||
assert TAG_TOPOLOGY == 7
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Tests for data_only_viz.config — VizConfig defaults, bool conventions, round-trip."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from data_only_viz.config import VizConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_defaults_mediapipe():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.mediapipe_delegate == "gpu"
|
||||
assert cfg.video_rotate == "none"
|
||||
|
||||
|
||||
def test_defaults_pose_discrimination():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.pose_ghost_min_visible == 10
|
||||
assert cfg.pose_ghost_min_conf == 0.5
|
||||
assert cfg.pose_hand_min_visible == 15
|
||||
assert cfg.pose_face_min_visible == 50
|
||||
assert cfg.pose_nms_iou == 0.7
|
||||
|
||||
|
||||
def test_defaults_pose_filter():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.pose_filter is None
|
||||
assert cfg.pose_filter_face is None
|
||||
assert cfg.pose_filter_hand is None
|
||||
|
||||
|
||||
def test_defaults_finger_piano():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.finger_piano is False
|
||||
assert cfg.finger_strike_vel == pytest.approx(0.02)
|
||||
assert cfg.finger_strike_refractory_ms == pytest.approx(120.0)
|
||||
assert cfg.finger_source == "auto"
|
||||
assert cfg.finger_debug is False
|
||||
|
||||
|
||||
def test_defaults_pinch():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.pinch_enable is False
|
||||
assert cfg.pinch_ratio_on == pytest.approx(0.50)
|
||||
assert cfg.pinch_ratio_off == pytest.approx(0.65)
|
||||
assert cfg.pinch_refractory_ms == pytest.approx(250.0)
|
||||
assert cfg.pinch_margin == pytest.approx(0.05)
|
||||
assert cfg.pinch_ext_ratio == pytest.approx(1.35)
|
||||
assert cfg.pinch_ext_min == 1
|
||||
assert cfg.pinch_debounce_frames == 3
|
||||
|
||||
|
||||
def test_defaults_hand_display():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.hand_conf_min == pytest.approx(0.45)
|
||||
assert cfg.hand_persist_frames == 3
|
||||
# HAND_SWAP_LR validated correct 2026-07-02: default OFF
|
||||
assert cfg.hand_swap_lr is False
|
||||
assert cfg.hand_near_min == pytest.approx(0.10)
|
||||
assert cfg.arkit_bone_max == pytest.approx(0.5)
|
||||
assert cfg.arkit_full_skeleton is True
|
||||
# detection v3 stabilizer knobs
|
||||
assert cfg.hand_persist_grace == 2
|
||||
assert cfg.hand_near_off == pytest.approx(0.08)
|
||||
assert cfg.hand_hold_frames == 2
|
||||
|
||||
|
||||
def test_defaults_misc():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.viz_hud is False
|
||||
assert cfg.iphone_osc_port == 57128
|
||||
assert cfg.concert_mirror is True
|
||||
|
||||
|
||||
def test_defaults_icp():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.icp_fusion is False
|
||||
assert cfg.icp_lidar_host is None
|
||||
assert cfg.icp_lidar_port == 5500
|
||||
assert cfg.icp_lidar_extrinsic is None
|
||||
|
||||
|
||||
def test_defaults_multihmr():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.multihmr_backend == "pytorch"
|
||||
assert cfg.multihmr_loop_fps == pytest.approx(30.0)
|
||||
assert cfg.multihmr_autocast is False
|
||||
assert cfg.multihmr_remote_host == "192.168.0.175"
|
||||
assert cfg.multihmr_remote_port == 57140
|
||||
assert cfg.multihmr_remote_jpeg is True
|
||||
assert cfg.multihmr_remote_jpeg_quality == 80
|
||||
assert cfg.multihmr_remote_async is True
|
||||
assert cfg.multihmr_reid == "dino"
|
||||
assert cfg.multihmr_reid_alpha == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_defaults_osc():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.avbody_host == "127.0.0.1"
|
||||
assert cfg.vdmx_osc_host is None
|
||||
assert cfg.vdmx_osc_port == 1234
|
||||
|
||||
|
||||
def test_defaults_worker_selection():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.av_live_mediapipe is True
|
||||
assert cfg.av_live_apple_vision is True
|
||||
assert cfg.av_live_coreml is True
|
||||
assert cfg.av_live_detrpose is False
|
||||
assert cfg.av_live_parallel_pose == "both"
|
||||
assert cfg.mesh_rig is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bool convention: standard (not in ("0","","false","False") → True)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("", False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("1", True),
|
||||
("true", True),
|
||||
("True", True),
|
||||
("yes", True),
|
||||
])
|
||||
def test_bool_std_finger_piano(val, expected):
|
||||
cfg = VizConfig.from_env({"FINGER_PIANO": val})
|
||||
assert cfg.finger_piano is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("", False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("1", True),
|
||||
])
|
||||
def test_bool_std_pinch_enable(val, expected):
|
||||
cfg = VizConfig.from_env({"PINCH_ENABLE": val})
|
||||
assert cfg.pinch_enable is expected
|
||||
|
||||
|
||||
# HAND_SWAP_LR: standard bool, default False
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("", False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
("1", True),
|
||||
])
|
||||
def test_bool_std_hand_swap_lr(val, expected):
|
||||
cfg = VizConfig.from_env({"HAND_SWAP_LR": val})
|
||||
assert cfg.hand_swap_lr is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bool convention: ne0 — != "0" (empty string is True)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("1", True),
|
||||
("", True), # ne0: "" != "0" = True
|
||||
("false", True), # ne0: "false" != "0" = True
|
||||
])
|
||||
def test_bool_ne0_concert_mirror(val, expected):
|
||||
cfg = VizConfig.from_env({"CONCERT_MIRROR": val})
|
||||
assert cfg.concert_mirror is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("1", True),
|
||||
("", True), # ne0: enabled by default when not "0"
|
||||
])
|
||||
def test_bool_ne0_av_live_mediapipe(val, expected):
|
||||
cfg = VizConfig.from_env({"AV_LIVE_MEDIAPIPE": val})
|
||||
assert cfg.av_live_mediapipe is expected
|
||||
|
||||
|
||||
# When AV_LIVE_MEDIAPIPE is not set, default = True
|
||||
def test_bool_ne0_av_live_unset():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.av_live_mediapipe is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bool convention: eq1 — only "1" is True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("1", True),
|
||||
("0", False),
|
||||
("true", False), # eq1: only exact "1"
|
||||
("", False),
|
||||
])
|
||||
def test_bool_eq1_icp_fusion(val, expected):
|
||||
cfg = VizConfig.from_env({"ICP_FUSION": val})
|
||||
assert cfg.icp_fusion is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("1", True),
|
||||
("0", False),
|
||||
("true", False),
|
||||
])
|
||||
def test_bool_eq1_multihmr_autocast(val, expected):
|
||||
cfg = VizConfig.from_env({"MULTIHMR_AUTOCAST": val})
|
||||
assert cfg.multihmr_autocast is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bool convention: flag — in ("1","true","yes","on") → True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("1", True),
|
||||
("true", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("no", False),
|
||||
("off", False),
|
||||
("", False),
|
||||
])
|
||||
def test_bool_flag_multihmr_remote_async(val, expected):
|
||||
cfg = VizConfig.from_env({"MULTIHMR_REMOTE_ASYNC": val})
|
||||
assert cfg.multihmr_remote_async is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numeric casts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_int_cast():
|
||||
cfg = VizConfig.from_env({"POSE_GHOST_MIN_VISIBLE": "7"})
|
||||
assert cfg.pose_ghost_min_visible == 7
|
||||
|
||||
|
||||
def test_float_cast():
|
||||
cfg = VizConfig.from_env({"POSE_GHOST_MIN_CONF": "0.8"})
|
||||
assert cfg.pose_ghost_min_conf == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_int_cast_fallback_on_invalid():
|
||||
cfg = VizConfig.from_env({"POSE_GHOST_MIN_VISIBLE": "notanumber"})
|
||||
assert cfg.pose_ghost_min_visible == 10 # falls back to default
|
||||
|
||||
|
||||
def test_float_cast_fallback_on_invalid():
|
||||
cfg = VizConfig.from_env({"POSE_GHOST_MIN_CONF": "bad"})
|
||||
assert cfg.pose_ghost_min_conf == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monkeypatched environ round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_roundtrip_via_monkeypatch(monkeypatch):
|
||||
monkeypatch.setenv("FINGER_PIANO", "1")
|
||||
monkeypatch.setenv("FINGER_STRIKE_VEL", "0.05")
|
||||
monkeypatch.setenv("PINCH_EXT_RATIO", "1.5")
|
||||
monkeypatch.setenv("PINCH_EXT_MIN", "4")
|
||||
monkeypatch.setenv("HAND_SWAP_LR", "1")
|
||||
cfg = VizConfig.from_env()
|
||||
assert cfg.finger_piano is True
|
||||
assert cfg.finger_strike_vel == pytest.approx(0.05)
|
||||
assert cfg.pinch_ext_ratio == pytest.approx(1.5)
|
||||
assert cfg.pinch_ext_min == 4
|
||||
assert cfg.hand_swap_lr is True
|
||||
|
||||
|
||||
def test_roundtrip_uses_live_environ(monkeypatch):
|
||||
"""from_env() without args reads os.environ — picks up monkeypatches."""
|
||||
monkeypatch.setenv("POSE_NMS_IOU", "0.55")
|
||||
cfg = VizConfig.from_env()
|
||||
assert cfg.pose_nms_iou == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_custom_mapping_does_not_pollute_os_environ(monkeypatch):
|
||||
"""Passing an explicit mapping leaves os.environ untouched."""
|
||||
monkeypatch.delenv("FINGER_PIANO", raising=False)
|
||||
cfg_custom = VizConfig.from_env({"FINGER_PIANO": "1"})
|
||||
cfg_env = VizConfig.from_env()
|
||||
assert cfg_custom.finger_piano is True
|
||||
assert cfg_env.finger_piano is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part B: MultiWorker with iphone_usb=True does not call landmarker builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_iphone_usb_skips_ensure_model():
|
||||
"""_ensure_model must never be called when iphone_usb=True.
|
||||
|
||||
When iphone_usb is set, the entire MediaPipe block (import + model
|
||||
download + landmarker construction) is gated out. We verify by
|
||||
patching _ensure_model to raise on call, then running _run with
|
||||
iphone_usb=True and a fake IphoneUSBSource that fails to start
|
||||
(so _run returns early without entering the capture loop).
|
||||
"""
|
||||
import data_only_viz.multi as multi_mod
|
||||
from data_only_viz.multi import MultiWorker
|
||||
from data_only_viz.state import State
|
||||
|
||||
ensure_calls: list[str] = []
|
||||
|
||||
def fail_ensure(name: str) -> Path:
|
||||
ensure_calls.append(name)
|
||||
raise AssertionError(
|
||||
f"_ensure_model({name!r}) called in iphone_usb mode — "
|
||||
"MediaPipe must not be loaded under --iphone-usb"
|
||||
)
|
||||
|
||||
# Minimal MultiWorker without calling __init__ (avoids ActionHead load)
|
||||
w = object.__new__(MultiWorker)
|
||||
w.iphone_usb = True
|
||||
w._stop = threading.Event()
|
||||
w.state = MagicMock(spec=State)
|
||||
w.state.lock.return_value = MagicMock(__enter__=MagicMock(return_value=None),
|
||||
__exit__=MagicMock(return_value=False))
|
||||
w.period = 0.033
|
||||
w.num_persons = 1
|
||||
w.min_conf = 0.4
|
||||
w.camera_index = 0
|
||||
|
||||
# fake_cap.start() returns False → _run returns after source setup
|
||||
fake_cap = MagicMock()
|
||||
fake_cap.start.return_value = False
|
||||
|
||||
with patch.object(multi_mod, "_ensure_model", fail_ensure):
|
||||
# Patch the inline `from .iphone_usb_source import IphoneUSBSource`
|
||||
# by pre-populating sys.modules so the local import resolves to our stub.
|
||||
import sys
|
||||
fake_src_mod = MagicMock()
|
||||
fake_src_mod.IphoneUSBSource.return_value = fake_cap
|
||||
with patch.dict(sys.modules,
|
||||
{"data_only_viz.iphone_usb_source": fake_src_mod}):
|
||||
multi_mod._run = None # guard against any indirect recursion
|
||||
try:
|
||||
w._run()
|
||||
except Exception:
|
||||
pass # early return from failed cap.start() is expected
|
||||
|
||||
assert ensure_calls == [], (
|
||||
f"_ensure_model was called under iphone_usb=True: {ensure_calls}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("0", False),
|
||||
("1", True),
|
||||
("", True), # ne0: "" != "0" = True (drift to _bool_std would break this)
|
||||
])
|
||||
def test_bool_ne0_mesh_rig(val, expected):
|
||||
cfg = VizConfig.from_env({"MESH_RIG": val})
|
||||
assert cfg.mesh_rig is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item 2: MultiWorker runs at 30 Hz under iphone-usb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_multiworker_period_formula_for_iphone_usb():
|
||||
"""MultiWorker stores period = 1 / max(1, target_fps).
|
||||
|
||||
Frame-based gesture calibrations (debounce/hold/grace/persist) assume
|
||||
30 Hz. When --iphone-usb is active (camera decode + ARKit conversion,
|
||||
no MediaPipe on the Mac), target_fps=30 is passed; webcam keeps 18.
|
||||
This test verifies the __init__ period arithmetic directly, sidestepping
|
||||
heavy ML/AppKit imports that are unavailable in CI.
|
||||
"""
|
||||
from data_only_viz.multi import MultiWorker
|
||||
import math
|
||||
# Verify the formula for both paths
|
||||
for target_fps, expected in [(30.0, 1.0 / 30.0), (18.0, 1.0 / 18.0)]:
|
||||
# Same expression as MultiWorker.__init__: self.period = 1.0 / max(1.0, target_fps)
|
||||
period = 1.0 / max(1.0, target_fps)
|
||||
assert math.isclose(period, expected, rel_tol=1e-6), (
|
||||
f"target_fps={target_fps}: expected period {expected:.6f}, got {period:.6f}"
|
||||
)
|
||||
# Verify the constructor signature accepts the kwarg (compilation check)
|
||||
import inspect
|
||||
sig = inspect.signature(MultiWorker.__init__)
|
||||
assert "target_fps" in sig.parameters, (
|
||||
"MultiWorker.__init__ must accept target_fps kwarg"
|
||||
)
|
||||
|
||||
|
||||
def test_hand_face_min_default():
|
||||
"""VizConfig.from_env({}) exposes hand_face_min with a default of 0.5."""
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.hand_face_min == 0.5
|
||||
|
||||
|
||||
def test_hand_face_min_env_override():
|
||||
"""HAND_FACE_MIN env var is parsed as float and stored in hand_face_min."""
|
||||
cfg = VizConfig.from_env({"HAND_FACE_MIN": "0.3"})
|
||||
assert cfg.hand_face_min == pytest.approx(0.3)
|
||||
|
||||
|
||||
def test_viz_source_keys_default_off():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.viz_source_keys is False
|
||||
assert VizConfig.from_env({"VIZ_SOURCE_KEYS": "1"}).viz_source_keys is True
|
||||
|
||||
|
||||
def test_viz_audio_keys_default_off():
|
||||
cfg = VizConfig.from_env({})
|
||||
assert cfg.viz_audio_keys is False
|
||||
assert VizConfig.from_env({"VIZ_AUDIO_KEYS": "1"}).viz_audio_keys is True
|
||||
|
||||
|
||||
def test_viz_default_mode():
|
||||
assert VizConfig.from_env({}).viz_default_mode == "auto"
|
||||
assert VizConfig.from_env(
|
||||
{"VIZ_DEFAULT_MODE": "voronoi"}).viz_default_mode == "voronoi"
|
||||
|
||||
|
||||
def test_skel_line_width_default():
|
||||
assert VizConfig.from_env({}).skel_line_width == 4
|
||||
assert VizConfig.from_env({"SKEL_LINE_WIDTH": "1"}).skel_line_width == 1
|
||||
@@ -27,11 +27,11 @@ def _hand(tip_y_by_finger: dict[int, float], base_y: float = 0.4,
|
||||
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)
|
||||
det.step([_hand({1: 0.40}), None], 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)
|
||||
e1 = det.step([_hand({1: 0.46}), None], 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)
|
||||
e2 = det.step([_hand({1: 0.46}), None], t_now=0.08)
|
||||
strikes = e1 + e2
|
||||
assert len(strikes) == 1
|
||||
assert strikes[0].finger == 1
|
||||
@@ -44,16 +44,16 @@ def test_whole_hand_translation_does_not_fire():
|
||||
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)
|
||||
out += det.step([_hand({1: by}, base_y=by), None], 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
|
||||
det.step([_hand({1: 0.40}), None], t_now=0.00) # prime
|
||||
a = det.step([_hand({1: 0.46}), None], t_now=0.02) # strike 1
|
||||
det.step([_hand({1: 0.40}), None], t_now=0.04) # lift -> rearm
|
||||
b = det.step([_hand({1: 0.46}), None], t_now=0.06) # within 120 ms -> blocked
|
||||
assert len(a) == 1
|
||||
assert b == []
|
||||
|
||||
@@ -61,10 +61,10 @@ def test_refractory_blocks_second_strike():
|
||||
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
|
||||
soft.step([_hand({1: 0.40}), None], t_now=0.0)
|
||||
hard.step([_hand({1: 0.40}), None], t_now=0.0)
|
||||
s = soft.step([_hand({1: 0.44}), None], t_now=0.04) # delta 0.04
|
||||
h = hard.step([_hand({1: 0.50}), None], t_now=0.04) # delta 0.10
|
||||
assert h[0].strike_speed > s[0].strike_speed
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ _PINCH = ((0.5, 0.5), (0.52, 0.5)) # dist 0.02 -> ratio 0.067
|
||||
|
||||
def test_pinch_fires_on_thumb_index_contact():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65)
|
||||
assert det.step([_pinch_hand(*_OPEN)], 0.0) == []
|
||||
ev = det.step([_pinch_hand(*_PINCH)], 0.1)
|
||||
assert det.step([_pinch_hand(*_OPEN), None], 0.0) == []
|
||||
ev = det.step([_pinch_hand(*_PINCH), None], 0.1)
|
||||
assert len(ev) == 1
|
||||
assert ev[0].finger == 1 # index = finger 1
|
||||
assert ev[0].hand == 0
|
||||
@@ -98,28 +98,268 @@ def test_pinch_fires_on_thumb_index_contact():
|
||||
|
||||
def test_pinch_does_not_refire_while_held():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH)], 0.1) # fire
|
||||
b = det.step([_pinch_hand(*_PINCH)], 0.2) # still held -> no refire
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH), None], 0.1) # fire
|
||||
b = det.step([_pinch_hand(*_PINCH), None], 0.2) # still held -> no refire
|
||||
assert len(a) == 1
|
||||
assert b == []
|
||||
|
||||
|
||||
def test_pinch_rearms_after_release():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH)], 0.1) # fire
|
||||
det.step([_pinch_hand(*_OPEN)], 0.2) # release (ratio > off)
|
||||
c = det.step([_pinch_hand(*_PINCH)], 0.3) # pinch again -> fire
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH), None], 0.1) # fire
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.2) # release (ratio > off)
|
||||
c = det.step([_pinch_hand(*_PINCH), None], 0.3) # pinch again -> fire
|
||||
assert len(a) == 1
|
||||
assert len(c) == 1
|
||||
|
||||
|
||||
def test_pinch_refractory_blocks():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=200)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH)], 0.05) # fire t=0.05
|
||||
det.step([_pinch_hand(*_OPEN)], 0.08) # release
|
||||
b = det.step([_pinch_hand(*_PINCH)], 0.10) # within 200 ms -> blocked
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
a = det.step([_pinch_hand(*_PINCH), None], 0.05) # fire t=0.05
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.08) # release
|
||||
b = det.step([_pinch_hand(*_PINCH), None], 0.10) # within 200 ms -> blocked
|
||||
assert len(a) == 1
|
||||
assert b == []
|
||||
|
||||
|
||||
def test_pinch_engage_release_emits_both_edges():
|
||||
"""Engage emits state=1; subsequent open emits state=0 (same finger)."""
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
engage = det.step([_pinch_hand(*_PINCH), None], 0.1) # engage edge
|
||||
release = det.step([_pinch_hand(*_OPEN), None], 0.2) # release edge
|
||||
assert len(engage) == 1 and engage[0].state == 1
|
||||
assert len(release) == 1 and release[0].state == 0
|
||||
assert release[0].hand == 0 and release[0].finger == 1
|
||||
|
||||
|
||||
def test_pinch_hand_disappear_emits_release():
|
||||
"""When an engaged hand disappears, a release edge is synthesised."""
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
engage = det.step([_pinch_hand(*_PINCH), None], 0.1) # engage
|
||||
release = det.step([None, None], 0.2) # hand gone
|
||||
assert len(engage) == 1 and engage[0].state == 1
|
||||
assert len(release) == 1 and release[0].state == 0
|
||||
|
||||
|
||||
def _pinch_hand_multi(thumb_xy, tips):
|
||||
"""21-kp hand (size 0.3); `tips` maps finger 0..3 (index/middle/ring/pinky)
|
||||
-> (x,y) for that fingertip. Unlisted fingertips are parked far from thumb."""
|
||||
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
|
||||
lm[0] = [0.5, 0.8, 0.0] # WRIST
|
||||
lm[9] = [0.5, 0.5, 0.0] # MIDDLE_MCP -> size 0.3
|
||||
lm[4] = [thumb_xy[0], thumb_xy[1], 0.0] # THUMB_TIP
|
||||
far = [(0.9, 0.5), (0.9, 0.6), (0.9, 0.7), (0.9, 0.8)]
|
||||
for i, idx in enumerate((8, 12, 16, 20)): # PINCH_TIPS
|
||||
x, y = tips.get(i, far[i])
|
||||
lm[idx] = [x, y, 0.0]
|
||||
return lm
|
||||
|
||||
|
||||
def test_pinch_closest_finger_wins():
|
||||
# index ratio ~0.067, middle ratio ~0.33 -> margin 0.27 >= 0.20 -> index only.
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand_multi((0.5, 0.5), {}), None], 0.0) # open
|
||||
ev = det.step([_pinch_hand_multi((0.5, 0.5),
|
||||
{0: (0.52, 0.5), 1: (0.60, 0.5)}), None], 0.1)
|
||||
assert len(ev) == 1
|
||||
assert ev[0].finger == 1 and ev[0].state == 1 # index only
|
||||
|
||||
|
||||
def test_pinch_ambiguous_adjacent_fires_nothing():
|
||||
# index ratio ~0.067, middle ratio ~0.10 -> margin 0.033 < 0.20 -> no winner.
|
||||
# (both are below ratio_on, so the OLD per-finger logic would fire both.)
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand_multi((0.5, 0.5), {}), None], 0.0) # open
|
||||
ev = det.step([_pinch_hand_multi((0.5, 0.5),
|
||||
{0: (0.52, 0.5), 1: (0.53, 0.5)}), None], 0.1)
|
||||
assert ev == []
|
||||
|
||||
|
||||
def _relaxed_hand():
|
||||
"""Relaxed/fist hand: thumb touches the index tip, but middle/ring/
|
||||
pinky are curled near the wrist (NOT extended). The runner-up tip is
|
||||
far from the thumb so the closest-wins margin passes -- only the
|
||||
extension gate can reject this hand."""
|
||||
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
|
||||
lm[0] = [0.5, 0.8, 0.0] # WRIST
|
||||
lm[9] = [0.5, 0.5, 0.0] # MIDDLE_MCP -> hand size 0.3
|
||||
lm[4] = [0.45, 0.55, 0.0] # THUMB_TIP
|
||||
lm[8] = [0.47, 0.55, 0.0] # INDEX_TIP: ratio 0.067 (pinch-close)
|
||||
lm[12] = [0.60, 0.62, 0.0] # MIDDLE_TIP curled (ext ~0.69)
|
||||
lm[16] = [0.62, 0.65, 0.0] # RING_TIP curled (ext ~0.64)
|
||||
lm[20] = [0.64, 0.68, 0.0] # LITTLE_TIP curled (ext ~0.62)
|
||||
return lm
|
||||
|
||||
|
||||
def test_relaxed_hand_rejected_by_extension_gate():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=2)
|
||||
out = []
|
||||
for i in range(10):
|
||||
out += det.step([_relaxed_hand(), None], i * 0.033)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_open_hand_pinch_passes_extension_gate():
|
||||
# _pinch_hand parks middle/ring/little far from the wrist
|
||||
# (ext 1.67 / 1.49 / 1.37, all >= 1.35) -> 3 extended >= ext_min 2.
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=2)
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.0)
|
||||
ev = det.step([_pinch_hand(*_PINCH), None], 0.1)
|
||||
assert len(ev) == 1 and ev[0].state == 1 and ev[0].finger == 1
|
||||
|
||||
|
||||
def test_extension_gate_defeat_reproduces_old_behavior():
|
||||
# ext_min=0 disables the gate: the relaxed hand fires like today.
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=0)
|
||||
ev = det.step([_relaxed_hand(), None], 0.1)
|
||||
assert len(ev) == 1 and ev[0].state == 1
|
||||
|
||||
|
||||
def test_debounce_delays_engage_to_nth_frame():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.00)
|
||||
a = det.step([_pinch_hand(*_PINCH), None], 0.10) # qualifying 1
|
||||
b = det.step([_pinch_hand(*_PINCH), None], 0.13) # qualifying 2
|
||||
c = det.step([_pinch_hand(*_PINCH), None], 0.16) # qualifying 3 -> engage
|
||||
assert a == [] and b == []
|
||||
assert len(c) == 1 and c[0].state == 1 and c[0].finger == 1
|
||||
|
||||
|
||||
def test_debounce_jitter_resets_counter():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_PINCH), None], 0.00) # qualifying 1
|
||||
det.step([_pinch_hand(*_PINCH), None], 0.03) # qualifying 2
|
||||
det.step([_pinch_hand(*_OPEN), None], 0.06) # jitter -> counter resets
|
||||
a = det.step([_pinch_hand(*_PINCH), None], 0.09) # qualifying 1 again
|
||||
b = det.step([_pinch_hand(*_PINCH), None], 0.12) # qualifying 2
|
||||
assert a == [] and b == []
|
||||
|
||||
|
||||
def test_release_immediate_after_debounced_engage():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_PINCH), None], 0.00)
|
||||
det.step([_pinch_hand(*_PINCH), None], 0.03)
|
||||
eng = det.step([_pinch_hand(*_PINCH), None], 0.06) # 3rd frame -> engage
|
||||
rel = det.step([_pinch_hand(*_OPEN), None], 0.09) # very next frame
|
||||
assert len(eng) == 1 and eng[0].state == 1
|
||||
assert len(rel) == 1 and rel[0].state == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FingerStrikeDetector.reset_slot — phantom-strike prevention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _strike_hand(tip_y: float, base_y: float = 0.4, cx: float = 0.3) -> list:
|
||||
"""Hand where index finger tip_y controls the relative position."""
|
||||
return _hand({1: tip_y}, base_y=base_y, cx=cx)
|
||||
|
||||
|
||||
def test_reset_slot_prevents_phantom_strike_on_reappear():
|
||||
"""After reset_slot, the first real-hand frame primes without firing.
|
||||
|
||||
Scenario: prime detector, hold the last hand (frozen coords),
|
||||
then reset_slot before the real hand returns with a large delta.
|
||||
Expected: no strike on reappear (first frame after reset is a prime).
|
||||
"""
|
||||
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
|
||||
|
||||
# Prime: index tip at 0.40
|
||||
det.step([_strike_hand(0.40), None], t_now=0.00)
|
||||
|
||||
# "Held" frames: stabilizer would replay the same hand — same coords,
|
||||
# vel=0, no strike, but prev_rel stays at 0.40.
|
||||
det.step([_strike_hand(0.40), None], t_now=0.03)
|
||||
det.step([_strike_hand(0.40), None], t_now=0.06)
|
||||
|
||||
# Simulate held -> real transition: reset before consuming new position.
|
||||
det.reset_slot(0)
|
||||
|
||||
# Real hand reappears with a large downward delta (0.40 -> 0.70 = 0.30 >> thresh).
|
||||
events = det.step([_strike_hand(0.70), None], t_now=0.09)
|
||||
assert events == [], (
|
||||
"after reset_slot, first frame should prime without firing "
|
||||
f"(got {events})"
|
||||
)
|
||||
|
||||
|
||||
def test_without_reset_big_delta_fires_strike():
|
||||
"""Control: WITHOUT reset_slot, the same scenario fires a phantom strike."""
|
||||
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
|
||||
|
||||
det.step([_strike_hand(0.40), None], t_now=0.00) # prime
|
||||
det.step([_strike_hand(0.40), None], t_now=0.03) # held
|
||||
det.step([_strike_hand(0.40), None], t_now=0.06) # held
|
||||
|
||||
# No reset — prev_rel is still 0.40 → delta 0.30 > thresh → fires
|
||||
events = det.step([_strike_hand(0.70), None], t_now=0.09)
|
||||
assert len(events) > 0, "without reset_slot, phantom strike must fire (control)"
|
||||
|
||||
|
||||
def test_reset_slot_clears_all_five_fingers():
|
||||
"""reset_slot clears every finger state for the specified slot."""
|
||||
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
|
||||
# Prime all 5 fingers by stepping a neutral hand
|
||||
neutral = _hand({f: 0.40 for f in range(5)}, base_y=0.40)
|
||||
det.step([neutral, None], t_now=0.00)
|
||||
# All prev_rel should be non-None now
|
||||
for f in range(5):
|
||||
assert det._state[0][f].prev_rel is not None
|
||||
|
||||
det.reset_slot(0)
|
||||
|
||||
for f in range(5):
|
||||
assert det._state[0][f].prev_rel is None, f"finger {f} prev_rel not cleared"
|
||||
assert det._state[0][f].armed is True, f"finger {f} not re-armed"
|
||||
|
||||
|
||||
def test_reset_slot_does_not_affect_other_slot():
|
||||
"""Resetting slot 0 leaves slot 1 state intact."""
|
||||
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
|
||||
h = _hand({1: 0.40})
|
||||
det.step([h, h], t_now=0.00) # prime both slots
|
||||
# slot 1 finger 1 should have a non-None prev_rel
|
||||
assert det._state[1][1].prev_rel is not None
|
||||
|
||||
det.reset_slot(0)
|
||||
|
||||
# slot 1 unchanged
|
||||
assert det._state[1][1].prev_rel is not None, "slot 1 must be unaffected"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PinchDetector.engaged_slots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_engaged_slots_both_false_initially():
|
||||
"""Fresh PinchDetector: no pinch engaged in either slot."""
|
||||
det = PinchDetector()
|
||||
assert det.engaged_slots() == (False, False)
|
||||
|
||||
|
||||
def test_engaged_slots_true_for_engaged_slot():
|
||||
"""engaged_slots() reflects per-slot engaged state correctly."""
|
||||
det = PinchDetector()
|
||||
det._state[0][0].engaged = True # slot 0, index finger
|
||||
s0, s1 = det.engaged_slots()
|
||||
assert s0 is True, "slot 0 has an engaged finger"
|
||||
assert s1 is False, "slot 1 has no engaged finger"
|
||||
|
||||
|
||||
def test_engaged_slots_false_after_release():
|
||||
"""Clearing engaged flag → engaged_slots returns False for that slot."""
|
||||
det = PinchDetector()
|
||||
det._state[1][2].engaged = True # slot 1, ring finger
|
||||
assert det.engaged_slots()[1] is True
|
||||
det._state[1][2].engaged = False
|
||||
assert det.engaged_slots() == (False, False)
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
"""Unit tests for hand_display geometry helpers.
|
||||
|
||||
All tests use tiny synthetic hands — no Metal, no pyobjc, no hardware required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from data_only_viz.hand_display import (
|
||||
HandPersistenceGate,
|
||||
arkit_2d_fresh,
|
||||
gauge_segments,
|
||||
gesture_quality,
|
||||
hand_facing,
|
||||
hand_plausible,
|
||||
hand_size,
|
||||
segment_ok,
|
||||
panel_rect,
|
||||
panel_segments,
|
||||
panel_frame,
|
||||
GAUGE_GAP,
|
||||
GAUGE_TICK_HALF,
|
||||
PANEL_SIDE,
|
||||
PANEL_MARGIN,
|
||||
PANEL_INNER,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal keypoint stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Kp:
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
z: float = 0.0
|
||||
c: float = 1.0
|
||||
|
||||
|
||||
def _make_hand(
|
||||
wrist_x: float = 0.5,
|
||||
wrist_y: float = 0.5,
|
||||
size: float = 0.1,
|
||||
c: float = 1.0,
|
||||
n: int = 21,
|
||||
) -> list[Kp]:
|
||||
"""Build a synthetic 21-landmark hand.
|
||||
|
||||
Landmark 0 = wrist, landmark 9 = middle-MCP at distance `size` along x.
|
||||
All other landmarks are scattered uniformly between wrist and MCP.
|
||||
"""
|
||||
kps: list[Kp] = []
|
||||
for i in range(n):
|
||||
# Spread landmarks roughly from wrist (0) to slightly past MCP (9)
|
||||
t = i / max(n - 1, 1)
|
||||
kps.append(Kp(x=wrist_x + t * size, y=wrist_y, z=0.0, c=c))
|
||||
# Ensure wrist and MCP are exactly placed so hand_size is deterministic
|
||||
kps[0] = Kp(x=wrist_x, y=wrist_y, z=0.0, c=c)
|
||||
kps[9] = Kp(x=wrist_x + size, y=wrist_y, z=0.0, c=c)
|
||||
return kps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hand_size
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_hand_size_simple():
|
||||
kp = _make_hand(wrist_x=0.3, wrist_y=0.4, size=0.15)
|
||||
assert math.isclose(hand_size(kp), 0.15, rel_tol=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hand_plausible — acceptance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normal_mediapipe_hand_passes():
|
||||
"""CRITICAL: c=1.0 MediaPipe hand at normal size must pass gate unchanged."""
|
||||
kp = _make_hand(size=0.12, c=1.0)
|
||||
assert hand_plausible(kp) is True
|
||||
|
||||
|
||||
def test_plausible_boundary_size_min_exact():
|
||||
"""Size exactly at size_min (0.02) should be accepted."""
|
||||
kp = _make_hand(size=0.02, c=1.0)
|
||||
assert hand_plausible(kp) is True
|
||||
|
||||
|
||||
def test_plausible_boundary_size_max_exact():
|
||||
"""Size exactly at size_max (0.5) should be accepted."""
|
||||
kp = _make_hand(size=0.5, c=1.0)
|
||||
assert hand_plausible(kp) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hand_plausible — rejections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fewer_than_21_landmarks_rejected():
|
||||
kp = _make_hand(size=0.1, c=1.0, n=20)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_tiny_hand_rejected():
|
||||
"""size=0.005 < size_min=0.02 → reject."""
|
||||
kp = _make_hand(size=0.005, c=1.0)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_huge_hand_rejected():
|
||||
"""size=0.8 > size_max=0.5 → reject."""
|
||||
kp = _make_hand(size=0.8, c=1.0)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_low_conf_hand_rejected():
|
||||
"""mean(c)=0.1 < conf_min=0.3 → reject."""
|
||||
kp = _make_hand(size=0.1, c=0.1)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_borderline_conf_just_below_rejected():
|
||||
"""mean(c) just under conf_min (0.29) → reject."""
|
||||
kp = _make_hand(size=0.1, c=0.29)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_borderline_conf_at_threshold_accepted():
|
||||
"""mean(c) = conf_min (0.3) → accept."""
|
||||
kp = _make_hand(size=0.1, c=0.3)
|
||||
assert hand_plausible(kp) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# segment_ok
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normal_bone_passes():
|
||||
"""A normal short bone at c=1.0 must pass."""
|
||||
size = 0.1
|
||||
A = Kp(x=0.5, y=0.5, c=1.0)
|
||||
B = Kp(x=0.55, y=0.5, c=1.0) # bone_len = 0.05 < 1.2 * 0.1 = 0.12
|
||||
assert segment_ok(A, B, size) is True
|
||||
|
||||
|
||||
def test_spike_segment_rejected():
|
||||
"""Bone length 2× hand size exceeds max_bone_ratio=1.2 → reject."""
|
||||
size = 0.1
|
||||
A = Kp(x=0.5, y=0.5, c=1.0)
|
||||
B = Kp(x=0.5 + 2 * size, y=0.5, c=1.0) # bone_len = 0.2 > 0.12
|
||||
assert segment_ok(A, B, size) is False
|
||||
|
||||
|
||||
def test_low_conf_endpoint_a_rejected():
|
||||
"""Endpoint A has c=0.1 < conf_min=0.3 → reject."""
|
||||
size = 0.1
|
||||
A = Kp(x=0.5, y=0.5, c=0.1)
|
||||
B = Kp(x=0.55, y=0.5, c=1.0)
|
||||
assert segment_ok(A, B, size) is False
|
||||
|
||||
|
||||
def test_low_conf_endpoint_b_rejected():
|
||||
"""Endpoint B has c=0.2 < conf_min=0.3 → reject."""
|
||||
size = 0.1
|
||||
A = Kp(x=0.5, y=0.5, c=1.0)
|
||||
B = Kp(x=0.55, y=0.5, c=0.2)
|
||||
assert segment_ok(A, B, size) is False
|
||||
|
||||
|
||||
def test_bone_exactly_at_ratio_limit_accepted():
|
||||
"""Bone length exactly max_bone_ratio * size should be accepted (<=)."""
|
||||
size = 0.1
|
||||
bone_len = 1.2 * size # exactly at limit
|
||||
A = Kp(x=0.5, y=0.5, c=1.0)
|
||||
B = Kp(x=0.5 + bone_len, y=0.5, c=1.0)
|
||||
# bone_len == 1.2 * size → not strictly greater, should pass
|
||||
assert segment_ok(A, B, size) is True
|
||||
|
||||
|
||||
def test_mediapipe_hand_all_bones_pass():
|
||||
"""CRITICAL invariant: for c=1.0 normal-size MediaPipe hand, ALL bones pass
|
||||
segment_ok (none treated as spikes given hand_size=0.12)."""
|
||||
kp = _make_hand(size=0.12, c=1.0)
|
||||
size = hand_size(kp)
|
||||
assert hand_plausible(kp) is True
|
||||
# All adjacent pairs in [0..20] should pass segment_ok
|
||||
# (spacing between consecutive kps = size/20 << 1.2 * size)
|
||||
failures = []
|
||||
for i in range(len(kp) - 1):
|
||||
if not segment_ok(kp[i], kp[i + 1], size):
|
||||
failures.append(i)
|
||||
assert failures == [], f"Segments failed segment_ok: {failures}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2D hand helper: spans a proper bbox with both x and y extent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_hand_2d(
|
||||
cx: float = 0.5,
|
||||
cy: float = 0.5,
|
||||
half_w: float = 0.05,
|
||||
half_h: float = 0.05,
|
||||
c: float = 1.0,
|
||||
) -> list[Kp]:
|
||||
"""21 landmarks filling bbox [cx±half_w, cy±half_h].
|
||||
|
||||
kp[0] = wrist at (cx-half_w, cy+half_h) — bottom-left of bbox
|
||||
kp[9] = MCP at (cx+half_w, cy-half_h) — top-right of bbox
|
||||
All other kp are spread uniformly across the bbox via a diagonal.
|
||||
hand_size = dist(kp[0], kp[9]) = 2 * sqrt(half_w² + half_h²).
|
||||
"""
|
||||
n = 21
|
||||
kps: list[Kp] = []
|
||||
for i in range(n):
|
||||
t = i / (n - 1)
|
||||
kps.append(Kp(
|
||||
x=cx - half_w + t * 2 * half_w,
|
||||
y=cy - half_h + t * 2 * half_h,
|
||||
z=0.0,
|
||||
c=c,
|
||||
))
|
||||
# Override wrist and MCP so bbox spans full [cx-half_w..cx+half_w, cy-half_h..cy+half_h]
|
||||
kps[0] = Kp(x=cx - half_w, y=cy + half_h, z=0.0, c=c) # bottom-left
|
||||
kps[9] = Kp(x=cx + half_w, y=cy - half_h, z=0.0, c=c) # top-right
|
||||
return kps
|
||||
|
||||
|
||||
# Minimal bone list for panel tests: wrist(0) → middle-MCP(9)
|
||||
_SIMPLE_BONES: list[tuple[int, int]] = [(0, 9)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# panel_rect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_panel_rect_left_x0_anchor():
|
||||
"""Left panel x0 == PANEL_MARGIN."""
|
||||
x0, _y0, _x1, _y1 = panel_rect("left", aspect=1.0)
|
||||
assert math.isclose(x0, PANEL_MARGIN, rel_tol=1e-6)
|
||||
|
||||
|
||||
def test_panel_rect_right_x1_anchor():
|
||||
"""Right panel x1 == 1 - PANEL_MARGIN."""
|
||||
_x0, _y0, x1, _y1 = panel_rect("right", aspect=1.0)
|
||||
assert math.isclose(x1, 1.0 - PANEL_MARGIN, rel_tol=1e-6)
|
||||
|
||||
|
||||
def test_panel_rect_vertically_centered():
|
||||
"""Panel vertical center is at 0.5."""
|
||||
_x0, y0, _x1, y1 = panel_rect("left", aspect=1.0)
|
||||
assert math.isclose((y0 + y1) / 2.0, 0.5, rel_tol=1e-6)
|
||||
|
||||
|
||||
def test_panel_rect_square_in_pixels_landscape():
|
||||
"""With 16:9 aspect ratio, normalized width × aspect == normalized height."""
|
||||
aspect = 16.0 / 9.0
|
||||
x0, y0, x1, y1 = panel_rect("left", aspect=aspect)
|
||||
norm_w = x1 - x0
|
||||
norm_h = y1 - y0
|
||||
# pixel_w = norm_w * view_w = norm_w * aspect * view_h == norm_h * view_h = pixel_h
|
||||
assert math.isclose(norm_w * aspect, norm_h, rel_tol=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# panel_segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_panel_segments_implausible_returns_empty():
|
||||
"""hand_plausible fails → panel_segments returns []."""
|
||||
kp = _make_hand(size=0.005, c=1.0) # size < size_min=0.02 → implausible
|
||||
result = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_panel_segments_inside_inner_rect():
|
||||
"""All output segment endpoints lie inside the inner panel rect."""
|
||||
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=0.04, half_h=0.08, c=1.0)
|
||||
x0, y0, x1, y1 = panel_rect("left", aspect=1.0)
|
||||
pw, ph = x1 - x0, y1 - y0
|
||||
ix0 = x0 + PANEL_INNER * pw
|
||||
ix1 = x1 - PANEL_INNER * pw
|
||||
iy0 = y0 + PANEL_INNER * ph
|
||||
iy1 = y1 - PANEL_INNER * ph
|
||||
segs = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
|
||||
assert segs, "expected at least one segment"
|
||||
eps = 1e-9
|
||||
for ax, ay, bx, by in segs:
|
||||
assert ix0 - eps <= ax <= ix1 + eps, f"ax={ax} outside [{ix0}, {ix1}]"
|
||||
assert ix0 - eps <= bx <= ix1 + eps, f"bx={bx} outside [{ix0}, {ix1}]"
|
||||
assert iy0 - eps <= ay <= iy1 + eps, f"ay={ay} outside [{iy0}, {iy1}]"
|
||||
assert iy0 - eps <= by <= iy1 + eps, f"by={by} outside [{iy0}, {iy1}]"
|
||||
|
||||
|
||||
def test_panel_segments_aspect_preserved():
|
||||
"""A hand 2× taller than wide in pixel space maps to 2× taller than wide in panel.
|
||||
|
||||
With aspect=1.0 (square screen), pixel ratio == normalized ratio.
|
||||
The hand has half_h = 2*half_w, so pixel bbox is 2× taller than wide.
|
||||
After uniform pixel-space scaling the output segment's |Δy|/|Δx| must also be 2.
|
||||
"""
|
||||
half_w, half_h = 0.04, 0.08 # kh=0.16, kw=0.08 → ratio 2
|
||||
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=half_w, half_h=half_h, c=1.0)
|
||||
segs = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
|
||||
assert len(segs) == 1, f"expected 1 segment, got {len(segs)}"
|
||||
ax, ay, bx, by = segs[0]
|
||||
dx = abs(bx - ax)
|
||||
dy = abs(by - ay)
|
||||
assert dx > 1e-9 and dy > 1e-9, "segment must have both x and y extent"
|
||||
ratio = dy / dx # pixel ratio (= normalized ratio at aspect=1)
|
||||
assert math.isclose(ratio, 2.0, rel_tol=1e-3), f"expected pixel ratio 2.0, got {ratio}"
|
||||
|
||||
|
||||
def test_panel_segments_mirror_flips_x():
|
||||
"""mirror=True flips X within the panel relative to mirror=False."""
|
||||
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=0.04, half_h=0.04, c=1.0)
|
||||
# kp[0] at bottom-left (kx_min), kp[9] at top-right (kx_max)
|
||||
segs_normal = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
|
||||
segs_mirror = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=True)
|
||||
assert len(segs_normal) == 1
|
||||
assert len(segs_mirror) == 1
|
||||
ax0, _ay0, bx0, _by0 = segs_normal[0]
|
||||
ax1, _ay1, bx1, _by1 = segs_mirror[0]
|
||||
# kp[0].x < kp[9].x → without mirror: ax < bx; with mirror: ax > bx
|
||||
assert ax0 < bx0, "without mirror: wrist (kx_min) should project left of MCP (kx_max)"
|
||||
assert ax1 > bx1, "with mirror: X is flipped so wrist projects right of MCP"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# panel_frame
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_panel_frame_returns_four_segments():
|
||||
"""panel_frame returns exactly 4 segments."""
|
||||
segs = panel_frame("left", aspect=1.0)
|
||||
assert len(segs) == 4
|
||||
|
||||
|
||||
def test_panel_frame_traces_rect():
|
||||
"""Every endpoint of each frame segment is a corner of the panel rect."""
|
||||
aspect = 1.0
|
||||
x0, y0, x1, y1 = panel_rect("left", aspect)
|
||||
corners = [(x0, y0), (x0, y1), (x1, y0), (x1, y1)]
|
||||
segs = panel_frame("left", aspect)
|
||||
for ax, ay, bx, by in segs:
|
||||
for ex, ey in [(ax, ay), (bx, by)]:
|
||||
on_corner = any(
|
||||
math.isclose(ex, cx, abs_tol=1e-9) and math.isclose(ey, cy, abs_tol=1e-9)
|
||||
for cx, cy in corners
|
||||
)
|
||||
assert on_corner, f"Endpoint ({ex:.4f}, {ey:.4f}) not a corner of panel rect"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arkit_2d_fresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_arkit_2d_fresh_empty_dict_is_false() -> None:
|
||||
"""No pids → never fresh."""
|
||||
assert arkit_2d_fresh({}, now=1000.0) is False
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_single_fresh_pid() -> None:
|
||||
"""A timestamp 0.5 s old with max_age=1.0 → fresh."""
|
||||
ts_by_pid = {0: 999.5}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is True
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_single_stale_pid() -> None:
|
||||
"""A timestamp 2 s old with max_age=1.0 → stale."""
|
||||
ts_by_pid = {0: 998.0}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is False
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_exactly_at_max_age_is_stale() -> None:
|
||||
"""age == max_age is NOT fresh (strict less-than)."""
|
||||
ts_by_pid = {0: 999.0}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=1.0) is False
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_mix_stale_and_fresh() -> None:
|
||||
"""At least one fresh pid makes the whole dict fresh."""
|
||||
ts_by_pid = {0: 990.0, 1: 999.8} # pid 0 stale, pid 1 fresh
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is True
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_all_pids_stale() -> None:
|
||||
"""All pids stale → False."""
|
||||
ts_by_pid = {0: 990.0, 1: 980.0}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is False
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_custom_max_age() -> None:
|
||||
"""max_age=0.5 makes a 0.4 s old timestamp fresh."""
|
||||
ts_by_pid = {5: 999.6}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=0.5) is True
|
||||
|
||||
|
||||
def test_arkit_2d_fresh_custom_max_age_stale() -> None:
|
||||
"""max_age=0.5 makes a 0.6 s old timestamp stale."""
|
||||
ts_by_pid = {5: 999.4}
|
||||
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=0.5) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hand_plausible — wrist out-of-frame rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_plausible_wrist_out_of_frame_x_neg_rejected():
|
||||
"""Wrist x < -0.1 → ghost anchor, reject."""
|
||||
kp = _make_hand(wrist_x=-0.2, wrist_y=0.5, size=0.12, c=1.0)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_plausible_wrist_out_of_frame_x_pos_rejected():
|
||||
"""Wrist x > 1.1 → ghost anchor, reject."""
|
||||
kp = _make_hand(wrist_x=1.2, wrist_y=0.5, size=0.12, c=1.0)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_plausible_wrist_out_of_frame_y_rejected():
|
||||
"""Wrist y < -0.1 → ghost anchor, reject."""
|
||||
kp = _make_hand(wrist_x=0.5, wrist_y=-0.15, size=0.12, c=1.0)
|
||||
assert hand_plausible(kp) is False
|
||||
|
||||
|
||||
def test_plausible_wrist_at_exact_boundary_accepted():
|
||||
"""Wrist at x=-0.1 is at the boundary — still accepted (inclusive)."""
|
||||
kp = _make_hand(wrist_x=-0.1, wrist_y=0.5, size=0.12, c=1.0)
|
||||
assert hand_plausible(kp) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HandPersistenceGate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gate_ghost_two_frames_never_draws():
|
||||
"""A hand appearing only 2 times is never drawn (min_frames=3)."""
|
||||
gate = HandPersistenceGate(min_frames=3)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
assert gate.step([hand]) == [False] # frame 1: count=1
|
||||
assert gate.step([hand]) == [False] # frame 2: count=2
|
||||
|
||||
|
||||
def test_gate_steady_hand_appears_at_third_frame():
|
||||
"""A steady hand becomes drawable from the 3rd consecutive frame."""
|
||||
gate = HandPersistenceGate(min_frames=3)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
assert gate.step([hand]) == [False] # frame 1
|
||||
assert gate.step([hand]) == [False] # frame 2
|
||||
assert gate.step([hand]) == [True] # frame 3: count=3 >= min_frames
|
||||
assert gate.step([hand]) == [True] # frame 4: count=4, stays drawable
|
||||
|
||||
|
||||
def test_gate_teleporting_hand_never_draws():
|
||||
"""A hand that jumps >radius every frame never accumulates a track."""
|
||||
gate = HandPersistenceGate(min_frames=3, radius=0.15)
|
||||
for px in (0.1, 0.5, 0.9): # each jump is 0.4 > radius=0.15
|
||||
hand = _make_hand(wrist_x=px, wrist_y=0.5)
|
||||
assert gate.step([hand]) == [False]
|
||||
|
||||
|
||||
def test_gate_two_steady_hands_both_drawable():
|
||||
"""Two hands that remain steady both become drawable at frame 3."""
|
||||
gate = HandPersistenceGate(min_frames=3)
|
||||
lh = _make_hand(wrist_x=0.3, wrist_y=0.5)
|
||||
rh = _make_hand(wrist_x=0.7, wrist_y=0.5)
|
||||
for _ in range(2):
|
||||
gate.step([lh, rh])
|
||||
result = gate.step([lh, rh])
|
||||
assert result == [True, True]
|
||||
|
||||
|
||||
def test_gate_min_frames_one_immediate():
|
||||
"""min_frames=1 makes any hand drawable on first appearance."""
|
||||
gate = HandPersistenceGate(min_frames=1)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
assert gate.step([hand]) == [True]
|
||||
|
||||
|
||||
def test_gate_track_resets_after_absence():
|
||||
"""A track that disappears for one frame must re-accumulate from scratch."""
|
||||
gate = HandPersistenceGate(min_frames=3)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
gate.step([hand])
|
||||
gate.step([hand]) # count=2, not yet drawable
|
||||
gate.step([]) # hand absent → track dropped
|
||||
# Reappears: back to count=1
|
||||
assert gate.step([hand]) == [False]
|
||||
|
||||
|
||||
def test_gate_empty_input_returns_empty():
|
||||
"""step([]) always returns []."""
|
||||
gate = HandPersistenceGate()
|
||||
assert gate.step([]) == []
|
||||
|
||||
|
||||
def test_gate_greedy_no_double_assign():
|
||||
"""Two hands close together: each matches its own track, not the same one."""
|
||||
gate = HandPersistenceGate(min_frames=2, radius=0.15)
|
||||
h1 = _make_hand(wrist_x=0.3, wrist_y=0.5)
|
||||
h2 = _make_hand(wrist_x=0.4, wrist_y=0.5) # 0.1 apart, both within radius
|
||||
gate.step([h1, h2])
|
||||
result = gate.step([h1, h2])
|
||||
# Both should have count=2 >= min_frames=2
|
||||
assert result == [True, True]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HandPersistenceGate — grace frames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gate_grace_zero_is_strict():
|
||||
"""grace=0 restores original strict behavior (1 miss drops the track)."""
|
||||
gate = HandPersistenceGate(min_frames=3, grace=0)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
for _ in range(3):
|
||||
gate.step([hand]) # establish count=3
|
||||
gate.step([]) # miss=1 > grace=0 → dropped
|
||||
assert gate.step([hand]) == [False] # fresh count=1
|
||||
|
||||
|
||||
def test_gate_grace_preserves_track_across_one_miss():
|
||||
"""With grace=2, a 1-frame hole preserves the established count."""
|
||||
gate = HandPersistenceGate(min_frames=3, grace=2)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
for _ in range(3):
|
||||
gate.step([hand]) # establish count=3
|
||||
gate.step([]) # miss=1 ≤ grace → track survives
|
||||
result = gate.step([hand]) # matches surviving track: count=4
|
||||
assert result == [True]
|
||||
|
||||
|
||||
def test_gate_grace_gap_frame_has_empty_result():
|
||||
"""During a grace frame (no hands input), result list is empty."""
|
||||
gate = HandPersistenceGate(min_frames=3, grace=2)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
for _ in range(3):
|
||||
gate.step([hand])
|
||||
assert gate.step([]) == [] # no hands → result is []
|
||||
|
||||
|
||||
def test_gate_grace_drops_after_grace_plus_one_misses():
|
||||
"""After grace+1 consecutive misses the track is dropped."""
|
||||
gate = HandPersistenceGate(min_frames=3, grace=2)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
for _ in range(3):
|
||||
gate.step([hand]) # establish
|
||||
for _ in range(3): # 3 misses: miss=1,2,3 → last one > grace=2 → dropped
|
||||
gate.step([])
|
||||
assert gate.step([hand]) == [False] # fresh count=1
|
||||
|
||||
|
||||
def test_gate_grace_ghost_stays_suppressed():
|
||||
"""A ghost appearing 1 frame then disappearing never becomes drawable
|
||||
(count=1 < min_frames=3 even after being carried by grace)."""
|
||||
gate = HandPersistenceGate(min_frames=3, grace=2)
|
||||
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
|
||||
gate.step([hand]) # count=1
|
||||
gate.step([]) # miss=1 ≤ grace → track [0.5, 0.5, 1, 1] survives
|
||||
# Reappears: count increments to 2 (still not established)
|
||||
assert gate.step([hand]) == [False]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hand_facing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_facing_kp(wrist_x=0.5, wrist_y=0.7, size=0.15, spread=0.14) -> list[Kp]:
|
||||
"""21-kp hand with index-MCP(5) and pinky-MCP(17) spread wide in x."""
|
||||
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
|
||||
kp[5] = Kp(x=wrist_x - spread / 2, y=wrist_y - size / 2)
|
||||
kp[17] = Kp(x=wrist_x + spread / 2, y=wrist_y - size / 2)
|
||||
return kp
|
||||
|
||||
|
||||
def _make_sideon_kp(wrist_x=0.5, wrist_y=0.7, size=0.15) -> list[Kp]:
|
||||
"""21-kp hand with index-MCP(5) and pinky-MCP(17) almost co-located."""
|
||||
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
|
||||
kp[5] = Kp(x=wrist_x, y=wrist_y - size / 2)
|
||||
kp[17] = Kp(x=wrist_x + 0.005, y=wrist_y - size / 2)
|
||||
return kp
|
||||
|
||||
|
||||
def test_hand_facing_camera_above_threshold():
|
||||
"""A palm facing the camera has a spread ratio >= 0.7."""
|
||||
kp = _make_facing_kp(size=0.15, spread=0.14)
|
||||
# facing = 0.14 / 0.15 ~ 0.93
|
||||
assert hand_facing(kp) >= 0.7
|
||||
|
||||
|
||||
def test_hand_facing_side_on_below_threshold():
|
||||
"""A side-on palm has a spread ratio < 0.4."""
|
||||
kp = _make_sideon_kp(size=0.15)
|
||||
# facing = 0.005 / 0.15 ~ 0.033
|
||||
assert hand_facing(kp) < 0.4
|
||||
|
||||
|
||||
def test_hand_facing_zero_size_returns_zero():
|
||||
"""Guard: when hand_size is ~0 (degenerate hand), returns 0.0."""
|
||||
kp = _make_hand(wrist_x=0.5, wrist_y=0.5, size=0.0)
|
||||
# Force wrist and MCP to the same position so hand_size = 0
|
||||
kp[0] = Kp(x=0.5, y=0.5)
|
||||
kp[9] = Kp(x=0.5, y=0.5)
|
||||
assert hand_facing(kp) == 0.0
|
||||
|
||||
|
||||
def test_hand_facing_list_format():
|
||||
"""hand_facing also accepts list-style kp ([x, y, z]) as used in hand_slots tests."""
|
||||
# Build a 21-element list-of-lists
|
||||
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
|
||||
lm[0] = [0.5, 0.7, 0.0] # wrist
|
||||
lm[9] = [0.5, 0.55, 0.0] # middle-MCP, size = 0.15
|
||||
# Wide spread: index-MCP and pinky-MCP 0.14 apart in x
|
||||
lm[5] = [0.43, 0.62, 0.0]
|
||||
lm[17] = [0.57, 0.62, 0.0]
|
||||
f = hand_facing(lm)
|
||||
# facing = 0.14 / 0.15 ~ 0.93
|
||||
assert f >= 0.7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gesture_quality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_quality_hand(size: float = 0.10, facing: float = 0.7) -> list[Kp]:
|
||||
"""21-kp hand with precise hand_size and hand_facing values.
|
||||
|
||||
hand_size = wrist(kp[0]) to middle-MCP(kp[9]) distance = size
|
||||
hand_facing = dist(kp[5], kp[17]) / hand_size = facing
|
||||
"""
|
||||
kp = _make_hand(wrist_x=0.5, wrist_y=0.5, size=size, c=1.0)
|
||||
spread = facing * size
|
||||
mid_y = 0.5 - size / 2
|
||||
kp[5] = Kp(x=0.5 - spread / 2, y=mid_y)
|
||||
kp[17] = Kp(x=0.5 + spread / 2, y=mid_y)
|
||||
return kp
|
||||
|
||||
|
||||
def test_quality_absent_hand_returns_zero():
|
||||
"""hand=None → quality 0.0 regardless of other params."""
|
||||
assert gesture_quality(None, face_min=0.5, near_on=0.10) == 0.0
|
||||
|
||||
|
||||
def test_quality_engaged_forces_one():
|
||||
"""engaged=True → quality 1.0 regardless of hand geometry."""
|
||||
hand = _make_quality_hand(size=0.05, facing=0.1) # far and side-on
|
||||
assert gesture_quality(hand, face_min=0.5, near_on=0.10, engaged=True) == 1.0
|
||||
|
||||
|
||||
def test_quality_far_but_fully_facing():
|
||||
"""Hand at near-threshold (size=0.5*near_on) but fully facing → 0.65.
|
||||
|
||||
near_norm = 0.0 (at lower bound), facing_norm = 1.0 → 0.30 + 0.35 = 0.65.
|
||||
"""
|
||||
face_min, near_on = 0.5, 0.10
|
||||
# size = 0.5 * near_on = 0.05 → near_norm = 0
|
||||
# facing = face_min = 0.5 → facing_norm = (0.5-0.25)/(0.5-0.25) = 1.0
|
||||
hand = _make_quality_hand(size=0.05, facing=face_min)
|
||||
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
|
||||
assert math.isclose(q, 0.65, rel_tol=1e-6), f"expected 0.65, got {q}"
|
||||
|
||||
|
||||
def test_quality_near_but_side_on():
|
||||
"""Hand fully near (size=near_on) but side-on (facing=0.25) → 0.65.
|
||||
|
||||
facing_norm = 0.0 (at lower bound), near_norm = 1.0 → 0.30 + 0.35 = 0.65.
|
||||
"""
|
||||
face_min, near_on = 0.5, 0.10
|
||||
# size = near_on = 0.10 → near_norm = (0.10-0.05)/0.05 = 1.0
|
||||
# facing = 0.25 → facing_norm = (0.25-0.25)/0.25 = 0.0
|
||||
hand = _make_quality_hand(size=near_on, facing=0.25)
|
||||
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
|
||||
assert math.isclose(q, 0.65, rel_tol=1e-6), f"expected 0.65, got {q}"
|
||||
|
||||
|
||||
def test_quality_full_caps_at_one():
|
||||
"""Hand well above near_on and facing threshold → quality = 1.0."""
|
||||
face_min, near_on = 0.5, 0.10
|
||||
hand = _make_quality_hand(size=0.20, facing=0.9) # near_norm=1, facing_norm=1
|
||||
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
|
||||
assert math.isclose(q, 1.0, rel_tol=1e-6), f"expected 1.0, got {q}"
|
||||
|
||||
|
||||
def test_quality_monotonic_in_size():
|
||||
"""For fixed full-facing, quality increases strictly with hand size."""
|
||||
face_min, near_on = 0.5, 0.10
|
||||
facing = face_min # facing_norm = 1.0 at exactly face_min
|
||||
sizes = [0.5 * near_on, 0.75 * near_on, near_on]
|
||||
qs = [gesture_quality(_make_quality_hand(size=s, facing=facing),
|
||||
face_min=face_min, near_on=near_on)
|
||||
for s in sizes]
|
||||
assert qs[0] < qs[1] < qs[2], f"not monotone: {qs}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gauge_segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_gauge_segments_no_data_returns_two_dim_rails():
|
||||
"""cx/cy=None → 2 rail segments (dim, no markers)."""
|
||||
segs = gauge_segments(None, None, "left", aspect=1.0)
|
||||
# Exactly 2 rails: one horizontal (X gauge), one vertical (Y gauge)
|
||||
assert len(segs) == 2, f"expected 2 rail segs, got {len(segs)}"
|
||||
confs = [s[4] for s in segs]
|
||||
assert all(c == 0.25 for c in confs), f"dim conf expected 0.25, got {confs}"
|
||||
pids = [s[5] for s in segs]
|
||||
assert all(p == 7 for p in pids), f"rail pid should be 7, got {pids}"
|
||||
|
||||
|
||||
def test_gauge_segments_with_data_returns_rails_and_markers():
|
||||
"""cx/cy provided → 2 rails + 2 X-marker ticks + 2 Y-marker ticks = 6 total."""
|
||||
segs = gauge_segments(0.5, 0.5, "left", aspect=1.0)
|
||||
assert len(segs) == 6, f"expected 6 segments (2 rail + 4 marker), got {len(segs)}"
|
||||
rail_segs = [s for s in segs if s[5] == 7]
|
||||
marker_segs = [s for s in segs if s[5] != 7]
|
||||
assert len(rail_segs) == 2 and len(marker_segs) == 4
|
||||
|
||||
|
||||
def test_gauge_x_marker_centered_on_rail():
|
||||
"""cx=0.5 → primary X marker tick is at x = (x0+x1)/2 of the panel."""
|
||||
x0, _y0, x1, _y1 = panel_rect("left", aspect=1.0)
|
||||
center_x = (x0 + x1) / 2.0
|
||||
segs = gauge_segments(0.5, 0.5, "left", aspect=1.0, content_pid=5)
|
||||
# X marker ticks are vertical segments (same x on both endpoints), pid != 7
|
||||
x_marker_segs = [s for s in segs
|
||||
if s[5] != 7 and math.isclose(s[0], s[2], abs_tol=1e-9)]
|
||||
assert x_marker_segs, "no vertical X-marker ticks found"
|
||||
# The primary tick (minimum x) should land at center_x; the bold-offset
|
||||
# tick is at center_x + GAUGE_BOLD (a tiny offset for visual thickness).
|
||||
primary_x = min(s[0] for s in x_marker_segs)
|
||||
assert math.isclose(primary_x, center_x, rel_tol=1e-6), \
|
||||
f"primary X marker at {primary_x}, expected {center_x}"
|
||||
|
||||
|
||||
def test_gauge_mirror_flips_x_marker():
|
||||
"""mirror=True → X marker at 1-cx (mirrored position), cx unchanged for Y."""
|
||||
x0, _y0, x1, _y1 = panel_rect("left", aspect=1.0)
|
||||
cx = 0.3
|
||||
# Without mirror: marker at x0 + 0.3*(x1-x0)
|
||||
segs_norm = gauge_segments(cx, 0.5, "left", 1.0, mirror=False, content_pid=5)
|
||||
# With mirror: marker at x0 + 0.7*(x1-x0)
|
||||
segs_mirr = gauge_segments(cx, 0.5, "left", 1.0, mirror=True, content_pid=5)
|
||||
# Extract first X-marker tick (vertical segment, pid=5)
|
||||
def _x_tick_x(segs):
|
||||
for ax, ay, bx, by, c, p in segs:
|
||||
if p == 5 and math.isclose(ax, bx, rel_tol=1e-9):
|
||||
return ax
|
||||
return None
|
||||
xn = _x_tick_x(segs_norm)
|
||||
xm = _x_tick_x(segs_mirr)
|
||||
assert xn is not None and xm is not None
|
||||
expected_n = x0 + cx * (x1 - x0)
|
||||
expected_m = x0 + (1.0 - cx) * (x1 - x0)
|
||||
assert math.isclose(xn, expected_n, rel_tol=1e-6), f"normal: {xn} != {expected_n}"
|
||||
assert math.isclose(xm, expected_m, rel_tol=1e-6), f"mirror: {xm} != {expected_m}"
|
||||
|
||||
|
||||
def test_gauge_y_rail_below_panel_x_rail():
|
||||
"""X gauge rail y-coordinate is below the panel bottom edge."""
|
||||
_x0, _y0, _x1, y1 = panel_rect("left", aspect=1.0)
|
||||
segs = gauge_segments(0.5, 0.5, "left", 1.0)
|
||||
# Horizontal rail: ay == by (same y, spanning x)
|
||||
h_rail = [s for s in segs if math.isclose(s[1], s[3], rel_tol=1e-9)
|
||||
and s[5] == 7]
|
||||
assert h_rail, "no horizontal rail segment"
|
||||
rail_y = h_rail[0][1]
|
||||
assert rail_y > y1, f"X gauge rail y={rail_y} should be below panel y1={y1}"
|
||||
assert math.isclose(rail_y, y1 + GAUGE_GAP, rel_tol=1e-6)
|
||||
@@ -100,3 +100,144 @@ def test_numpy_row_landmarks_finite_features():
|
||||
assert math.isfinite(hand["cy"])
|
||||
assert math.isfinite(hand["openness"])
|
||||
assert hand["openness"] > 0.8 # span/size = 2.0, same as test_open_hand
|
||||
|
||||
|
||||
def _fist_hand(tip_ext):
|
||||
"""All 4 finger MCPs at center; each fingertip placed tip_ext*size away
|
||||
(so the per-finger tip->MCP ratio == tip_ext). size (wrist->mcp9) = 0.10."""
|
||||
cx, cy = 0.5, 0.5
|
||||
pts = [LM(cx, cy) for _ in range(21)]
|
||||
pts[0] = LM(cx, cy + 0.10) # wrist -> size 0.10
|
||||
for mcp in (5, 9, 13, 17):
|
||||
pts[mcp] = LM(cx, cy)
|
||||
for tip in (8, 12, 16, 20):
|
||||
pts[tip] = LM(cx + tip_ext * 0.10, cy)
|
||||
pts[4] = LM(cx - 0.05, cy) # thumb
|
||||
return pts
|
||||
|
||||
|
||||
def test_fist_all_fingers_curled():
|
||||
out = HandFeatureExtractor(fist_enable=True).step([_fist_hand(0.20)]) # tips at knuckles
|
||||
assert (out["L"] or out["R"])["fist"] > 0.9
|
||||
|
||||
|
||||
def test_open_hand_is_not_a_fist():
|
||||
out = HandFeatureExtractor(fist_enable=True).step([_fist_hand(0.80)]) # fingers extended
|
||||
assert (out["L"] or out["R"])["fist"] < 0.1
|
||||
|
||||
|
||||
def test_one_extended_finger_breaks_the_fist():
|
||||
h = _fist_hand(0.20) # all curled...
|
||||
h[8] = LM(0.5 + 0.80 * 0.10, 0.5) # ...except the index
|
||||
out = HandFeatureExtractor(fist_enable=True).step([h])
|
||||
assert (out["L"] or out["R"])["fist"] < 0.1 # min over fingers
|
||||
|
||||
|
||||
def test_swap_routes_inverted_chirality_to_user_slots():
|
||||
"""C1 pin: with the inverted-source swap ON (HAND_SWAP_LR default),
|
||||
a physical LEFT hand arriving labeled chir=1 must land in feature
|
||||
slot L — consistent with the pinch/strike gesture path."""
|
||||
ext = HandFeatureExtractor()
|
||||
left_hand = _hand(0.30, 0.5, span=0.20) # user's left (source says 1)
|
||||
right_hand = _hand(0.70, 0.5, span=0.06) # user's right (source says 0)
|
||||
out = ext.step([left_hand, right_hand], [1, 0], swap=True)
|
||||
assert out["L"] is not None and out["R"] is not None
|
||||
assert out["L"]["cx"] == pytest.approx(0.30, abs=0.01) # left hand in L
|
||||
assert out["R"]["cx"] == pytest.approx(0.70, abs=0.01)
|
||||
|
||||
|
||||
def test_no_swap_keeps_source_chirality():
|
||||
ext = HandFeatureExtractor()
|
||||
a = _hand(0.30, 0.5, span=0.20)
|
||||
b = _hand(0.70, 0.5, span=0.06)
|
||||
out = ext.step([a, b], [1, 0], swap=False)
|
||||
assert out["L"]["cx"] == pytest.approx(0.70, abs=0.01) # chir 0 -> L
|
||||
assert out["R"]["cx"] == pytest.approx(0.30, abs=0.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fist_enabled gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fist_enabled_false_zeroes_fist_preserves_openness():
|
||||
"""fist_enabled=(False, False) forces fist=0 but leaves openness intact."""
|
||||
ext = HandFeatureExtractor()
|
||||
h = _fist_hand(0.20) # all curled -> fist > 0.9 normally
|
||||
out = ext.step([h], fist_enabled=(False, False))
|
||||
hand = out["L"] or out["R"]
|
||||
assert hand["fist"] == 0.0, "fist must be zeroed when disabled"
|
||||
assert hand["openness"] < 0.2, "openness unaffected"
|
||||
|
||||
|
||||
def test_fist_enabled_true_computes_fist():
|
||||
"""fist_enabled=(True, True) leaves fist computation unchanged."""
|
||||
ext = HandFeatureExtractor(fist_enable=True)
|
||||
h = _fist_hand(0.20)
|
||||
out = ext.step([h], fist_enabled=(True, True))
|
||||
hand = out["L"] or out["R"]
|
||||
assert hand["fist"] > 0.9
|
||||
|
||||
|
||||
def test_mirror_flips_cx_fallback_slot_assignment():
|
||||
"""mirror=True inverts the cx fallback so screen-right hand goes to slot L."""
|
||||
ext = HandFeatureExtractor()
|
||||
h_low = _hand(0.2, 0.5, span=0.10) # cx=0.2 -> slot L normally (leftmost)
|
||||
h_high = _hand(0.8, 0.5, span=0.10) # cx=0.8 -> slot R normally
|
||||
# No chirality -> cx fallback
|
||||
plain = ext.step([h_low, h_high], chirality=None, mirror=False)
|
||||
mirrored = ext.step([h_low, h_high], chirality=None, mirror=True)
|
||||
assert plain["L"]["cx"] == pytest.approx(0.2, abs=0.02), "non-mirrored: L gets leftmost"
|
||||
assert mirrored["L"]["cx"] == pytest.approx(0.8, abs=0.02), "mirrored: L gets rightmost"
|
||||
|
||||
|
||||
def test_mirror_default_false_unchanged():
|
||||
"""mirror defaults to False; existing tests that pass no mirror kwarg are unaffected."""
|
||||
ext = HandFeatureExtractor()
|
||||
h_a = _hand(0.2, 0.5, span=0.10)
|
||||
h_b = _hand(0.8, 0.5, span=0.10)
|
||||
no_kwarg = ext.step([h_a, h_b])
|
||||
explicit = ext.step([h_a, h_b], mirror=False)
|
||||
assert no_kwarg["L"]["cx"] == pytest.approx(explicit["L"]["cx"], abs=1e-6)
|
||||
|
||||
|
||||
def test_fist_enabled_per_slot_independence():
|
||||
"""fist_enabled masks only the specified slot."""
|
||||
ext = HandFeatureExtractor()
|
||||
# Build two 21-kp hands with distinct wrist positions for L/R routing
|
||||
# Left hand: cx~0.2, all fingers curled (fist)
|
||||
h_fist = _fist_hand(0.20)
|
||||
h_fist[0] = LM(0.2, 0.6)
|
||||
h_fist[9] = LM(0.2, 0.5)
|
||||
for mcp in (5, 13, 17):
|
||||
h_fist[mcp] = LM(0.2, 0.5)
|
||||
for tip in (8, 12, 16, 20):
|
||||
h_fist[tip] = LM(0.2 + 0.20 * 0.10, 0.5)
|
||||
# Right hand: cx~0.8, fingers extended (open)
|
||||
h_open = _fist_hand(0.80)
|
||||
h_open[0] = LM(0.8, 0.6)
|
||||
h_open[9] = LM(0.8, 0.5)
|
||||
for mcp in (5, 13, 17):
|
||||
h_open[mcp] = LM(0.8, 0.5)
|
||||
for tip in (8, 12, 16, 20):
|
||||
h_open[tip] = LM(0.8 + 0.80 * 0.10, 0.5)
|
||||
out = ext.step([h_fist, h_open], fist_enabled=(False, True))
|
||||
# L slot (cx~0.2) should have fist=0 even though it's curled
|
||||
assert out["L"]["fist"] == 0.0
|
||||
# R slot (cx~0.8) should compute normally (open -> fist < 0.1)
|
||||
assert out["R"]["fist"] < 0.1
|
||||
|
||||
|
||||
def test_fist_kill_switch_default_off(monkeypatch):
|
||||
"""FIST_ENABLE unset -> fist forced 0.0 even on a perfect fist
|
||||
(user-disabled live 2026-07-02); continuous features unaffected."""
|
||||
monkeypatch.delenv("FIST_ENABLE", raising=False)
|
||||
out = HandFeatureExtractor().step([_fist_hand(0.20)])
|
||||
hand = out["L"] or out["R"]
|
||||
assert hand["fist"] == 0.0
|
||||
assert hand["openness"] < 0.2
|
||||
|
||||
|
||||
def test_fist_kill_switch_env_reenable(monkeypatch):
|
||||
monkeypatch.setenv("FIST_ENABLE", "1")
|
||||
out = HandFeatureExtractor().step([_fist_hand(0.20)])
|
||||
assert (out["L"] or out["R"])["fist"] > 0.9
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Tests for hand_slots.route_hands and GestureSlotStabilizer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from data_only_viz.hand_slots import route_hands, GestureSlotStabilizer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _hand(cx: float = 0.5, size: float = 0.15) -> list:
|
||||
"""21-landmark hand (list of [x, y, z]). Wrist at cx, middle-MCP 'size' above."""
|
||||
lm = [[cx, 0.5, 0.0] for _ in range(21)]
|
||||
lm[0] = [cx, 0.6, 0.0] # wrist
|
||||
lm[9] = [cx, 0.6 - size, 0.0] # middle-MCP -> hand_size == size
|
||||
return lm
|
||||
|
||||
|
||||
def _small_hand(cx: float = 0.5) -> list:
|
||||
"""Hand with size 0.02 (below default near_min 0.10)."""
|
||||
return _hand(cx=cx, size=0.02)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic chirality routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chirality_routes_left_to_slot0():
|
||||
h_l = _hand(cx=0.8) # camera-right = user's left (mirror)
|
||||
h_r = _hand(cx=0.2)
|
||||
result = route_hands([h_l, h_r], [0, 1])
|
||||
assert result[0] is h_l
|
||||
assert result[1] is h_r
|
||||
|
||||
|
||||
def test_chirality_routes_right_to_slot1():
|
||||
h_r = _hand(cx=0.2)
|
||||
result = route_hands([h_r], [1])
|
||||
assert result[0] is None
|
||||
assert result[1] is h_r
|
||||
|
||||
|
||||
def test_chirality_first_match_wins_per_slot():
|
||||
"""Two hands with chirality==0: first wins, second dropped."""
|
||||
h1 = _hand(cx=0.3)
|
||||
h2 = _hand(cx=0.7)
|
||||
result = route_hands([h1, h2], [0, 0])
|
||||
assert result[0] is h1
|
||||
assert result[1] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chirality misaligned → cx fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chirality_misaligned_uses_cx_fallback():
|
||||
h_l = _hand(cx=0.2)
|
||||
h_r = _hand(cx=0.8)
|
||||
# chirality length 1 != hands length 2 → misaligned
|
||||
result = route_hands([h_l, h_r], [0])
|
||||
# cx fallback: ascending cx order → cx=0.2 → slot 0, cx=0.8 → slot 1
|
||||
assert result[0] is h_l
|
||||
assert result[1] is h_r
|
||||
|
||||
|
||||
def test_chirality_none_uses_cx_fallback():
|
||||
h_l = _hand(cx=0.2)
|
||||
h_r = _hand(cx=0.8)
|
||||
result = route_hands([h_l, h_r], None)
|
||||
assert result[0] is h_l
|
||||
assert result[1] is h_r
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mirror flips fallback order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mirror_flips_cx_fallback_order():
|
||||
h_a = _hand(cx=0.2) # low cx = screen-left normally
|
||||
h_b = _hand(cx=0.8)
|
||||
# Without mirror: slot0=cx0.2, slot1=cx0.8
|
||||
plain = route_hands([h_a, h_b])
|
||||
assert plain[0] is h_a
|
||||
# With mirror: flip cx → 1-0.2=0.8, 1-0.8=0.2 → sort ascending → cx0.8 goes slot0
|
||||
mirrored = route_hands([h_a, h_b], mirror=True)
|
||||
assert mirrored[0] is h_b
|
||||
assert mirrored[1] is h_a
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# swap: inverts the CHIRALITY interpretation only (source chirality flipped);
|
||||
# the cx fallback is screen-relative and must never be affected.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_swap_inverts_chirality_mapping():
|
||||
h_l = _hand(cx=0.8) # Vision says left (chir=0) but source is inverted
|
||||
h_r = _hand(cx=0.2) # Vision says right (chir=1)
|
||||
result = route_hands([h_l, h_r], [0, 1], swap=True)
|
||||
assert result[0] is h_r # chir 1 -> left slot under swap
|
||||
assert result[1] is h_l # chir 0 -> right slot under swap
|
||||
|
||||
|
||||
def test_swap_does_not_alter_cx_fallback():
|
||||
"""swap only concerns chirality; the cx fallback stays screen-relative."""
|
||||
h_a = _hand(cx=0.2)
|
||||
h_b = _hand(cx=0.8)
|
||||
result = route_hands([h_a, h_b], None, swap=True)
|
||||
assert result[0] is h_a # unchanged: leftmost keeps slot 0
|
||||
assert result[1] is h_b
|
||||
|
||||
|
||||
def test_swap_single_hand_moves_slot():
|
||||
"""A lone chir==0 hand lands in slot 1 when swap is on."""
|
||||
h = _hand(cx=0.5)
|
||||
result = route_hands([h], [0], swap=True)
|
||||
assert result[0] is None
|
||||
assert result[1] is h
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# near_min gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_near_min_drops_small_hand():
|
||||
big = _hand(cx=0.3, size=0.15)
|
||||
small = _hand(cx=0.7, size=0.02)
|
||||
result = route_hands([big, small], near_min=0.10)
|
||||
assert result[0] is big
|
||||
assert result[1] is None # too small
|
||||
|
||||
|
||||
def test_near_min_preserves_big_slot():
|
||||
big = _hand(cx=0.5, size=0.20)
|
||||
result = route_hands([big], near_min=0.10)
|
||||
assert result[0] is big
|
||||
|
||||
|
||||
def test_near_min_drops_both_when_all_small():
|
||||
s1 = _small_hand(cx=0.3)
|
||||
s2 = _small_hand(cx=0.7)
|
||||
result = route_hands([s1, s2], near_min=0.10)
|
||||
assert result == [None, None]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invalid hand filtering (alignment preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_invalid_hand_skipped_chirality_alignment_preserved():
|
||||
"""An invalid hand at index 0 must not shift chirality for index 1."""
|
||||
bad = None # invalid
|
||||
h_r = _hand(cx=0.5)
|
||||
# chirality: bad=0(ignored), h_r=1(right)
|
||||
result = route_hands([bad, h_r], [0, 1])
|
||||
assert result[0] is None
|
||||
assert result[1] is h_r
|
||||
|
||||
|
||||
def test_short_hand_rejected():
|
||||
short = [[0.5, 0.5, 0.0]] * 10 # <21 landmarks
|
||||
h_ok = _hand(cx=0.5)
|
||||
result = route_hands([short, h_ok], [0, 1])
|
||||
assert result[0] is None # short dropped
|
||||
assert result[1] is h_ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_empty_hands_returns_none_pair():
|
||||
assert route_hands([]) == [None, None]
|
||||
|
||||
|
||||
def test_single_hand_no_chirality_slot0():
|
||||
h = _hand(cx=0.5)
|
||||
result = route_hands([h])
|
||||
assert result[0] is h
|
||||
assert result[1] is None
|
||||
|
||||
|
||||
def test_two_hands_chirality_all_left():
|
||||
"""Both hands claim left; first gets slot 0, second is dropped."""
|
||||
h1 = _hand(cx=0.2)
|
||||
h2 = _hand(cx=0.8)
|
||||
result = route_hands([h1, h2], [0, 0])
|
||||
assert result[0] is h1
|
||||
assert result[1] is None
|
||||
|
||||
|
||||
def test_result_always_length_2():
|
||||
for hands, chir in [
|
||||
([], None),
|
||||
([_hand()], None),
|
||||
([_hand(), _hand(cx=0.8)], [0, 1]),
|
||||
([_hand(), _hand(cx=0.8), _hand(cx=0.3)], [0, 1, 0]),
|
||||
]:
|
||||
r = route_hands(hands, chir)
|
||||
assert len(r) == 2, f"expected 2 slots, got {len(r)} for {chir}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GestureSlotStabilizer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _near_hand(cx: float = 0.5, size: float = 0.15) -> list:
|
||||
"""Near hand (size >= near_on=0.10) that also passes the face gate.
|
||||
|
||||
kp[5]/kp[17] spread by 0.12 → facing = 0.12/0.15 = 0.8 >= face_min=0.5.
|
||||
All tests that verify near-hysteresis / hold / resumed behavior should use
|
||||
this helper so they continue to pass after the facing gate was added.
|
||||
"""
|
||||
lm = _hand(cx=cx, size=size)
|
||||
lm[5] = [cx - 0.06, 0.5, 0.0] # index-MCP
|
||||
lm[17] = [cx + 0.06, 0.5, 0.0] # pinky-MCP → spread=0.12, facing≈0.8
|
||||
return lm
|
||||
|
||||
|
||||
def _sideon_near_hand(cx: float = 0.5, size: float = 0.15) -> list:
|
||||
"""Near hand (size >= near_on) whose kp[5]==kp[17] → facing=0.0 (fails face gate).
|
||||
|
||||
Use this for tests that specifically verify the face gate rejects a side-on
|
||||
palm even when the hand is close enough to pass the near gate.
|
||||
"""
|
||||
return _hand(cx=cx, size=size)
|
||||
|
||||
|
||||
def _far_hand(cx: float = 0.5) -> list:
|
||||
"""Hand with hand_size == 0.02 (below near_on=0.10)."""
|
||||
return _hand(cx=cx, size=0.02)
|
||||
|
||||
|
||||
def test_stab_hold_last_one_frame_hole():
|
||||
"""A 1-frame Vision hole keeps the last hand in the slot."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
stab.step([h, None]) # activate slot 0
|
||||
stab.step([h, None]) # slot 0 established
|
||||
out = stab.step([None, None]) # miss=1 <= hold -> slot 0 holds last hand
|
||||
assert out[0] is h, "slot 0 should hold last hand on 1-frame miss"
|
||||
assert out[1] is None
|
||||
|
||||
|
||||
def test_stab_hold_last_two_frame_hole():
|
||||
"""A 2-frame Vision hole keeps the last hand both frames."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
stab.step([h, None])
|
||||
out1 = stab.step([None, None]) # miss=1
|
||||
out2 = stab.step([None, None]) # miss=2 <= hold=2
|
||||
assert out1[0] is h
|
||||
assert out2[0] is h
|
||||
|
||||
|
||||
def test_stab_slot_goes_none_after_hold_frames():
|
||||
"""After hold_frames+1 consecutive misses the slot yields None."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
stab.step([h, None])
|
||||
stab.step([None, None]) # miss=1
|
||||
stab.step([None, None]) # miss=2 (still holding)
|
||||
out = stab.step([None, None]) # miss=3 > hold=2 -> None for real
|
||||
assert out[0] is None
|
||||
|
||||
|
||||
def test_stab_hysteresis_stays_active_above_near_off():
|
||||
"""Once active, hand stays active while size >= near_off."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h_on = _near_hand(size=0.11) # size >= near_on -> activates
|
||||
h_mid = _near_hand(size=0.09) # near_off <= size < near_on -> stays active
|
||||
out1 = stab.step([h_on, None])
|
||||
assert out1[0] is h_on
|
||||
out2 = stab.step([h_mid, None])
|
||||
assert out2[0] is h_mid, "should stay active at size 0.09 (>= near_off 0.08)"
|
||||
|
||||
|
||||
def test_stab_hysteresis_deactivates_below_near_off():
|
||||
"""Hand deactivates when size drops below near_off."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h_on = _near_hand(size=0.11)
|
||||
h_off = _near_hand(size=0.07) # size < near_off -> deactivate
|
||||
stab.step([h_on, None])
|
||||
out = stab.step([h_off, None])
|
||||
assert out[0] is None, "slot should deactivate below near_off"
|
||||
|
||||
|
||||
def test_stab_hysteresis_needs_near_on_to_reactivate():
|
||||
"""After deactivation, size must reach near_on again to re-activate."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h_on = _near_hand(size=0.11)
|
||||
h_off = _near_hand(size=0.07) # deactivates
|
||||
h_mid = _near_hand(size=0.09) # between near_off and near_on -> stays inactive
|
||||
h_on2 = _near_hand(size=0.11) # back to near_on -> reactivates
|
||||
stab.step([h_on, None])
|
||||
stab.step([h_off, None]) # deactivated
|
||||
out_mid = stab.step([h_mid, None])
|
||||
assert out_mid[0] is None, "should stay inactive at 0.09 after deactivation"
|
||||
out_on2 = stab.step([h_on2, None])
|
||||
assert out_on2[0] is h_on2, "should re-activate at near_on"
|
||||
|
||||
|
||||
def test_stab_fresh_far_hand_returns_none():
|
||||
"""A far hand on a fresh stabilizer yields None (not near)."""
|
||||
stab = GestureSlotStabilizer(near_on=0.10, near_off=0.08)
|
||||
h = _far_hand()
|
||||
out = stab.step([h, None])
|
||||
assert out[0] is None
|
||||
|
||||
|
||||
def test_stab_active_flags_reflect_current_output():
|
||||
"""active_flags() matches the last step() output per slot."""
|
||||
stab = GestureSlotStabilizer(near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
stab.step([h, None])
|
||||
flags = stab.active_flags()
|
||||
assert flags == (True, False)
|
||||
stab.step([None, None]) # miss=1 -> hold
|
||||
flags2 = stab.active_flags()
|
||||
assert flags2 == (True, False) # still holding
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GestureSlotStabilizer.resumed_flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resumed_flags_true_exactly_one_step_on_reappear():
|
||||
"""resumed_flags True on the one step that transitions held -> real."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
h2 = _near_hand(cx=0.4)
|
||||
stab.step([h, None]) # activate slot 0
|
||||
stab.step([None, None]) # miss=1 -> held replay
|
||||
stab.step([h2, None]) # real hand returns -> resumed
|
||||
assert stab.resumed_flags()[0] is True, "should be True on first real frame after hold"
|
||||
# next step, still real -> False
|
||||
stab.step([h2, None])
|
||||
assert stab.resumed_flags()[0] is False, "should be False once hand stays real"
|
||||
|
||||
|
||||
def test_resumed_flags_false_steady():
|
||||
"""Continuously present hand -> resumed_flags never True."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
stab.step([h, None])
|
||||
stab.step([h, None])
|
||||
assert stab.resumed_flags()[0] is False
|
||||
|
||||
|
||||
def test_resumed_flags_false_after_expired_hold():
|
||||
"""Hand returning after hold window expired does not trigger resumed (slot went None)."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand()
|
||||
h2 = _near_hand(cx=0.4)
|
||||
stab.step([h, None]) # activate
|
||||
stab.step([None, None]) # miss=1, held
|
||||
stab.step([None, None]) # miss=2, held
|
||||
stab.step([None, None]) # miss=3 > hold -> near_active cleared, out=None
|
||||
stab.step([h2, None]) # reappear after expired hold
|
||||
# _held_flag was False (hold expired), so resumed must be False
|
||||
assert stab.resumed_flags()[0] is False, "expired hold: held_flag reset, no resume"
|
||||
|
||||
|
||||
def test_resumed_flags_independent_per_slot():
|
||||
"""Each slot tracks held/resumed independently."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h0 = _near_hand(cx=0.3)
|
||||
h1 = _near_hand(cx=0.7)
|
||||
h0b = _near_hand(cx=0.3)
|
||||
stab.step([h0, h1]) # activate both
|
||||
stab.step([None, h1]) # slot 0 held, slot 1 real
|
||||
stab.step([h0b, h1]) # slot 0 resumed, slot 1 unchanged
|
||||
r = stab.resumed_flags()
|
||||
assert r[0] is True, "slot 0 just transitioned held -> real"
|
||||
assert r[1] is False, "slot 1 was never held"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GestureSlotStabilizer — face gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stab_face_gate_sideon_never_activates():
|
||||
"""A side-on hand (facing=0.0) never activates the slot even when near."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _sideon_near_hand() # size=0.15 >= near_on, but facing=0.0 < face_min=0.5
|
||||
out1 = stab.step([h, None])
|
||||
out2 = stab.step([h, None])
|
||||
assert out1[0] is None, "side-on: slot must be None (face gate rejects)"
|
||||
assert out2[0] is None, "side-on: slot still None after two frames"
|
||||
assert stab.active_flags() == (False, False)
|
||||
|
||||
|
||||
def test_stab_face_gate_facing_activates():
|
||||
"""A near, camera-facing hand activates the slot (passes both gates)."""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
h = _near_hand() # size=0.15 >= near_on, facing=0.8 >= face_min=0.5
|
||||
stab.step([h, None])
|
||||
out = stab.step([h, None])
|
||||
assert out[0] is h, "near+facing hand must be in slot 0"
|
||||
assert stab.active_flags() == (True, False)
|
||||
|
||||
|
||||
def test_stab_face_gate_hysteresis_stays_active_between_thresholds():
|
||||
"""Once active, slot stays active when facing drops between face_off and face_min.
|
||||
|
||||
face_min=0.5, face_off=0.4. A hand that was facing (0.8) then turns
|
||||
slightly side-on to facing=0.43 must NOT deactivate (hysteresis protects
|
||||
against flapping). Only a drop below face_off (0.4) would deactivate.
|
||||
"""
|
||||
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
|
||||
# Activate: near+facing hand
|
||||
h_facing = _near_hand() # facing=0.8
|
||||
stab.step([h_facing, None])
|
||||
assert stab.step([h_facing, None])[0] is h_facing
|
||||
|
||||
# Build a hand with facing=0.43 (between face_off=0.4 and face_min=0.5)
|
||||
# size=0.15, spread=0.43*0.15=0.0645
|
||||
h_mid = _hand()
|
||||
h_mid[5] = [0.5 - 0.032, 0.5, 0.0]
|
||||
h_mid[17] = [0.5 + 0.032, 0.5, 0.0] # dist≈0.064/0.15≈0.43
|
||||
|
||||
out = stab.step([h_mid, None])
|
||||
assert out[0] is h_mid, "hysteresis: slot stays active between face_off and face_min"
|
||||
assert stab.active_flags() == (True, False)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""The iPhone USB source must keep retrying the initial connect, not give up
|
||||
when the iPhone app isn't streaming yet. Auto-reconnect must cover a startup
|
||||
race (app not ready when data_only_viz starts), not only a mid-session drop."""
|
||||
import time
|
||||
|
||||
import data_only_viz.iphone_usb_source as m
|
||||
from data_only_viz.state import PoseKp, State
|
||||
|
||||
|
||||
def test_start_keeps_retrying_when_no_device(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_connect():
|
||||
calls["n"] += 1
|
||||
return None # device never available during the test
|
||||
|
||||
monkeypatch.setattr(m, "connect_device", fake_connect)
|
||||
monkeypatch.setattr(m, "iter_frames", lambda s: iter(()))
|
||||
src = m.IphoneUSBSource()
|
||||
try:
|
||||
assert src.start() is True # does NOT give up on initial connect failure
|
||||
time.sleep(0.25) # let the read thread attempt a few reconnects
|
||||
assert calls["n"] >= 2, (
|
||||
"start() should keep retrying; connect_device called %d time(s)" % calls["n"])
|
||||
finally:
|
||||
src.release()
|
||||
|
||||
|
||||
def test_hands_cleared_on_stream_drop(monkeypatch):
|
||||
"""After a USB stream drop, persons_hands* lists must be emptied so the
|
||||
renderer does not freeze on the last known hand positions."""
|
||||
|
||||
class FakeSock:
|
||||
def close(self): pass
|
||||
def shutdown(self, *a): pass
|
||||
|
||||
connect_calls = [0]
|
||||
|
||||
def fake_connect():
|
||||
connect_calls[0] += 1
|
||||
if connect_calls[0] == 1:
|
||||
return FakeSock() # initial connection succeeds
|
||||
return None # reconnect fails; release() will interrupt the wait
|
||||
|
||||
def fake_iter_frames(sock):
|
||||
raise OSError("stream dropped") # simulate immediate disconnect
|
||||
|
||||
state = State()
|
||||
dummy_hand = [PoseKp(x=0.5, y=0.5, z=0.0, c=1.0)] * 21
|
||||
with state.lock():
|
||||
state.persons_hands = [dummy_hand]
|
||||
state.persons_hands_iphone = [dummy_hand]
|
||||
state.persons_hands_chirality = [1]
|
||||
|
||||
monkeypatch.setattr(m, "connect_device", fake_connect)
|
||||
monkeypatch.setattr(m, "iter_frames", fake_iter_frames)
|
||||
|
||||
src = m.IphoneUSBSource(state=state, write_hands=True)
|
||||
src.start()
|
||||
src.release() # joins thread; ensures clearing happened before we check
|
||||
|
||||
with state.lock():
|
||||
assert state.persons_hands == [], "persons_hands not cleared on drop"
|
||||
assert state.persons_hands_iphone == [], "persons_hands_iphone not cleared on drop"
|
||||
assert state.persons_hands_chirality == [], "persons_hands_chirality not cleared on drop"
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Pure (no-device) unit tests for iphone_usb_source helpers.
|
||||
|
||||
Tests _to_annexb and _decode_hands without any hardware or network connection.
|
||||
Tests _to_annexb, _decode_hands, and apply_skeleton_joints without any
|
||||
hardware or network connection.
|
||||
"""
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from data_only_viz.iphone_usb_source import _to_annexb, _decode_hands, _HAND_BYTES
|
||||
from data_only_viz.iphone_usb_source import (
|
||||
_to_annexb, _decode_hands, _HAND_BYTES,
|
||||
apply_skeleton_joints, _ARKIT_JOINTS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,15 +26,20 @@ def _avcc(nals: list[bytes]) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _make_hands_payload(hands: list[list[tuple[float, float, float]]]) -> bytes:
|
||||
def _make_hands_payload(
|
||||
hands: list[list[tuple[float, float, float]]],
|
||||
chiralities: list[int] | None = None,
|
||||
) -> bytes:
|
||||
"""Build a synthetic HandsPayload matching the wire layout.
|
||||
|
||||
count:u8, then per hand: chirality:u8 (1=right) + 21 × (x,y,z) big-endian f32.
|
||||
chiralities[i] sets the chirality byte for hand i (default 1=right for all).
|
||||
"""
|
||||
out = bytearray()
|
||||
out += bytes([len(hands)])
|
||||
for hand_kps in hands:
|
||||
out += bytes([1]) # chirality = right
|
||||
for i, hand_kps in enumerate(hands):
|
||||
chir = chiralities[i] if chiralities is not None else 1
|
||||
out += bytes([chir])
|
||||
for x, y, z in hand_kps:
|
||||
out += struct.pack(">fff", x, y, z)
|
||||
return bytes(out)
|
||||
@@ -86,7 +96,7 @@ def test_decode_hands_empty_payload():
|
||||
|
||||
|
||||
def test_decode_hands_count_zero():
|
||||
assert _decode_hands(bytes([0])) == []
|
||||
assert _decode_hands(bytes([0])) == ([], [])
|
||||
|
||||
|
||||
def test_decode_hands_two_hands_length_and_coords():
|
||||
@@ -94,24 +104,30 @@ def test_decode_hands_two_hands_length_and_coords():
|
||||
hand_b = [(1.0 - float(i) * 0.03, 0.5, float(i) * 0.01) for i in range(21)]
|
||||
payload = _make_hands_payload([hand_a, hand_b])
|
||||
result = _decode_hands(payload)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
for hand in result:
|
||||
assert isinstance(result, tuple)
|
||||
hands, chirality = result
|
||||
assert isinstance(hands, list)
|
||||
assert len(hands) == 2
|
||||
assert len(chirality) == 2
|
||||
for hand in hands:
|
||||
assert len(hand) == 21
|
||||
# x/y spot-checks for hand A
|
||||
assert abs(result[0][0].x - hand_a[0][0]) < 1e-5
|
||||
assert abs(result[0][5].y - hand_a[5][1]) < 1e-5
|
||||
assert abs(hands[0][0].x - hand_a[0][0]) < 1e-5
|
||||
assert abs(hands[0][5].y - hand_a[5][1]) < 1e-5
|
||||
# x/y spot-checks for hand B
|
||||
assert abs(result[1][3].x - hand_b[3][0]) < 1e-5
|
||||
assert abs(result[1][20].y - hand_b[20][1]) < 1e-5
|
||||
assert abs(hands[1][3].x - hand_b[3][0]) < 1e-5
|
||||
assert abs(hands[1][20].y - hand_b[20][1]) < 1e-5
|
||||
|
||||
|
||||
def test_decode_hands_one_hand():
|
||||
kps = [(0.1 * i, 0.2 * i, 0.0) for i in range(21)]
|
||||
result = _decode_hands(_make_hands_payload([kps]))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 21
|
||||
assert isinstance(result, tuple)
|
||||
hands, chirality = result
|
||||
assert isinstance(hands, list)
|
||||
assert len(hands) == 1
|
||||
assert len(hands[0]) == 21
|
||||
assert len(chirality) == 1
|
||||
|
||||
|
||||
def test_decode_hands_truncated_does_not_raise():
|
||||
@@ -119,6 +135,189 @@ def test_decode_hands_truncated_does_not_raise():
|
||||
full = _make_hands_payload([hand_kps, hand_kps])
|
||||
# Truncate to half — second hand will be incomplete
|
||||
truncated = full[:len(full) // 2]
|
||||
# Must not raise
|
||||
# Must not raise; result is either None or a (hands, chirality) tuple
|
||||
result = _decode_hands(truncated)
|
||||
assert isinstance(result, (list, type(None)))
|
||||
assert result is None or isinstance(result, tuple)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task A: confidence + chirality (new tests added for TDD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_decode_hands_count_zero_returns_tuple():
|
||||
"""count=0 payload must return ([], []), not a bare []."""
|
||||
result = _decode_hands(bytes([0]))
|
||||
assert result == ([], [])
|
||||
|
||||
|
||||
def test_decode_hands_chirality_and_confidence_roundtrip():
|
||||
"""Two hands with chirality [0,1] and in-range z values round-trip correctly.
|
||||
|
||||
.c must equal clamp(z, 0, 1); .z must equal raw wire z.
|
||||
"""
|
||||
hand_a = [(0.1 * i, 0.2 * i, 0.7) for i in range(21)] # z=0.7 in range
|
||||
hand_b = [(0.05 * i, 0.1 * i, 0.3) for i in range(21)] # z=0.3 in range
|
||||
payload = _make_hands_payload([hand_a, hand_b], chiralities=[0, 1])
|
||||
result = _decode_hands(payload)
|
||||
assert result is not None
|
||||
hands, chirality = result
|
||||
assert chirality == [0, 1]
|
||||
assert len(hands) == 2
|
||||
# hand A: z=0.7 → .z=0.7 raw, .c=0.7 clamped (same, in range)
|
||||
assert abs(hands[0][0].z - 0.7) < 1e-5
|
||||
assert abs(hands[0][0].c - 0.7) < 1e-5
|
||||
# hand B: z=0.3 → .z=0.3 raw, .c=0.3 clamped (same, in range)
|
||||
assert abs(hands[1][0].z - 0.3) < 1e-5
|
||||
assert abs(hands[1][0].c - 0.3) < 1e-5
|
||||
|
||||
|
||||
def test_decode_hands_confidence_clamped_out_of_range():
|
||||
"""z outside [0,1] clamps in .c but stays raw in .z."""
|
||||
hand_low = [(0.5, 0.5, -0.2)] * 21 # z below 0
|
||||
hand_high = [(0.5, 0.5, 1.7)] * 21 # z above 1
|
||||
payload = _make_hands_payload([hand_low, hand_high])
|
||||
result = _decode_hands(payload)
|
||||
assert result is not None
|
||||
hands, _ = result
|
||||
# hand_low: raw z preserved, c clamped to 0
|
||||
assert abs(hands[0][0].z - (-0.2)) < 1e-5
|
||||
assert hands[0][0].c == 0.0
|
||||
# hand_high: raw z preserved, c clamped to 1
|
||||
assert abs(hands[1][0].z - 1.7) < 1e-5
|
||||
assert hands[1][0].c == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_skeleton_joints — B4 tearing fix (chantier 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_all_valid_joints(value: float = 0.1) -> list[tuple[float, float, float, bool]]:
|
||||
"""Return _ARKIT_JOINTS tuples all marked valid with a constant value."""
|
||||
return [(value, value, value, True)] * _ARKIT_JOINTS
|
||||
|
||||
|
||||
def _make_joints_with_mask(
|
||||
values: "list[tuple[float, float, float]]",
|
||||
valid_mask: "list[bool]",
|
||||
) -> list[tuple[float, float, float, bool]]:
|
||||
"""Zip (x,y,z) values with a validity mask into the joints wire format."""
|
||||
return [(x, y, z, v) for (x, y, z), v in zip(values, valid_mask)]
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_fresh_pid_returns_correct_shape():
|
||||
"""prev=None → zeros array of shape (_ARKIT_JOINTS, 3), dtype float32."""
|
||||
joints = [(0.0, 0.0, 0.0, False)] * _ARKIT_JOINTS
|
||||
result = apply_skeleton_joints(None, joints)
|
||||
assert result.shape == (_ARKIT_JOINTS, 3)
|
||||
assert result.dtype == np.float32
|
||||
assert (result == 0).all()
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_returns_new_array_each_call():
|
||||
"""Each call returns a different array object — never the same reference.
|
||||
|
||||
This is the core identity guarantee for the B4 tearing fix: readers that
|
||||
captured a reference to arr1 continue to see a consistent arr1 even while
|
||||
the writer has moved on to arr2.
|
||||
"""
|
||||
joints1 = _make_all_valid_joints(0.1)
|
||||
arr1 = apply_skeleton_joints(None, joints1)
|
||||
|
||||
joints2 = _make_all_valid_joints(0.9)
|
||||
arr2 = apply_skeleton_joints(arr1, joints2)
|
||||
|
||||
assert arr2 is not arr1
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_preserves_values_not_valid_this_frame():
|
||||
"""Joints with valid=False in update 2 keep their values from update 1.
|
||||
|
||||
Update 1 sets all joints to their index value. Update 2 marks only
|
||||
joint 0 as valid with a new value. Joints 1..N-1 must be unchanged.
|
||||
"""
|
||||
# Update 1: every joint valid, joint i has x=float(i)
|
||||
joints1 = [(float(i), 0.0, 0.0, True) for i in range(_ARKIT_JOINTS)]
|
||||
arr1 = apply_skeleton_joints(None, joints1)
|
||||
|
||||
# Update 2: only joint 0 valid (new value), rest invalid
|
||||
vals2 = [(99.0, 99.0, 99.0)] * _ARKIT_JOINTS
|
||||
mask2 = [i == 0 for i in range(_ARKIT_JOINTS)]
|
||||
joints2 = _make_joints_with_mask(vals2, mask2)
|
||||
arr2 = apply_skeleton_joints(arr1, joints2)
|
||||
|
||||
# Joint 0: overwritten by update 2
|
||||
assert abs(arr2[0, 0] - 99.0) < 1e-5
|
||||
|
||||
# Joints 1..N-1: preserved from update 1
|
||||
for i in range(1, _ARKIT_JOINTS):
|
||||
assert abs(arr2[i, 0] - float(i)) < 1e-5, f"joint {i} not preserved"
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_valid_joints_applied():
|
||||
"""Valid joints from update 2 overwrite the previous values correctly."""
|
||||
joints1 = _make_all_valid_joints(0.0)
|
||||
arr1 = apply_skeleton_joints(None, joints1)
|
||||
|
||||
joints2 = [(1.0, 2.0, 3.0, True)] * _ARKIT_JOINTS
|
||||
arr2 = apply_skeleton_joints(arr1, joints2)
|
||||
|
||||
assert abs(arr2[5, 0] - 1.0) < 1e-5
|
||||
assert abs(arr2[5, 1] - 2.0) < 1e-5
|
||||
assert abs(arr2[5, 2] - 3.0) < 1e-5
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_wrong_shape_prev_discarded():
|
||||
"""prev_arr with wrong shape is discarded and output starts from zeros."""
|
||||
wrong_shape = np.ones((10, 3), dtype=np.float32)
|
||||
joints = [(0.5, 0.5, 0.5, True)] * _ARKIT_JOINTS
|
||||
result = apply_skeleton_joints(wrong_shape, joints)
|
||||
assert result.shape == (_ARKIT_JOINTS, 3)
|
||||
assert abs(result[0, 0] - 0.5) < 1e-5
|
||||
|
||||
|
||||
def test_apply_skeleton_joints_does_not_mutate_prev():
|
||||
"""apply_skeleton_joints must never modify the array passed as prev_arr."""
|
||||
joints1 = _make_all_valid_joints(1.0)
|
||||
arr1 = apply_skeleton_joints(None, joints1)
|
||||
arr1_copy = arr1.copy()
|
||||
|
||||
joints2 = _make_all_valid_joints(2.0)
|
||||
apply_skeleton_joints(arr1, joints2)
|
||||
|
||||
# arr1 must be byte-for-byte identical to what it was before the call
|
||||
np.testing.assert_array_equal(arr1, arr1_copy)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VIDEO_ROTATE normalized-point counterpart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rotate_norm_xy_matches_rot90():
|
||||
from data_only_viz.iphone_usb_source import rotate_norm_xy
|
||||
# np.rot90 k=1 (ccw): original (x, y) lands at (y, 1-x)
|
||||
assert rotate_norm_xy(0.2, 0.7, "ccw") == pytest.approx((0.7, 0.8))
|
||||
# cw (k=3): (x, y) -> (1-y, x)
|
||||
assert rotate_norm_xy(0.2, 0.7, "cw") == pytest.approx((0.3, 0.2))
|
||||
assert rotate_norm_xy(0.2, 0.7, "180") == pytest.approx((0.8, 0.3))
|
||||
assert rotate_norm_xy(0.2, 0.7, "none") == (0.2, 0.7)
|
||||
assert rotate_norm_xy(0.2, 0.7, "garbage") == (0.2, 0.7)
|
||||
|
||||
|
||||
def test_effective_point_rotate_mirror_conjugation():
|
||||
"""Video = flip THEN rotate; points = rotate THEN flip (renderer).
|
||||
Prove the conjugated mode lands points where the video pixel lands:
|
||||
flip_x(T(x,y)) must equal rot(flip_x(x,y)) for T = conjugate(rot)."""
|
||||
from data_only_viz.iphone_usb_source import (
|
||||
effective_point_rotate, rotate_norm_xy,
|
||||
)
|
||||
flip = lambda x, y: (1.0 - x, y)
|
||||
for video_mode in ("ccw", "cw", "180", "none"):
|
||||
pt_mode = effective_point_rotate(video_mode, mirror=True)
|
||||
for (x, y) in [(0.2, 0.7), (0.9, 0.1), (0.5, 0.5)]:
|
||||
video_dest = rotate_norm_xy(*flip(x, y), video_mode)
|
||||
point_dest = flip(*rotate_norm_xy(x, y, pt_mode))
|
||||
assert video_dest == pytest.approx(point_dest), (
|
||||
video_mode, x, y)
|
||||
# No mirror: modes pass through untouched
|
||||
assert effective_point_rotate("ccw", mirror=False) == "ccw"
|
||||
assert effective_point_rotate("none", mirror=True) == "none"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for PoseSoundBridge.send_finger."""
|
||||
"""Tests for PoseSoundBridge.send_finger and send_pinch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -34,3 +34,38 @@ def test_send_finger_emits_expected_osc():
|
||||
assert isinstance(args[0], int) and not isinstance(args[0], bool)
|
||||
assert isinstance(args[1], int) and isinstance(args[2], int)
|
||||
assert all(isinstance(a, float) for a in args[3:7])
|
||||
|
||||
|
||||
def test_send_pinch_emits_state_as_4th_arg():
|
||||
"""send_pinch must put the edge state (1=engage, 0=release) as 4th arg."""
|
||||
b = _bridge()
|
||||
|
||||
class _FakePinchEvent:
|
||||
hand = 0
|
||||
finger = 1
|
||||
state = 1
|
||||
|
||||
b.send_pinch(_FakePinchEvent())
|
||||
b._client.send_message.assert_called_once()
|
||||
addr, args = b._client.send_message.call_args[0]
|
||||
assert addr == "/pose/pinch"
|
||||
assert args[0] == 0 # pid
|
||||
assert args[1] == 0 # hand
|
||||
assert args[2] == 1 # finger
|
||||
assert args[3] == 1 # state (engage edge)
|
||||
assert len(args) == 4
|
||||
|
||||
|
||||
def test_send_pinch_release_state_is_zero():
|
||||
"""Release edge: state=0 appears as 4th arg."""
|
||||
b = _bridge()
|
||||
|
||||
class _FakeRelease:
|
||||
hand = 1
|
||||
finger = 2
|
||||
state = 0
|
||||
|
||||
b.send_pinch(_FakeRelease())
|
||||
addr, args = b._client.send_message.call_args[0]
|
||||
assert addr == "/pose/pinch"
|
||||
assert args[3] == 0 # state = release
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for /pose/mouth OSC route."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_send_mouth_address_and_args() -> None:
|
||||
from data_only_viz.pose_bridge import PoseSoundBridge
|
||||
b = PoseSoundBridge()
|
||||
b._client = MagicMock()
|
||||
b._vdmx = None
|
||||
b.send_mouth(pid=5, mouth_open=0.42)
|
||||
b._client.send_message.assert_called_once()
|
||||
address, args = b._client.send_message.call_args.args
|
||||
assert address == "/pose/mouth"
|
||||
assert args[0] == 5
|
||||
assert isinstance(args[0], int)
|
||||
assert abs(args[1] - 0.42) < 1e-6
|
||||
assert isinstance(args[1], float)
|
||||
|
||||
|
||||
def test_send_mouth_vj_mirror() -> None:
|
||||
"""VJ mirror receives the same address and args."""
|
||||
from data_only_viz.pose_bridge import PoseSoundBridge
|
||||
b = PoseSoundBridge()
|
||||
b._client = MagicMock()
|
||||
b._vdmx = MagicMock()
|
||||
b.send_mouth(pid=1, mouth_open=0.75)
|
||||
vj_call = b._vdmx.send_message.call_args.args
|
||||
assert vj_call[0] == "/pose/mouth"
|
||||
assert vj_call[1][0] == 1
|
||||
assert abs(vj_call[1][1] - 0.75) < 1e-6
|
||||
|
||||
|
||||
def test_send_mouth_zero_and_one_clamp() -> None:
|
||||
"""Boundary values: 0.0 and 1.0 are forwarded without change."""
|
||||
from data_only_viz.pose_bridge import PoseSoundBridge
|
||||
b = PoseSoundBridge()
|
||||
b._client = MagicMock()
|
||||
b._vdmx = None
|
||||
b.send_mouth(pid=0, mouth_open=0.0)
|
||||
_, args0 = b._client.send_message.call_args.args
|
||||
assert args0[1] == 0.0
|
||||
|
||||
b._client.reset_mock()
|
||||
b.send_mouth(pid=0, mouth_open=1.0)
|
||||
_, args1 = b._client.send_message.call_args.args
|
||||
assert args1[1] == 1.0
|
||||
@@ -0,0 +1,266 @@
|
||||
# Matrix 6-colour contrast derivation — 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:** Derive a well-contrasted 6-colour spectrum (base/busier/broken/double/sparse/fill) for every voice's `default` pattern family from its characteristic colour 1, deterministically, at read time.
|
||||
|
||||
**Architecture:** Two pure functions (`derive_drum_colors`, `derive_mel_colors`) transform a 16-step base into colours 2-6. `patterns_for` calls them whenever the resolved family is `default` — so the dicts are NOT edited; the contrast is applied uniformly at read time. Authored style families pass through untouched.
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`), SuperCollider validation harness.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: **uv** only. No emojis in code/docs/commits. Commits: subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no `--no-verify`, no underscore in commit scope.
|
||||
- Deterministic transforms (no RNG) → byte-for-byte reproducible regeneration.
|
||||
- Step shapes (from `_rhythm`/`_mel`): drum step = `None` (rest) or `(0, vel)` with `vel ∈ {0.30,0.50,0.80,1.0}`; mel step = `None` or `(int_degree, float_vel)`. Derived colours keep these shapes (16 steps).
|
||||
- Only the `default` family is derived; all non-`default` (authored style) families are unchanged. Colour 1 (the base) of every voice is unchanged.
|
||||
- SC validation: `/Applications/SuperCollider.app/Contents/MacOS/sclang <harness>` (binary not on PATH; no `timeout`). Push only on user request.
|
||||
|
||||
## Key existing code (patterns.py)
|
||||
|
||||
- `_rhythm(s)` (26): 16-char string → 16 steps (`None` / `(0, vel)`).
|
||||
- `_mel(pairs)` (36): 16 `(deg,vel)|None` → normalized list.
|
||||
- `DRUMS[role][family]` = 6 rhythm STRINGS; `MEL[role][family]` = 6 already-`_mel`'d motif lists.
|
||||
- `DRUM_VOICES` (287): the 12 drum voices.
|
||||
- `_pick(bank, role, family)` (291): `rolebank.get(family) or rolebank.get("default")`.
|
||||
- `patterns_for(voice, preset)` (299): drums → `[_rhythm(s) for s in _pick(...)]`; mel → `_pick(...)` directly.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix_presets/patterns.py` | add `derive_drum_colors`/`derive_mel_colors`; wire into `patterns_for` (derive when family == default) |
|
||||
| `sound_algo/data_only/matrix_presets/test_contrast.py` | create — standalone density/validity/determinism assertions |
|
||||
| `sound_algo/data_only/matrix_presets/*.matrix` | regenerated (28) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: derivation functions + unit tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/patterns.py` (add the two functions, e.g. after `_mel` ~39)
|
||||
- Create: `sound_algo/data_only/matrix_presets/test_contrast.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `derive_drum_colors(base) -> [c2,c3,c4,c5,c6]` (5 step-lists), `derive_mel_colors(base) -> [c2,c3,c4,c5,c6]`. `base` is a 16-element step list; each returned colour is a 16-element step list of the same shape.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `sound_algo/data_only/matrix_presets/test_contrast.py`:
|
||||
|
||||
```python
|
||||
"""Standalone test for the 6-colour contrast derivation.
|
||||
Run: cd sound_algo/data_only/matrix_presets && uv run python test_contrast.py
|
||||
Prints TEST PASS / TEST FAIL; exits non-zero on failure."""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from patterns import derive_drum_colors, derive_mel_colors, _rhythm, _mel
|
||||
|
||||
ok = True
|
||||
def check(c, m):
|
||||
global ok
|
||||
if not c: ok = False; print("FAIL:", m)
|
||||
|
||||
def dens(p): return sum(1 for s in p if s is not None)
|
||||
|
||||
# --- drums ---
|
||||
base = _rhythm("X...x...X...x...") # 4 hits
|
||||
c2, c3, c4, c5, c6 = derive_drum_colors(base)
|
||||
check(all(len(c) == 16 for c in (c2,c3,c4,c5,c6)), "drum colours 16 steps")
|
||||
check(dens(c2) >= dens(base), "busier >= base")
|
||||
check(dens(c4) >= dens(base), "double >= base")
|
||||
check(dens(c5) <= dens(base), "sparse <= base")
|
||||
check(dens(c3) == dens(base), "broken keeps hit count")
|
||||
check([i for i in range(16) if (c3[i] is not None) != (base[i] is not None)] != [], "broken displaces")
|
||||
check(all(c6[i] is not None for i in range(12, 16)), "fill last 4 active")
|
||||
# velocities legal
|
||||
legal = {0.30, 0.50, 0.80, 1.0}
|
||||
for c in (c2, c3, c4, c5, c6):
|
||||
check(all(s is None or s[1] in legal for s in c), "drum vel legal")
|
||||
# determinism
|
||||
check(derive_drum_colors(base) == derive_drum_colors(base), "drum deterministic")
|
||||
|
||||
# --- mel ---
|
||||
mbase = _mel([(0,0.8),None,(3,0.6),None,(7,0.7),None,None,None,
|
||||
(5,0.6),None,(3,0.6),None,(0,0.8),None,None,None])
|
||||
m2, m3, m4, m5, m6 = derive_mel_colors(mbase)
|
||||
check(all(len(c) == 16 for c in (m2,m3,m4,m5,m6)), "mel colours 16 steps")
|
||||
check(dens(m4) >= dens(mbase), "mel double >= base")
|
||||
check(dens(m5) <= dens(mbase), "mel sparse <= base")
|
||||
check(all((s is None) or (isinstance(s[0], int) and isinstance(s[1], float)) for s in m4), "mel step shape")
|
||||
check(all(m6[i] is not None for i in range(12, 16)), "mel fill last 4 active")
|
||||
check(derive_mel_colors(mbase) == derive_mel_colors(mbase), "mel deterministic")
|
||||
|
||||
print("TEST PASS" if ok else "TEST FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_contrast.py`
|
||||
Expected: ImportError / `TEST FAIL` — the derive functions don't exist.
|
||||
|
||||
- [ ] **Step 3: Implement the two functions.** In `patterns.py`, after `_mel` (~39), add:
|
||||
|
||||
```python
|
||||
_DRUM_VELS = (0.30, 0.50, 0.80, 1.0)
|
||||
|
||||
|
||||
def _dhit(v):
|
||||
return None if v == 0.0 else (0, round(v, 2))
|
||||
|
||||
|
||||
def derive_drum_colors(base):
|
||||
"""Colours 2-6 from a 16-step drum base (None | (0, vel))."""
|
||||
n = len(base)
|
||||
busier = [base[i] if base[i] is not None else (_dhit(0.30) if i % 4 == 2 else None)
|
||||
for i in range(n)]
|
||||
broken = base[-1:] + base[:-1] # rotate right 1: same count, displaced
|
||||
double = [base[i] if base[i] is not None else (_dhit(0.80) if i % 2 == 0 else None)
|
||||
for i in range(n)]
|
||||
sparse = [base[i] if (base[i] is not None and ((base[i][1] >= 1.0) or i % 4 == 0)) else None
|
||||
for i in range(n)]
|
||||
fill = list(base)
|
||||
for k, v in enumerate((0.80, 0.80, 1.0, 1.0)):
|
||||
fill[n - 4 + k] = _dhit(v)
|
||||
return [busier, broken, double, sparse, fill]
|
||||
|
||||
|
||||
def derive_mel_colors(base):
|
||||
"""Colours 2-6 from a 16-step melodic base (None | (degree, vel))."""
|
||||
n = len(base)
|
||||
actives = [base[i][0] for i in range(n) if base[i] is not None]
|
||||
last_deg = actives[-1] if actives else 0
|
||||
|
||||
def prev_deg(i):
|
||||
for j in range(i, -1, -1):
|
||||
if base[j] is not None:
|
||||
return base[j][0]
|
||||
return last_deg
|
||||
|
||||
busier = [base[i] if base[i] is not None else ((prev_deg(i), 0.5) if i % 4 == 2 else None)
|
||||
for i in range(n)]
|
||||
broken = [((base[i][0] + 7, base[i][1]) if (base[i] is not None and i % 4 == 0) else base[i])
|
||||
for i in range(n)]
|
||||
double = [base[i] if base[i] is not None else ((prev_deg(i), 0.6) if i % 2 == 0 else None)
|
||||
for i in range(n)]
|
||||
sparse = [base[i] if (base[i] is not None and i % 4 == 0) else None for i in range(n)]
|
||||
fill = list(base)
|
||||
for k in range(4):
|
||||
fill[n - 4 + k] = (last_deg + k, round(0.6 + 0.1 * k, 2))
|
||||
return [busier, broken, double, sparse, fill]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it PASSES.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_contrast.py`
|
||||
Expected: `TEST PASS` (exit 0).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/patterns.py sound_algo/data_only/matrix_presets/test_contrast.py
|
||||
git commit -m "feat(presets): 6-colour contrast derivation fns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: wire into patterns_for + regenerate + validate
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/patterns.py` (`patterns_for` ~299-306)
|
||||
- Modify: `sound_algo/data_only/matrix_presets/*.matrix` (regenerated), `test_contrast.py` (add the wiring assertion)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes Task 1's `derive_drum_colors`/`derive_mel_colors`. Produces: `patterns_for` returns a contrasted `default` family (colour 1 base + derived 2-6) while leaving authored style families unchanged.
|
||||
|
||||
- [ ] **Step 1: Add the failing wiring assertion** to `test_contrast.py` (before the final `print`):
|
||||
|
||||
```python
|
||||
from patterns import patterns_for
|
||||
# A voice whose default family is used (acid in a non-psy preset uses MEL default).
|
||||
# default-derived: colours 2-6 are NOT all identical to colour 1.
|
||||
ac = patterns_for("acid", "techno_drive") # techno_drive maps mel-family default for acid
|
||||
check(ac is not None and len(ac) == 6, "patterns_for returns 6")
|
||||
check(ac[4] != ac[0] or ac[3] != ac[0], "default colours are contrasted (derived)")
|
||||
# kick default colour 5 (sparse) has <= hits than colour 1
|
||||
def dens2(p): return sum(1 for s in p if s is not None)
|
||||
kk = patterns_for("kick", "techno_drive")
|
||||
check(dens2(kk[4]) <= dens2(kk[0]), "kick sparse <= base")
|
||||
# an authored style family is NOT derived: acid in a psy preset keeps its hand-authored set
|
||||
# (psytrance_roll maps mel-family 'psy' for acid) -> colour list equals the raw MEL['acid']['psy']
|
||||
from patterns import MEL
|
||||
psy = patterns_for("acid", "psytrance_roll")
|
||||
check(psy == MEL["acid"]["psy"], "authored psy family untouched")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_contrast.py`
|
||||
Expected: `TEST FAIL` — `patterns_for` still returns the raw (low-contrast) default colours 2-6.
|
||||
|
||||
- [ ] **Step 3: Wire derivation into `patterns_for`.** Replace `patterns_for` (patterns.py ~299-306) with:
|
||||
|
||||
```python
|
||||
def patterns_for(voice, preset_name):
|
||||
"""Return 6 step-patterns (one per colour 1-6) for `voice` in `preset_name`,
|
||||
or None if the voice has no bank. The `default` family's colours 2-6 are
|
||||
derived from colour 1 for a consistent base/busier/broken/double/sparse/fill
|
||||
contrast; authored style families pass through unchanged."""
|
||||
dfam, mfam = PRESET_FAMILY.get(preset_name, ("default", "default"))
|
||||
if voice in DRUM_VOICES:
|
||||
rolebank = DRUMS.get(voice)
|
||||
if rolebank is None:
|
||||
return None
|
||||
fam = dfam if dfam in rolebank else "default"
|
||||
cols = [_rhythm(s) for s in rolebank[fam]]
|
||||
if fam == "default":
|
||||
cols = [cols[0]] + derive_drum_colors(cols[0])
|
||||
return cols
|
||||
rolebank = MEL.get(voice)
|
||||
if rolebank is None:
|
||||
return None
|
||||
fam = mfam if mfam in rolebank else "default"
|
||||
cols = rolebank[fam]
|
||||
if fam == "default":
|
||||
cols = [cols[0]] + derive_mel_colors(cols[0])
|
||||
return cols
|
||||
```
|
||||
(The old `_pick` helper is now unused by `patterns_for`; leave it if anything else imports it — grep `_pick`; if nothing else uses it, remove it.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it PASSES.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_contrast.py`
|
||||
Expected: `TEST PASS` (exit 0).
|
||||
|
||||
- [ ] **Step 5: Regenerate the 28 presets.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python generate_presets.py`
|
||||
Expected: 28 `.matrix` files written, no errors. (Determinism: a second run produces identical files.)
|
||||
|
||||
- [ ] **Step 6: SC load validation.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd`
|
||||
Expected: `VALIDATE PASS` (28/28 load). The derived `default` colours parse and play. (If the harness asserts specific preset values from a prior task, confirm they still hold — colour 1 is unchanged so prior DEFAULTS/voiceMods asserts are unaffected.)
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets
|
||||
git commit -m "feat(presets): contrasted default colours, regen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- 6-colour convention (base/busier/broken/double/sparse/fill) → Task 1 `derive_*` functions. ✓
|
||||
- Deterministic, no RNG → Task 1 (fixed rules) + determinism test. ✓
|
||||
- Applied to the `default` family of ALL 22 voices, keeping colour 1 → Task 2 `patterns_for` (`fam == "default"` branch, `[cols[0]] + derive(...)`). ✓
|
||||
- Authored style families untouched → Task 2 (`fam != "default"` passes through) + the "authored psy family untouched" assertion. ✓
|
||||
- Operate on parsed step lists, same shape → Task 1 functions take/return 16-step lists. ✓
|
||||
- Density/validity invariants → Task 1 tests. ✓
|
||||
- Regenerate + SC load → Task 2. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. The mel test base uses a single explicit `_mel([...])` assignment (no walrus/throwaway).
|
||||
|
||||
**Type consistency:** `derive_drum_colors`/`derive_mel_colors` take a 16-step list and return a list of 5 same-shape lists. `patterns_for` builds `[cols[0]] + derive(cols[0])` → 6 colours, matching the existing 6-per-voice contract consumed by `emit_colordefs`. Drum steps stay `None|(0,vel)`; mel steps stay `None|(degree,vel)`. The `fam == "default"` gate matches `_pick`'s fallback semantics (family present → use it; else default).
|
||||
@@ -0,0 +1,276 @@
|
||||
# Default pose→instrument mappings in presets — 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:** Bake a fixed, logical, role-adapted default pose→instrument modulation scheme into all 28 matrix presets (soloists→hands, structure→body), so the body controls instruments expressively out of the box.
|
||||
|
||||
**Architecture:** Pure content in the preset generator. Two helpers — `default_mods(voice)` and `default_poses(voice)` — return a voice's fixed bindings (from a per-role table, filtered by a Cycle-1 eligibility map). `emit_voicemods`/`emit_voiceposes` call them for each ACTIVE voice instead of the removed `*_DEMO` literals. No SC or web changes (the engine already supports these targets).
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`), SuperCollider validation harness (`validate_presets.scd`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: **uv** only.
|
||||
- No emojis in code/docs/commits.
|
||||
- Commits: subject ≤ 50 chars, body ≤ 72 chars/line, no AI attribution, no `--no-verify`, no underscore in commit scope.
|
||||
- Source/target IDs are SC symbols on the wire; emitted as `(source: \\x, target: \\y, depth: 0.NN)` Event literals (existing `emit_sc_mod_binding`).
|
||||
- Bindings MUST respect Cycle-1 target eligibility — never emit an ineligible binding:
|
||||
- `cutoff`: perc, acid, arp, lead, stab, pad, reese, melody, chord
|
||||
- `res`: sub, acid, arp, lead, stab, pad, reese, bells, melody, chord
|
||||
- `pitch`: sub, acid, arp, lead, stab, pad, reese, bells, perc, tom
|
||||
- `amp`, `pan`, `rev`: all voices
|
||||
- SC validation: `/Applications/SuperCollider.app/Contents/MacOS/sclang <harness>` (binary not on PATH; no `timeout` binary). Push only on user request.
|
||||
|
||||
## The fixed scheme (verbatim — soloists→hands, structure→body)
|
||||
|
||||
| Voices | Bindings (source, target, depth) |
|
||||
|--------|----------------------------------|
|
||||
| acid, arp, lead, stab | (rHandX,amp,0.4) (rHandY,pitch,0.4) (lHandY,cutoff,0.5) (lHandX,res,0.4) |
|
||||
| bells | (rHandX,amp,0.4) (rHandY,pitch,0.4) (lHandX,res,0.4) |
|
||||
| melody | (rHandX,amp,0.4) (lHandY,cutoff,0.5) (lHandX,res,0.4) |
|
||||
| reese | (bodyVitesse,cutoff,0.5) (bodyY,pitch,0.3) (limbSpan,res,0.4) (bodyX,pan,0.3) |
|
||||
| sub | (bodyVitesse,res,0.4) (bodyY,pitch,0.3) (bodyX,pan,0.3) |
|
||||
| pad, chord | (bodyVitesse,cutoff,0.4) (limbSpan,rev,0.4) (bodyX,pan,0.3) |
|
||||
| perc | (bodyVitesse,cutoff,0.4) (bodyY,pitch,0.3) (bodyX,pan,0.3) |
|
||||
| tom | (bodyY,pitch,0.3) (bodyVitesse,amp,0.3) (bodyX,pan,0.3) |
|
||||
| kick, hats, clap, ride, rim, snare, crash, shaker | (bodyVitesse,rev,0.3) (bodyX,pan,0.3) |
|
||||
| sweep, fx | (danse,rev,0.5) (bodyVitesse,amp,0.4) |
|
||||
|
||||
Poses: `danse→gate` on `fx` and `sweep` only; all other voices none.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix_presets/generate_presets.py` | add eligibility map + `_ROLE_MODS`/`_VOICE_ROLE` + `default_mods`/`default_poses`; rewire `emit_voicemods`/`emit_voiceposes`; remove `VOICE_MODS_DEMO`/`VOICE_POSES_DEMO` |
|
||||
| `sound_algo/data_only/matrix_presets/test_default_mods.py` | create — standalone assertion test (no pytest dep) |
|
||||
| `sound_algo/data_only/matrix_presets/*.matrix` | regenerated (28) |
|
||||
| `sound_algo/data_only/matrix_presets/validate_presets.scd` | extend — assert defaults loaded |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: generator helpers + wiring + Python test
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/generate_presets.py` (`VOICE_MODS_DEMO`/`VOICE_POSES_DEMO` ~518-529, `emit_voicemods`/`emit_voiceposes` ~542-567)
|
||||
- Create: `sound_algo/data_only/matrix_presets/test_default_mods.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `default_mods(voice) -> list[(source,target,depth)]`, `default_poses(voice) -> list[(poseId,action)]`, module-level `_MOD_TARGETS: dict[str,set[str]]`. `emit_voicemods`/`emit_voiceposes` now emit defaults for ACTIVE voices (`patterns_for(voice, preset) is not None`), `[]` otherwise.
|
||||
- Consumes: existing `VOICES`, `patterns_for`, `emit_sc_mod_binding`, `emit_sc_pose_binding`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `sound_algo/data_only/matrix_presets/test_default_mods.py`:
|
||||
|
||||
```python
|
||||
"""Standalone test for the default pose->instrument mapping scheme.
|
||||
Run: cd sound_algo/data_only/matrix_presets && uv run python test_default_mods.py
|
||||
Prints TEST PASS / TEST FAIL; exits non-zero on failure."""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from generate_presets import default_mods, default_poses, _MOD_TARGETS, VOICES
|
||||
|
||||
ok = True
|
||||
def check(cond, msg):
|
||||
global ok
|
||||
if not cond:
|
||||
ok = False
|
||||
print("FAIL:", msg)
|
||||
|
||||
# lead voices: the 4 hand bindings
|
||||
for v in ("acid", "arp", "lead", "stab"):
|
||||
tgts = [t for (_s, t, _d) in default_mods(v)]
|
||||
check(tgts == ["amp", "pitch", "cutoff", "res"], "%s lead bindings, got %s" % (v, tgts))
|
||||
|
||||
# bells: no cutoff; melody: no pitch
|
||||
check([t for (_s, t, _d) in default_mods("bells")] == ["amp", "pitch", "res"], "bells")
|
||||
check([t for (_s, t, _d) in default_mods("melody")] == ["amp", "cutoff", "res"], "melody")
|
||||
|
||||
# sub: res (not cutoff) + pitch + pan ; reese: cutoff+pitch+res+pan
|
||||
check([t for (_s, t, _d) in default_mods("sub")] == ["res", "pitch", "pan"], "sub")
|
||||
check([t for (_s, t, _d) in default_mods("reese")] == ["cutoff", "pitch", "res", "pan"], "reese")
|
||||
|
||||
# perc: cutoff+pitch+pan (no res) ; tom: pitch+amp+pan (no res/cutoff)
|
||||
check([t for (_s, t, _d) in default_mods("perc")] == ["cutoff", "pitch", "pan"], "perc")
|
||||
check([t for (_s, t, _d) in default_mods("tom")] == ["pitch", "amp", "pan"], "tom")
|
||||
|
||||
# drums: body rev+pan ; fx: danse rev + body amp
|
||||
check(default_mods("kick") == [("bodyVitesse", "rev", 0.3), ("bodyX", "pan", 0.3)], "kick")
|
||||
check([s for (s, _t, _d) in default_mods("fx")] == ["danse", "bodyVitesse"], "fx sources")
|
||||
|
||||
# ELIGIBILITY INVARIANT: every emitted binding's target is eligible for its voice
|
||||
for v in VOICES:
|
||||
for (_s, t, _d) in default_mods(v):
|
||||
check(t in _MOD_TARGETS[v], "ineligible %s->%s on %s" % (_s, t, v))
|
||||
|
||||
# poses: danse->gate on fx/sweep only
|
||||
check(default_poses("fx") == [("danse", "gate")], "fx pose")
|
||||
check(default_poses("sweep") == [("danse", "gate")], "sweep pose")
|
||||
check(default_poses("kick") == [], "kick no pose")
|
||||
|
||||
print("TEST PASS" if ok else "TEST FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_default_mods.py`
|
||||
Expected: ImportError / `TEST FAIL` — `default_mods`, `default_poses`, `_MOD_TARGETS` don't exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the eligibility map + scheme + helpers.** In `generate_presets.py`, REPLACE the `VOICE_MODS_DEMO` and `VOICE_POSES_DEMO` blocks (~518-529) with:
|
||||
|
||||
```python
|
||||
# -- Default pose->instrument mapping scheme (fixed, role-adapted) --
|
||||
# Eligibility mirrors the SC engine ~matModTargets (cycle 1): a binding is only
|
||||
# emitted if its target is valid for that voice.
|
||||
_MOD_TARGETS = {v: {"amp", "pan", "rev"} for v in VOICES}
|
||||
for v in ("perc", "acid", "arp", "lead", "stab", "pad", "reese", "melody", "chord"):
|
||||
_MOD_TARGETS[v].add("cutoff")
|
||||
for v in ("sub", "acid", "arp", "lead", "stab", "pad", "reese", "bells", "melody", "chord"):
|
||||
_MOD_TARGETS[v].add("res")
|
||||
for v in ("sub", "acid", "arp", "lead", "stab", "pad", "reese", "bells", "perc", "tom"):
|
||||
_MOD_TARGETS[v].add("pitch")
|
||||
|
||||
# Per-role default bindings (source, target, depth). Soloists -> hand sources
|
||||
# (expressive, close-up); energy/structure -> body sources (robust at distance).
|
||||
_ROLE_MODS = {
|
||||
"lead": [("rHandX", "amp", 0.4), ("rHandY", "pitch", 0.4), ("lHandY", "cutoff", 0.5), ("lHandX", "res", 0.4)],
|
||||
"bells": [("rHandX", "amp", 0.4), ("rHandY", "pitch", 0.4), ("lHandX", "res", 0.4)],
|
||||
"melody": [("rHandX", "amp", 0.4), ("lHandY", "cutoff", 0.5), ("lHandX", "res", 0.4)],
|
||||
"reese": [("bodyVitesse", "cutoff", 0.5), ("bodyY", "pitch", 0.3), ("limbSpan", "res", 0.4), ("bodyX", "pan", 0.3)],
|
||||
"sub": [("bodyVitesse", "res", 0.4), ("bodyY", "pitch", 0.3), ("bodyX", "pan", 0.3)],
|
||||
"pad": [("bodyVitesse", "cutoff", 0.4), ("limbSpan", "rev", 0.4), ("bodyX", "pan", 0.3)],
|
||||
"perc": [("bodyVitesse", "cutoff", 0.4), ("bodyY", "pitch", 0.3), ("bodyX", "pan", 0.3)],
|
||||
"tom": [("bodyY", "pitch", 0.3), ("bodyVitesse", "amp", 0.3), ("bodyX", "pan", 0.3)],
|
||||
"drums": [("bodyVitesse", "rev", 0.3), ("bodyX", "pan", 0.3)],
|
||||
"fx": [("danse", "rev", 0.5), ("bodyVitesse", "amp", 0.4)],
|
||||
}
|
||||
_VOICE_ROLE = {
|
||||
"acid": "lead", "arp": "lead", "lead": "lead", "stab": "lead",
|
||||
"bells": "bells", "melody": "melody",
|
||||
"reese": "reese", "sub": "sub",
|
||||
"pad": "pad", "chord": "pad",
|
||||
"perc": "perc", "tom": "tom",
|
||||
"kick": "drums", "hats": "drums", "clap": "drums", "ride": "drums",
|
||||
"rim": "drums", "snare": "drums", "crash": "drums", "shaker": "drums",
|
||||
"sweep": "fx", "fx": "fx",
|
||||
}
|
||||
|
||||
|
||||
def default_mods(voice):
|
||||
"""Fixed default mod bindings for a voice, filtered by target eligibility."""
|
||||
role = _VOICE_ROLE.get(voice)
|
||||
if role is None:
|
||||
return []
|
||||
elig = _MOD_TARGETS.get(voice, {"amp", "pan", "rev"})
|
||||
return [(s, t, d) for (s, t, d) in _ROLE_MODS[role] if t in elig]
|
||||
|
||||
|
||||
def default_poses(voice):
|
||||
"""Fixed default pose bindings: danse->gate swells the FX voices."""
|
||||
return [("danse", "gate")] if voice in ("fx", "sweep") else []
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewire `emit_voicemods` and `emit_voiceposes`** to use the defaults for ACTIVE voices. Replace both functions (~542-567):
|
||||
|
||||
```python
|
||||
def emit_voicemods(preset_name):
|
||||
"""22-element list of SC sub-arrays: default scheme for active voices, else []."""
|
||||
parts = []
|
||||
for voice in VOICES:
|
||||
active = patterns_for(voice, preset_name) is not None
|
||||
bindings = default_mods(voice) if active else []
|
||||
if bindings:
|
||||
parts.append("[" + ", ".join(emit_sc_mod_binding(*b) for b in bindings) + "]")
|
||||
else:
|
||||
parts.append("[]")
|
||||
return parts
|
||||
|
||||
|
||||
def emit_voiceposes(preset_name):
|
||||
"""22-element list of SC sub-arrays: default poses for active voices, else []."""
|
||||
parts = []
|
||||
for voice in VOICES:
|
||||
active = patterns_for(voice, preset_name) is not None
|
||||
bindings = default_poses(voice) if active else []
|
||||
if bindings:
|
||||
parts.append("[" + ", ".join(emit_sc_pose_binding(*b) for b in bindings) + "]")
|
||||
else:
|
||||
parts.append("[]")
|
||||
return parts
|
||||
```
|
||||
(`patterns_for` is the same active-voice signal `emit_colordefs` uses — `None` ⇒ the voice is silent in this preset ⇒ no mappings.)
|
||||
|
||||
- [ ] **Step 5: Run to verify it PASSES.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_default_mods.py`
|
||||
Expected: `TEST PASS` (exit 0).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_default_mods.py
|
||||
git commit -m "feat(presets): default pose-instrument mod scheme"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: regenerate presets + SC load validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/*.matrix` (regenerated), `sound_algo/data_only/matrix_presets/validate_presets.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's generator. Produces: 28 regenerated `.matrix` files carrying the default `voiceMods`/`voicePoses`.
|
||||
|
||||
- [ ] **Step 1: Regenerate the 28 presets.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python generate_presets.py`
|
||||
Expected: 28 `.matrix` files written, no errors.
|
||||
|
||||
- [ ] **Step 2: Spot-check the emitted defaults.**
|
||||
|
||||
Run: `grep -c "voiceMods" sound_algo/data_only/matrix_presets/*.matrix | grep -c ":1"` (28 files each with the key) and `grep -o "(source: \\\\rHandX, target: \\\\amp[^)]*)" sound_algo/data_only/matrix_presets/techno_drive.matrix | head -1`
|
||||
Expected: the lead hand binding present in a preset that has a lead/acid voice; voiceMods key in all 28.
|
||||
|
||||
- [ ] **Step 3: Extend `validate_presets.scd`** to assert the defaults loaded. After the existing 28/28 load loop, add (matching the harness's style — it already stubs `~toscSend`, loads `../../matrix.scd`, loops presets):
|
||||
|
||||
```supercollider
|
||||
// assert default mappings landed: load techno_drive, check a lead voice has its
|
||||
// 4 hand bindings and a drum voice has its 2 body bindings.
|
||||
~matLoad.("techno_drive");
|
||||
~vpass = (~matVoiceMods[\acid].size == 4)
|
||||
and: { ~matVoiceMods[\acid].any({ |b| b[\target] == \pitch }) }
|
||||
and: { ~matVoiceMods[\kick].size == 2 }
|
||||
and: { ~matVoiceMods[\kick][0][\source] == \bodyVitesse };
|
||||
~vpass.if({ "DEFAULTS PASS".postln }, { "DEFAULTS FAIL".postln });
|
||||
```
|
||||
(Use the actual voice that `techno_drive` activates for a lead — if `acid` is inactive there, pick an active lead/melodic voice; confirm via the generated file. If `acid` has no steps in techno_drive, its voiceMods is `[]` by the active-voice filter — choose a voice the preset plays.)
|
||||
|
||||
- [ ] **Step 4: Run the validation.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd`
|
||||
Expected: `VALIDATE PASS` (28/28 load) AND `DEFAULTS PASS`. If the lead voice chosen is inactive in techno_drive, the harness will reveal a `[]` — switch to an active voice and re-run.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets
|
||||
git commit -m "feat(presets): regenerate with default mappings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- Fixed role-adapted scheme (soloists→hands, structure→body) → Task 1 `_ROLE_MODS`/`_VOICE_ROLE`. ✓
|
||||
- Eligibility filter (no invalid binding) → Task 1 `_MOD_TARGETS` + `default_mods` filter + the test's eligibility invariant. ✓
|
||||
- Active-voice-only emission → Task 1 `emit_*` use `patterns_for(...) is not None`. ✓
|
||||
- DEMO dicts removed → Task 1 Step 3 replaces them. ✓
|
||||
- Default poses (danse→gate on fx/sweep) → Task 1 `default_poses`. ✓
|
||||
- Regenerate + SC load validation → Task 2. ✓
|
||||
- No engine/web change → confirmed (only generator + its test + regenerated data). ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. Task 2 Step 3/4 flags the active-voice contingency (pick a lead voice the preset actually plays) with a concrete remedy rather than leaving it vague.
|
||||
|
||||
**Type consistency:** `default_mods` returns `(source,target,depth)` tuples consumed by `emit_sc_mod_binding(*b)` (matches its `(source,target,depth)` signature). `default_poses` returns `(poseId,action)` for `emit_sc_pose_binding(*b)`. `_MOD_TARGETS` sets mirror the Global Constraints eligibility lists exactly. The test's expected target-order matches `_ROLE_MODS` insertion order (lead: amp,pitch,cutoff,res; sub: res,pitch,pan; etc.).
|
||||
@@ -0,0 +1,466 @@
|
||||
# Matrix Instrument + Colour Editor Refactor — 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:** Replace the tangled per-voice instrument + per-(voice,colour) modal editor with an inline-accordion, tabbed voice editor (adaptive pitch/drum step sequencer + synth/FX tab), split `control.js` into ES modules, and extend the SC engine with per-colour `res`/`rev`.
|
||||
|
||||
**Architecture:** Three phases. **A** extends `matrix.scd` (res/rev colour-def fields) — backend, testable headless. **B** splits `control.js` (1151 lines) into ES modules with **behaviour unchanged** (the old modal still works) — pure refactor, verifiable by smoke test. **C** replaces the modal with the inline accordion + tabs + adaptive step editor + synth/FX tab. Each phase ships working software.
|
||||
|
||||
**Tech Stack:** Vanilla JS ES modules (no bundler/framework/build step), Node `--test` for pure-logic unit tests, Playwright (Chrome) for browser smoke, SuperCollider (`sclang`) headless for engine tests. WebSocket→OSC bridge via `web_realart/server.js`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Vanilla **ES modules only** — no bundler, no framework, no build step. `index.html` loads `<script type="module" src="js/main.js">`.
|
||||
- **OSC protocol addresses unchanged**. Only the `colordef` field set gains `res`/`rev` values. SC side stays backward-compatible (missing fields → nil).
|
||||
- **localStorage key `avlive.matrix` shape preserved**: `{grid, inst, cdef}`; colour defs may gain optional `res`/`rev` (back-filled to `null` on load).
|
||||
- **22 voices × 64 bars** (`MATRIX_BARS = 64`). Fix stale "16×32" comments.
|
||||
- **Single colour palette source of truth**: `['#c22','#1a8','#25b','#990','#c60','#82a']` (m1..m6).
|
||||
- Code, comments, commits, docs in **English**.
|
||||
- Commits: subject ≤ 50 chars, body ≤ 72/line, **no AI attribution**, **no underscore in scope**, never `--no-verify` (git hooks enforce).
|
||||
- SC convention: env vars `~lowercase`; `.load` files one top-level block; balance parens (P:0 B:0).
|
||||
- Run SC with `/Applications/SuperCollider.app/Contents/MacOS/sclang`.
|
||||
- Work on `main` (consolidated at `04ff9ce`). The user iterates in parallel — coordinate before large edits.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Engine extension (res / rev)
|
||||
|
||||
### Task A1: Add per-colour `res` and `rev` to the matrix engine
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` (`~matDefaultColorDefs` ~l.48-56; `~matVariationOverlay` ~l.178-213; `~matColorStepPattern` ~l.218-250; `~matLoadFile` field-copy ~l.629; `~matSave` ~l.584)
|
||||
- Test: `sound_algo/data_only/test/test_matrix.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: colour-def Events now carry `res` (Float|nil) and `rev` (Float 0..1|nil). Pushed to the Pbind as `\res`/`\rev` when non-nil and the voice supports them. OSC `/matrix/colordef vi colour \res value` and `\rev` accepted (no new address). `/matrix/colordefs` bulk push gains two trailing fields per colour.
|
||||
- Consumes: existing SynthDef args `res`, `rev` (verified present on `tb303, ms20, minimoog, supersaw, juno, lp_acid, lp_lead, lp_pad`).
|
||||
|
||||
- [ ] **Step 1: Add a res-capable voice set near the top of matrix.scd**
|
||||
|
||||
After `~matModNeutralCut` (~l.45), add:
|
||||
|
||||
```supercollider
|
||||
// voices whose SynthDef has a resonant filter (res param). rev (reverb send)
|
||||
// is accepted by all lp_/classic synths, so it is not gated by voice.
|
||||
~matResVoices = ~matResVoices ? [\sub, \acid, \arp, \lead, \stab, \pad, \reese, \bells, \melody, \chord];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add res/rev to default colour defs**
|
||||
|
||||
In `~matDefaultColorDefs` (~l.50-55), add `res: nil, rev: nil,` to each of the six colour Events (after `pan: nil,`). Example for colour 1:
|
||||
|
||||
```supercollider
|
||||
(stretch: 1.0, octave: 0, amp: 1.0, inst: nil, cutoff: nil, pan: nil, res: nil, rev: nil, steps: Array.fill(16, nil)),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Push \res and \rev in the overlay variation**
|
||||
|
||||
In `~matVariationOverlay`, where `cdPairs` is assembled (the `cutoff`/`pan` block ~l.270-273 in `~matVariation`; mirror it inside the overlay's `cdPairs`), add after the pan push:
|
||||
|
||||
```supercollider
|
||||
(spec[\res].notNil and: { ~matResVoices.includes(name) }).if({
|
||||
cdPairs = cdPairs ++ [\res, spec[\res]] });
|
||||
(spec[\rev].notNil).if({
|
||||
cdPairs = cdPairs ++ [\rev, spec[\rev]] });
|
||||
```
|
||||
|
||||
(Place this in the same scope that builds `cdPairs` for both overlay and step paths — in `~matVariation` ~l.269-273, so both render modes inherit it.)
|
||||
|
||||
- [ ] **Step 4: Restore res/rev on load**
|
||||
|
||||
In `~matLoadFile`, the per-colour field copy loop (~l.629) currently lists `[\stretch, \octave, \amp, \inst, \cutoff, \pan]`. Change to:
|
||||
|
||||
```supercollider
|
||||
[\stretch, \octave, \amp, \inst, \cutoff, \pan, \res, \rev].do { |f|
|
||||
d[f].notNil.if({ ~matColorDefs[vi][ci + 1][f] = d[f] })
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Include res/rev in the colordefs OSC push**
|
||||
|
||||
Find `~matColorDefPush` (~l.499-508). It flattens per-colour fields to `/matrix/colordefs`. Add `res` and `rev` to the flattened field list (append after `pan`), keeping the receiver (`control.js`) arg layout in sync — document the new per-colour stride in a comment (was 6 scalars: stretch,octave,amp,cutoff,pan,+? → now include res,rev). Quote the exact existing layout before editing and extend it by two.
|
||||
|
||||
- [ ] **Step 6: Add test assertions**
|
||||
|
||||
In `test/test_matrix.scd`, after the existing colordef test, add:
|
||||
|
||||
```supercollider
|
||||
// res/rev round-trip through a colour def
|
||||
~matColorDefs[5][1][\res] = 0.7; // acid, colour 1
|
||||
~matColorDefs[5][1][\rev] = 0.3;
|
||||
variation = ~matVariation.(\acid, 1, 5);
|
||||
pass = pass and: { variation.notNil and: { variation.isKindOf(Pattern) } };
|
||||
// stream one event, must not raise
|
||||
try { variation.asStream.next(()) } { |e| pass = false;
|
||||
("EXCEPTION res/rev stream: " ++ e.errorString).postln };
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the engine test**
|
||||
|
||||
Run: `cd sound_algo/data_only && /Applications/SuperCollider.app/Contents/MacOS/sclang test/test_matrix.scd 2>&1 | grep -iE "TEST PASS|FAIL|ERROR"`
|
||||
Expected: `TEST PASS`, no ERROR lines.
|
||||
|
||||
- [ ] **Step 8: Re-validate the 28 presets against the extended engine**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /private/tmp/.../scratchpad/validate_presets.scd 2>&1 | grep -iE "Loaded OK|PASS|FAIL"` (or recreate the validator: interpret + `~matLoadFile` each `matrix_presets/*.matrix`, assert 28/28 load).
|
||||
Expected: `Loaded OK via ~matLoadFile: 28 / 28`, `VALIDATION PASS`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
|
||||
git commit -m "feat: per-colour res and rev in matrix engine"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Modularisation (behaviour unchanged)
|
||||
|
||||
Goal of Phase B: `control.js` becomes `js/main.js` importing focused modules; the **UI and behaviour are byte-for-byte equivalent** (old modal still works). No feature change. Verify by browser smoke after each extraction. Order: most-isolated first.
|
||||
|
||||
### Task B1: Bootstrap ES modules + OSC module with dispatch registry
|
||||
|
||||
**Files:**
|
||||
- Create: `web_realart/public/control/js/osc.js`
|
||||
- Create: `web_realart/public/control/js/main.js`
|
||||
- Modify: `web_realart/public/control/index.html` (l.190: `<script src="control.js">` → `<script type="module" src="js/main.js">`)
|
||||
- Modify: `web_realart/public/control/control.js` → moved verbatim to `js/main.js` initially, minus the WS plumbing (which moves to osc.js).
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`osc.js`): `export function send(address, ...args)`; `export function on(address, handler)` registers a handler `(args:Array)=>void`; `export function onOpen(fn)`; internal single `ws` dispatches incoming `{address,args}` to all registered handlers for that address. `send` JSON-encodes `{address,args}`.
|
||||
|
||||
- [ ] **Step 1: Write osc.js**
|
||||
|
||||
```js
|
||||
// js/osc.js — WebSocket↔OSC bridge with an address dispatch registry.
|
||||
const ws = new WebSocket(`ws://${location.host}`);
|
||||
const handlers = new Map(); // address -> Set<fn(args)>
|
||||
const openFns = [];
|
||||
|
||||
export function on(address, fn) {
|
||||
if (!handlers.has(address)) handlers.set(address, new Set());
|
||||
handlers.get(address).add(fn);
|
||||
}
|
||||
export function onOpen(fn) { openFns.push(fn); }
|
||||
export function send(address, ...args) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ address, args }));
|
||||
}
|
||||
ws.addEventListener("open", () => openFns.forEach((f) => f()));
|
||||
ws.addEventListener("message", (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data); } catch (_e) { return; }
|
||||
const set = handlers.get(msg.address);
|
||||
if (set) for (const fn of set) fn(msg.args || []);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move control.js → js/main.js and rewire transport**
|
||||
|
||||
`git mv web_realart/public/control/control.js web_realart/public/control/js/main.js`. At the top of `main.js` add `import { send, on, onOpen } from "./osc.js";`. Delete the old `ws`/`send`/`dot` declarations (l.1-7) and the `ws.addEventListener("message", …)` mega-switch (l.34-286): replace each `if (address === "/x") { … }` branch with `on("/x", (args) => { … })` registrations (mechanical: the branch body becomes the handler body; `args[i]` references already match). Replace the `ws.addEventListener("open", …)` with `onOpen(() => send("/matrix/list"))`.
|
||||
|
||||
- [ ] **Step 3: Point index.html at the module**
|
||||
|
||||
`index.html` l.190: replace `<script src="control.js"></script>` with `<script type="module" src="js/main.js"></script>`. The `matrix_glow.js` import line (l.189) stays.
|
||||
|
||||
- [ ] **Step 4: Browser smoke test**
|
||||
|
||||
Run the bridge (`cd web_realart && node server.js`) with SC booted (or stub), open `http://localhost:4400/control/`. Verify: page loads with no console errors, tabs switch, matrix grid renders, clicking a cell sends `/matrix/cell` (check Network/console), the colordef modal still opens. Expected: identical behaviour to before.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "refactor: split osc transport into es module"
|
||||
```
|
||||
|
||||
### Task B2: Extract matrix-state.js (data, palette, persistence, classification)
|
||||
|
||||
**Files:**
|
||||
- Create: `web_realart/public/control/js/matrix-state.js`
|
||||
- Modify: `js/main.js` (remove the moved declarations; import from matrix-state)
|
||||
- Test: `web_realart/test/matrix-state.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `export const MATRIX_VOICES` (22), `MATRIX_BARS = 64`, `MATRIX_INST_CHOICES`, `MATRIX_MOD_SOURCES`, `MATRIX_MOD_TARGETS`, `PALETTE` (`['#c22','#1a8','#25b','#990','#c60','#82a']`), `VOICE_CLASS` (`{kick:'drum', acid:'melodic', perc:'semi', …}`), `mkColor()`, `matGrid`, `matInst`, `matColorDefs` (exported as mutable via accessor object `state`), `saveMatState()`, `loadMatState()`, `isMelodic(voice)`, `defaultStepMode(voice)` (`'pitch'|'drum'`).
|
||||
|
||||
- [ ] **Step 1: Write the failing unit test**
|
||||
|
||||
```js
|
||||
// web_realart/test/matrix-state.test.mjs
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { PALETTE, VOICE_CLASS, defaultStepMode, MATRIX_VOICES, MATRIX_BARS } from "../public/control/js/matrix-state.js";
|
||||
|
||||
test("palette is the single 6-colour source", () => {
|
||||
assert.deepEqual(PALETTE, ["#c22","#1a8","#25b","#990","#c60","#82a"]);
|
||||
});
|
||||
test("dimensions are 22x64", () => {
|
||||
assert.equal(MATRIX_VOICES.length, 22);
|
||||
assert.equal(MATRIX_BARS, 64);
|
||||
});
|
||||
test("step-mode defaults by voice class", () => {
|
||||
assert.equal(defaultStepMode("acid"), "pitch");
|
||||
assert.equal(defaultStepMode("kick"), "drum");
|
||||
assert.equal(defaultStepMode("perc"), "drum"); // semi defaults to drum
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify it fails**
|
||||
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: FAIL — cannot import (module not created yet).
|
||||
|
||||
- [ ] **Step 3: Create matrix-state.js**
|
||||
|
||||
Move from `main.js` l.288-367 verbatim: `MATRIX_VOICES`, `MATRIX_INST_CHOICES`, `MATRIX_MOD_SOURCES/TARGETS`, `matInst`, `matColorDefs`, `mkColor`, `matGrid`, `MATRIX_BARS`, `saveMatState`, `loadMatState` (drop `cellRefs`/`matPlayhead`/`cdEditVoice` — those stay UI-side). Add and export:
|
||||
|
||||
```js
|
||||
export const PALETTE = ["#c22","#1a8","#25b","#990","#c60","#82a"];
|
||||
export const VOICE_CLASS = {
|
||||
kick:"drum", hats:"drum", clap:"drum", perc:"semi", sub:"melodic", acid:"melodic",
|
||||
arp:"melodic", lead:"melodic", stab:"melodic", pad:"melodic", ride:"drum", rim:"drum",
|
||||
tom:"semi", reese:"melodic", bells:"melodic", sweep:"drum", melody:"melodic",
|
||||
chord:"melodic", fx:"drum", snare:"drum", crash:"drum", shaker:"drum",
|
||||
};
|
||||
export function isMelodic(v) { return VOICE_CLASS[v] === "melodic"; }
|
||||
export function defaultStepMode(v) { return VOICE_CLASS[v] === "melodic" ? "pitch" : "drum"; }
|
||||
```
|
||||
Make the mutable arrays exported `let` with named exports, or wrap in an exported `state` object — pick the pattern `main.js` can consume cleanly. Add `res:null, rev:null` to `mkColor()`'s returned shape, and back-fill them in `loadMatState` alongside steps/pose/mod.
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it passes**
|
||||
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Import into main.js, smoke test browser**
|
||||
|
||||
Replace moved declarations in `main.js` with `import { … } from "./matrix-state.js";`. Browser smoke (as B1 Step 4): identical behaviour.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/public/control/js/main.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "refactor: extract matrix state and palette module"
|
||||
```
|
||||
|
||||
### Task B3: Single palette via CSS custom properties
|
||||
|
||||
**Files:**
|
||||
- Modify: `web_realart/public/control/control.css` (`.mcell.m1..m6` l.109-114; `.cd-row.m*`/`.seq-row.m*` l.178-192)
|
||||
- Modify: `js/matrix-state.js` (write CSS vars from `PALETTE` at load)
|
||||
|
||||
**Interfaces:** Consumes `PALETTE` from matrix-state. Produces CSS vars `--m1..--m6` on `:root`.
|
||||
|
||||
- [ ] **Step 1: Emit CSS vars from PALETTE**
|
||||
|
||||
In `matrix-state.js`, add and call once on import:
|
||||
|
||||
```js
|
||||
export function installPaletteVars() {
|
||||
const r = document.documentElement.style;
|
||||
PALETTE.forEach((c, i) => r.setProperty(`--m${i + 1}`, c));
|
||||
}
|
||||
installPaletteVars();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace hard-coded colours in CSS with vars**
|
||||
|
||||
`control.css`: change `.mcell.m1 { background:#c22; border-color:#e44; }` … to `.mcell.m1 { background:var(--m1); border-color:var(--m1); filter:brightness(1.2); }` for m1..m6 (keep a readable border via `brightness`). Do the same for `.cd-row.m*` / `.seq-row.mN .seq-cell.on`. Remove the now-duplicate hex values.
|
||||
|
||||
- [ ] **Step 3: Remove the JS COLORS array**
|
||||
|
||||
In the (now in voice-editor / still in main) `renderColorSel`, replace the local `const COLORS = [...]` with `import { PALETTE } from "./matrix-state.js"` usage. (If still in main.js at this point, import there.)
|
||||
|
||||
- [ ] **Step 4: Browser smoke** — cells m1..m6 and the colour selector render with identical colours. Expected: visually unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "refactor: single colour palette via css vars"
|
||||
```
|
||||
|
||||
### Task B4: Extract mixer.js, transport.js, scenes.js, sequencer.js, launchpad.js
|
||||
|
||||
**Files:**
|
||||
- Create: `js/mixer.js`, `js/transport.js`, `js/scenes.js`, `js/sequencer.js`, `js/launchpad.js`
|
||||
- Modify: `js/main.js` (import + call each module's `init()`)
|
||||
|
||||
**Interfaces:** Each module exports `export function init()` that renders its DOM and registers its own `on(...)` OSC handlers and DOM event listeners, importing `send/on` from `./osc.js` and shared data from `./matrix-state.js`. No module reaches into another's internals.
|
||||
|
||||
- [ ] **Step 1: Extract mixer.js** — move `mixerLevel`, `mixerMuted`, `renderMixer` (main.js l.711-772) + its `/launch/vol` sends; expose `export function init()`. (Most isolated — own state.)
|
||||
- [ ] **Step 2: Extract transport.js** — beat/rms/tempo handlers (l.54-74) + quant/filter buttons; register `on("/sync/beat")`, `on("/sync/rms")`.
|
||||
- [ ] **Step 3: Extract scenes.js** — `sceneMode`, `setSceneMode`, `/scene/state` handler, `[data-scene]` wiring (l.120-129, 1093-1111).
|
||||
- [ ] **Step 4: Extract sequencer.js** — rhythm+melody editors (l.76-118, 830-973, 1011-1027, 1068-1091) + its `avlive.seq` persistence + `/seq/*` handlers.
|
||||
- [ ] **Step 5: Extract launchpad.js** — `armed`, `clipState`, `togglePad`, `/armed/` handler, `[data-pad]` wiring (l.8-28, 42-52, 1029-1066).
|
||||
- [ ] **Step 6: Wire in main.js** — `import` each, call `init()` in the `DOMContentLoaded` bootstrap. Keep tab switching + `[data-osc]` generic buttons in main.js.
|
||||
- [ ] **Step 7: Browser smoke** — every tab and control works identically (mixer faders, transport, scenes, sequencer pads, launchpad clips).
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "refactor: extract mixer transport scenes seq pads"
|
||||
```
|
||||
|
||||
### Task B5: Extract matrix-grid.js and voice-editor.js (modal as-is)
|
||||
|
||||
**Files:**
|
||||
- Create: `js/matrix-grid.js` (grid render, `applyMatCellColor`, `cellRefs`, glow, timeline — l.369-448, 610-709, 774-828)
|
||||
- Create: `js/voice-editor.js` (the current modal: `renderColorSel`, `renderPattern`, `openColorDef`, `closeColorDef`, `setCD` + `cdEditVoice/cdColor/cdAuditioning` + the `/matrix/colordefs|steps|colormod|colorpose` handlers — l.450-608, 235-285)
|
||||
- Modify: `js/main.js` (import + init; main.js is now just bootstrap + tabs + generic buttons)
|
||||
|
||||
**Interfaces:**
|
||||
- `matrix-grid.js`: `export function renderMatrix()`, `export function init()`, `export function triggerGlow(...)`; owns `cellRefs`, `matPlayhead`. `renderMatrix` calls `voiceEditor.openFor(vi)` on the edit button (import).
|
||||
- `voice-editor.js`: `export function init()`, `export function openFor(vi)`, `export function isOpenFor(vi)` (replaces the `!getElementById('colordef-modal').hidden` checks). Registers its own OSC handlers.
|
||||
|
||||
- [ ] **Step 1: Extract matrix-grid.js** with the exports above; replace the inbound matrix handlers it needs (`/matrix/cell`, `/matrix/grid`, `/matrix/instrument(s)`) with `on(...)` registrations inside its `init()`.
|
||||
- [ ] **Step 2: Extract voice-editor.js** moving the modal functions verbatim; replace the 5 modal-DOM open-checks with `isOpenFor(vi)`; register `/matrix/colordefs|steps|colormod|colorpose` here.
|
||||
- [ ] **Step 3: main.js final shape** — imports osc, matrix-state, and every module; bootstrap calls each `init()`; retains tab switching + `[data-osc]` wiring only. Confirm `main.js` is now < ~120 lines.
|
||||
- [ ] **Step 4: Full browser smoke** — grid, playhead, glow, timeline drag, mixer, and the **modal editor** (open, colour select, steps, mod, pose, audition) all behave identically.
|
||||
- [ ] **Step 5: Keep matrix_glow test green** — Run: `cd web_realart && node --test test/matrix_glow.test.mjs` → PASS.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "refactor: extract matrix grid and voice editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — New editor UX
|
||||
|
||||
Phase C changes behaviour. After B, `voice-editor.js` is the only file that defines the editor — all C work is contained there + `index.html` markup + `control.css`.
|
||||
|
||||
### Task C1: Inline accordion (replace the modal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `index.html` (remove `#colordef-modal` block l.180-188; the editor now renders into an expandable row inside `#matrix-grid`)
|
||||
- Modify: `js/matrix-grid.js` (grid row gains the unified instrument dropdown + a `▷` expand toggle; renders an editor container row under the expanded voice)
|
||||
- Modify: `js/voice-editor.js` (`openFor(vi)` renders into the inline container, not the modal; track single `expandedVoice`)
|
||||
- Modify: `control.css` (drop `.cd-modal`/`.cd-panel`; add `.voice-editor-row`, `.voice-expand-btn`)
|
||||
|
||||
**Interfaces:** `voice-editor.js`: `openFor(vi, containerEl)` renders into `containerEl`; `collapse()` clears it; only one voice expanded (opening another collapses prior). The per-voice instrument dropdown stays in the grid row (single source) and is the only instrument selector.
|
||||
|
||||
- [ ] **Step 1: Grid row markup** — in `renderMatrix`, after the instrument dropdown add a `<button class="voice-expand-btn">▷</button>`; below each voice row, an empty `<div class="voice-editor-row" hidden>`. Clicking the button (or label) calls `voiceEditor.toggle(vi, rowEl)`.
|
||||
- [ ] **Step 2: voice-editor toggle** — `toggle(vi, el)`: if `expandedVoice === vi` collapse; else collapse previous, set `expandedVoice = vi`, render editor into `el`, fetch state (`send("/matrix/colordefs/get", vi)` etc.).
|
||||
- [ ] **Step 3: Remove modal** — delete `#colordef-modal` from index.html; delete `openColorDef`/`closeColorDef` modal-specific DOM code; the colour selector + AUDITION render at the top of the inline editor.
|
||||
- [ ] **Step 4: CSS** — `.voice-editor-row { background:#141414; border-left:3px solid; padding:8px; }`; `.voice-expand-btn` rotates ▷→▼ on expand.
|
||||
- [ ] **Step 5: Browser smoke** — expanding a voice shows the editor inline; grid stays visible; opening another voice collapses the first; instrument dropdown drives `/matrix/instrument`; all colour/step/mod/pose edits still send correct OSC.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "feat: inline accordion voice editor"
|
||||
```
|
||||
|
||||
### Task C2: Tabs inside the editor (STEPS / SYNTH-FX / MOD-POSE)
|
||||
|
||||
**Files:** Modify `js/voice-editor.js`, `control.css`.
|
||||
|
||||
**Interfaces:** editor renders a tab bar; `activeTab` per editor instance (default `steps`). Tab render functions: `renderStepsTab(vi)`, `renderSynthFxTab(vi)`, `renderModPoseTab(vi)`.
|
||||
|
||||
- [ ] **Step 1: Tab bar** — three buttons (STEPS, SYNTH/FX, MOD/POSE) above the colour selector; clicking sets `activeTab` and re-renders the tab body. Reuse existing `.tab-btn`/`.tab-btn.active` styles.
|
||||
- [ ] **Step 2: Split current renderPattern** — move the 16-step sub-editor into `renderStepsTab`; the var-row (stretch/oct/amp/cutoff/pan) into `renderSynthFxTab`; mod + pose into `renderModPoseTab`. No new fields yet (just reorganised).
|
||||
- [ ] **Step 3: Browser smoke** — tabs switch; each tab edits the same data as before; colour selector applies across tabs.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "feat: tabbed voice editor"
|
||||
```
|
||||
|
||||
### Task C3: Adaptive step sequencer — Drum + Pitch (piano-roll) + toggle
|
||||
|
||||
**Files:** Modify `js/voice-editor.js`, `control.css`. Consumes `defaultStepMode`/`isMelodic` from matrix-state.
|
||||
|
||||
**Interfaces:** `renderStepsTab(vi)` branches on a per-voice `stepMode` (`'pitch'|'drum'`, init from `defaultStepMode(voice)`, with a toggle button). Both write `steps[s] = {degree, vel}` via `send("/matrix/step", vi, colour, s, degree, vel)` (degree=0 in drum).
|
||||
|
||||
- [ ] **Step 1: Drum mode** — render 16 `.seq-cell` on/off pads (current behaviour) with a 3-level accent (click cycles off→soft→loud → vel 0/0.6/0.95, degree 0). Send `/matrix/step`.
|
||||
- [ ] **Step 2: Pitch mode (piano-roll)** — render a grid: rows = scale degrees (default range 0..14, i.e. 2 octaves; show low extension for sub/reese, high for bells via `VOICE_CLASS`), cols = 16 steps. A cell at (degree d, step s) toggles `steps[s] = {degree:d, vel}` (one note per step; clicking another degree in the same column moves it). Velocity via wheel on the active note. Vertical scroll for out-of-range degrees.
|
||||
|
||||
```js
|
||||
function renderPitchRoll(vi, colour, body) {
|
||||
const def = state.matColorDefs[vi][colour];
|
||||
const degrees = [14,13,12,11,10,9,8,7,6,5,4,3,2,1,0]; // top=high
|
||||
const grid = document.createElement("div"); grid.className = "pianoroll";
|
||||
for (const d of degrees) {
|
||||
const rowEl = document.createElement("div"); rowEl.className = "pr-row";
|
||||
for (let s = 0; s < 16; s++) {
|
||||
const cell = document.createElement("button");
|
||||
cell.className = "pr-cell";
|
||||
const st = def.steps[s];
|
||||
if (st && st.degree === d) cell.classList.add("on");
|
||||
cell.addEventListener("click", () => {
|
||||
const cur = def.steps[s];
|
||||
const vel = cur ? cur.vel : 0.8;
|
||||
def.steps[s] = (cur && cur.degree === d) ? null : { degree: d, vel };
|
||||
saveMatState();
|
||||
send("/matrix/step", vi, colour, s, def.steps[s] ? d : -99, vel);
|
||||
renderStepsTab(vi);
|
||||
});
|
||||
rowEl.appendChild(cell);
|
||||
}
|
||||
grid.appendChild(rowEl);
|
||||
}
|
||||
body.appendChild(grid);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Toggle** — a `Pitch | Drum` switch in the steps tab header; `semi` voices (`perc`,`tom`) start in drum but can switch; melodic default pitch, drum voices stay drum (toggle hidden or disabled for pure drums).
|
||||
- [ ] **Step 4: CSS** — `.pianoroll{display:flex;flex-direction:column;gap:1px}` `.pr-row{display:flex;gap:1px}` `.pr-cell{width:20px;height:14px;background:#1c1c1c}` `.pr-cell.on{background:var(--m1)}` (use active colour var).
|
||||
- [ ] **Step 5: Browser smoke + audition** — for `acid`: pitch roll places notes, AUDITION plays the melody at the right pitches; for `kick`: drum pads toggle, audition plays the rhythm. Confirm `/matrix/step` args match (degree, vel).
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "feat: adaptive pitch-roll and drum step modes"
|
||||
```
|
||||
|
||||
### Task C4: SYNTH/FX tab — cutoff, res, reverb, pan, amp, octave, stretch
|
||||
|
||||
**Files:** Modify `js/voice-editor.js`, `control.css`. Consumes Phase A engine `res`/`rev`; `MATRIX_MOD_TARGETS`/`matResVoices`-equivalent classification from matrix-state.
|
||||
|
||||
**Interfaces:** `renderSynthFxTab(vi)` renders per-(voice,colour) controls; each calls `setCD(colour, field, value)` → `send("/matrix/colordef", vi, colour, field, value)`. Fields: `octave` (select -1..1), `stretch` (select .5/1/2), `amp` (range 0..1.5), `cutoff` (range 200..6000, melodic+semi only), `res` (range 0..1, res-capable voices only), `rev` (range 0..1, all), `pan` (range -1..1).
|
||||
|
||||
- [ ] **Step 1: Render controls** — labelled rows; gate `cutoff`/`res` on voice class (`isMelodic(voice) || VOICE_CLASS[voice]==='semi'`), `pan`/`rev`/`amp`/`octave`/`stretch` always. Each input `change` → `setCD`.
|
||||
|
||||
```js
|
||||
function fxRow(label, input) { const r=document.createElement("div"); r.className="fx-row";
|
||||
const l=document.createElement("span"); l.textContent=label; r.append(l,input); return r; }
|
||||
// res example:
|
||||
const res = document.createElement("input"); res.type="range"; res.min=0; res.max=1; res.step=0.02;
|
||||
res.value = def.res ?? 0.3;
|
||||
res.addEventListener("change", () => setCD(colour, "res", +res.value));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Reverb control** — `rev` range 0..1 labelled "REVERB"; `setCD(colour,"rev",value)`.
|
||||
- [ ] **Step 3: Persist + echo** — confirm `setCD` writes `matColorDefs[vi][colour][field]`, `saveMatState()`, sends OSC; the `/matrix/colordefs` inbound (Phase A extended push) updates res/rev when SC echoes.
|
||||
- [ ] **Step 4: Browser + audio smoke** — for `acid`: raise res → audible resonance; raise reverb → audible tail (requires SC booted on macm1). Confirm `/matrix/colordef vi colour res` / `rev` egress.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/
|
||||
git commit -m "feat: synth and fx tab with res and reverb"
|
||||
```
|
||||
|
||||
### Task C5: Cleanup + final verification
|
||||
|
||||
- [ ] **Step 1: Fix stale comments** — `js/matrix-state.js` / `matrix-grid.js`: any "16×32" → "22×64".
|
||||
- [ ] **Step 2: Run all JS unit tests** — `cd web_realart && node --test test/` → all PASS.
|
||||
- [ ] **Step 3: Run SC engine test + preset validator** — both PASS (as Phase A steps 7-8).
|
||||
- [ ] **Step 4: Full browser smoke on macm1** — boot the matrix (`launch_concert.sh` mechanism), load `acid_journey`, open the editor, edit a pitch step + res + reverb live, confirm audio changes and zero console errors.
|
||||
- [ ] **Step 5: Commit + push**
|
||||
|
||||
```bash
|
||||
git add -A web_realart/ sound_algo/
|
||||
git commit -m "chore: matrix editor refactor cleanup"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** accordion (C1), tabs (C2), pitch/drum steps (C3), synth/fx + res/rev engine (A1, C4), ES modules (B1-B5), single palette (B3), OSC registry (B1), voice classification (B2). All spec sections mapped.
|
||||
- **Behaviour-preserving boundary:** Phase B ends with identical UX; the only behaviour change is Phase C. This lets a reviewer reject C work without losing the modularisation.
|
||||
- **Type consistency:** `steps[s] = {degree, vel}` used identically in A1/C3; `setCD(colour, field, value)` signature stable across B5/C4; `openFor`/`toggle` introduced in C1 and used by matrix-grid.
|
||||
- **Engine/front contract:** `/matrix/colordef vi colour field value` carries `res`/`rev` (A1 step 5 extends the bulk push; C4 sends the singular set).
|
||||
@@ -0,0 +1,335 @@
|
||||
# Matrix generative per-cycle evolution — 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:** At each 64-bar loop wrap, recolour ~15% of active matrix cells from an immutable base snapshot so the cycle never repeats identically, staying close to the preset.
|
||||
|
||||
**Architecture:** Add `~matBaseGrid` (clean snapshot, taken on enable), `~matEvolve`/`~matEvolveRate`, a `~matEvolveStep` that re-derives the working grid `~lp[\matrix]` from the base each cycle (recolour active cells only, bounded), triggered at the loop wrap in `~matPlay`'s routine. Live edits dual-write to the base; save serializes the base. New OSC routes + a minor web toggle/slider.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang, no server for tests), vanilla ES + `node --test` (`web_realart`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No emojis in code/docs/commits. Commits: subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no `--no-verify`, no underscore in commit scope.
|
||||
- `.scd` balanced P:0 B:0 — run `validating-scd-files` after every `.scd` edit.
|
||||
- SC tests: `sound_algo/data_only/test/test_matrix.scd` (NO server; stubs `~toscSend`/`~toscTouch`; `pass = pass and:{...}` + `try{}`; ends `0.exit`; final `TEST PASS`). Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd` (binary not on PATH; no `timeout` binary).
|
||||
- Web tests: `cd web_realart && node --test test/*.test.mjs` (GLOB form required).
|
||||
- Defaults: `~matEvolve` false, `~matEvolveRate` 0.15 (env-tunable `? 0.15`). Recolour active cells only (0 stays 0); bounded (re-derive from base, no accumulation); mutated colour ≠ base and ∈ 1..6. Push only on user request. macm1 is the live host.
|
||||
|
||||
## Key existing code (anchors — line numbers drift, grep by name)
|
||||
|
||||
- `~matNextBar` (~290): `{ var nb = ~lp[\matBar]+1; ... wrap to ls; nb.clip(...) }`.
|
||||
- `~matApplyBar` (~296): reads `~lp[\matrix][vi][bar]`, re-sources per `~matLastColor`.
|
||||
- `~matPlay` routine (~368-375): `loop { ~matApplyBar.(~lp[\matBar]); ...playhead; ~matBeatsPerBar.wait; ~lp[\matBar] = ~matNextBar.value; }`.
|
||||
- `~matSetCell` (~389): `~lp[\matrix][vi][bar] = color.clip(0,6).asInteger; ...; ~toscSend.("/matrix/cell"...)`.
|
||||
- `~matGridPush` (~469): pushes the whole grid to surfaces.
|
||||
- `~matSave` (~590): payload `(grid: ~lp[\matrix], instruments:..., colorDefs:..., voiceMods:..., voicePoses:...)`.
|
||||
- `~matLoadFile` (~617): `~lp[\matrix] = grid`.
|
||||
- Matrix OSCdefs region (~860): `\mat_cell` (`/matrix/cell`), `\mat_play` (`/matrix/play`).
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix.scd` | `~matEvolve`/`~matEvolveRate`/`~matBaseGrid` decls; `~matEvolveStep`; `~matSetEvolve`; `~matEvolvePush`; routine wrap-trigger; `~matSetCell` dual-write; `~matSave` base; `~matLoadFile` re-snapshot; OSCdefs `\mat_evolve`/`\mat_evolverate` |
|
||||
| `sound_algo/data_only/test/test_matrix.scd` | evolve mutation + snapshot/restore + edit/save integration assertions |
|
||||
| `web_realart/public/control/js/matrix-grid.js` (or transport area) | ÉVOLUE toggle + rate slider + `/matrix/evolve` echo handler |
|
||||
| `web_realart/test/matrix-state.test.mjs` | (if a pure parse helper is added) payload shape |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: evolve core — state, mutation, enable/disable, wrap trigger
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` (decls near `~matBars`/`~matLastColor` ~20-27; new fns near `~matApplyBar` ~322; routine ~373)
|
||||
- Test: `sound_algo/data_only/test/test_matrix.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `~matEvolve` (Bool), `~matEvolveRate` (Float), `~matBaseGrid` (2D Array or nil), `~matEvolveStep.()` (mutates `~lp[\matrix]` from `~matBaseGrid`), `~matSetEvolve.(onBool)` (snapshot/restore + push), `~matEvolvePush.()`.
|
||||
- Consumes: `~lp[\matrix]`, `~matVoices`, `~matBars`, `~matGridPush`, `~matLastColor`, `~toscSend`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Add to `test_matrix.scd` (near the grid/cell tests):
|
||||
|
||||
```supercollider
|
||||
// --- generative evolve (SP-B) ---
|
||||
// build a base grid with known 0 and active cells (small 2-voice fixture in-place)
|
||||
~evBase = Array.fill(~matVoices.size, { Array.fill(~matBars, 0) });
|
||||
~evBase[0][0] = 3; ~evBase[0][1] = 0; ~evBase[0][2] = 5; // kick: active,0,active
|
||||
~evBase[1][0] = 0; ~evBase[1][4] = 2; // hats: 0,...,active
|
||||
~matBaseGrid = ~evBase.collect({ |row| row.copy });
|
||||
~lp[\matrix] = ~evBase.collect({ |row| row.copy });
|
||||
~matEvolveRate = 1.0; // mutate every active cell
|
||||
~matEvolveStep.();
|
||||
// density preserved: 0-cells stay 0, active stay active (1..6)
|
||||
pass = pass and: { ~lp[\matrix][0][1] == 0 }; // base 0 stays 0
|
||||
pass = pass and: { ~lp[\matrix][1][0] == 0 };
|
||||
pass = pass and: { (~lp[\matrix][0][0] >= 1) and: { ~lp[\matrix][0][0] <= 6 } };
|
||||
pass = pass and: { (~lp[\matrix][0][2] >= 1) and: { ~lp[\matrix][0][2] <= 6 } };
|
||||
pass = pass and: { (~lp[\matrix][1][4] >= 1) and: { ~lp[\matrix][1][4] <= 6 } };
|
||||
// recolour differs from base (rate 1.0 guarantees a change)
|
||||
pass = pass and: { ~lp[\matrix][0][0] != 3 };
|
||||
pass = pass and: { ~lp[\matrix][0][2] != 5 };
|
||||
pass = pass and: { ~lp[\matrix][1][4] != 2 };
|
||||
// rate 0 -> identical to base
|
||||
~matEvolveRate = 0.0;
|
||||
~lp[\matrix] = ~evBase.collect({ |row| row.copy });
|
||||
~matEvolveStep.();
|
||||
pass = pass and: { ~lp[\matrix][0][0] == 3 and: { ~lp[\matrix][0][2] == 5 } };
|
||||
// bounded: base is untouched by mutation; setEvolve(false) restores base exactly
|
||||
~matEvolveRate = 1.0;
|
||||
~matSetEvolve.(true); // snapshots current ~lp[\matrix] as base
|
||||
~evSnap = ~matBaseGrid.collect({ |row| row.copy });
|
||||
~matEvolveStep.(); ~matEvolveStep.(); // two cycles
|
||||
pass = pass and: { ~matBaseGrid == ~evSnap }; // base unchanged by steps
|
||||
~matSetEvolve.(false); // restore
|
||||
pass = pass and: { ~lp[\matrix] == ~evSnap }; // working grid back to base
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST FAIL`/exception — `~matEvolveStep`/`~matSetEvolve` undefined.
|
||||
|
||||
- [ ] **Step 3: Add state + functions.** After `~matApplyBar` (matrix.scd ~322), insert:
|
||||
|
||||
```supercollider
|
||||
// -- generative per-cycle evolution (SP-B) --
|
||||
~matEvolve = ~matEvolve ? false;
|
||||
~matEvolveRate = ~matEvolveRate ? 0.15; // fraction of active cells recoloured per cycle
|
||||
~matBaseGrid = ~matBaseGrid ? nil; // immutable clean snapshot while evolving
|
||||
|
||||
// re-derive ~lp[\matrix] from ~matBaseGrid: recolour active cells (0 stays 0),
|
||||
// mutated colour differs from base and is in 1..6. Bounded (always from base).
|
||||
~matEvolveStep = {
|
||||
~matBaseGrid.notNil.if({
|
||||
~matVoices.do { |name, vi|
|
||||
~matBars.do { |b|
|
||||
var base = ~matBaseGrid[vi][b];
|
||||
((base != 0) and: { 1.0.rand < ~matEvolveRate }).if({
|
||||
~lp[\matrix][vi][b] = (1..6).reject({ |c| c == base }).choose
|
||||
}, {
|
||||
~lp[\matrix][vi][b] = base
|
||||
})
|
||||
}
|
||||
};
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 }; // force re-source next bar
|
||||
~matGridPush.()
|
||||
})
|
||||
};
|
||||
|
||||
// push evolve state to surfaces
|
||||
~matEvolvePush = {
|
||||
~toscSend !? { ~toscSend.("/matrix/evolve", ~matEvolve.if({ 1 }, { 0 }), ~matEvolveRate) }
|
||||
};
|
||||
|
||||
// enable/disable: snapshot base on enable; restore clean grid on disable
|
||||
~matSetEvolve = { |on|
|
||||
on.asBoolean.if({
|
||||
~matEvolve = true;
|
||||
~matBaseGrid = ~lp[\matrix].collect({ |row| row.copy });
|
||||
}, {
|
||||
~matEvolve = false;
|
||||
~matBaseGrid.notNil.if({
|
||||
~lp[\matrix] = ~matBaseGrid.collect({ |row| row.copy });
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 };
|
||||
~matGridPush.()
|
||||
});
|
||||
~matBaseGrid = nil;
|
||||
});
|
||||
~matEvolvePush.()
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the wrap trigger into `~matPlay`.** In the routine loop (matrix.scd ~373), replace:
|
||||
|
||||
```supercollider
|
||||
~matBeatsPerBar.wait;
|
||||
~lp[\matBar] = ~matNextBar.value;
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matBeatsPerBar.wait;
|
||||
~matPrevBar = ~lp[\matBar];
|
||||
~lp[\matBar] = ~matNextBar.value;
|
||||
// loop wrapped (playhead jumped backward) -> mutate one cycle
|
||||
(~matEvolve and: { ~lp[\matBar] < ~matPrevBar }).if({ ~matEvolveStep.() });
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Balance check.** `validating-scd-files` on `matrix.scd` and `test_matrix.scd`. Expected `P:0 B:0` each.
|
||||
|
||||
- [ ] **Step 6: Run to verify it PASSES.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST PASS`, zero `ERROR`, clean exit.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
|
||||
git commit -m "feat(matrix): generative per-cycle evolve core"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: persistence integration + OSC routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` — `~matSetCell`, `~matSave`, `~matLoadFile`, + OSCdefs
|
||||
- Test: `sound_algo/data_only/test/test_matrix.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes Task 1's `~matEvolve`/`~matBaseGrid`/`~matSetEvolve`/`~matEvolveRate`.
|
||||
- Produces: `~matSetCell` dual-writes the base while evolving; `~matSave` serializes the base while evolving; `~matLoadFile` re-snapshots if evolving; OSCdefs `\mat_evolve` (`/matrix/evolve [0/1]`), `\mat_evolverate` (`/matrix/evolverate [0..1]`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Add to `test_matrix.scd` after Task 1's block:
|
||||
|
||||
```supercollider
|
||||
// edit during evolve writes through to the base (survives next cycle)
|
||||
~lp[\matrix] = Array.fill(~matVoices.size, { Array.fill(~matBars, 0) });
|
||||
~lp[\matrix][0][0] = 1;
|
||||
~matSetEvolve.(true); // base = current grid
|
||||
~matSetCell.(0, 0, 4); // edit while evolving
|
||||
pass = pass and: { ~matBaseGrid[0][0] == 4 }; // base updated, not just working grid
|
||||
// save serializes the base, not the mutated working grid
|
||||
~matEvolveRate = 1.0; ~matEvolveStep.(); // working grid now mutated
|
||||
~matSave.("ev_tmp");
|
||||
~evRaw = File.use(~matDir +/+ "ev_tmp.matrix", "r", { |f| f.readAllString }).interpret;
|
||||
pass = pass and: { ~evRaw[\grid][0][0] == 4 }; // saved = base value, not the mutated one
|
||||
File.delete(~matDir +/+ "ev_tmp.matrix");
|
||||
~matSetEvolve.(false);
|
||||
// OSCdefs route through to the setters
|
||||
OSCdef(\mat_evolverate).func.value(['/matrix/evolverate', 0.42]);
|
||||
pass = pass and: { (~matEvolveRate - 0.42).abs < 0.001 };
|
||||
OSCdef(\mat_evolve).func.value(['/matrix/evolve', 1]);
|
||||
pass = pass and: { ~matEvolve == true and: { ~matBaseGrid.notNil } };
|
||||
OSCdef(\mat_evolve).func.value(['/matrix/evolve', 0]);
|
||||
pass = pass and: { ~matEvolve == false };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST FAIL` — setCell doesn't touch base; save uses working grid; OSCdefs absent.
|
||||
|
||||
- [ ] **Step 3: Dual-write in `~matSetCell`.** In `~matSetCell` (matrix.scd ~391), after `~lp[\matrix][vi][bar] = color.clip(0, 6).asInteger;` add:
|
||||
|
||||
```supercollider
|
||||
(~matEvolve and: { ~matBaseGrid.notNil }).if({ ~matBaseGrid[vi][bar] = ~lp[\matrix][vi][bar] });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Save the base.** In `~matSave` find the payload `grid: ~lp[\matrix]` and replace it with:
|
||||
|
||||
```supercollider
|
||||
grid: (~matEvolve.if({ ~matBaseGrid ? ~lp[\matrix] }, { ~lp[\matrix] })),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-snapshot on load.** In `~matLoadFile`, immediately after the line `~lp[\matrix] = grid;` (matrix.scd ~617), add:
|
||||
|
||||
```supercollider
|
||||
(~matEvolve).if({ ~matBaseGrid = ~lp[\matrix].collect({ |row| row.copy }) });
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the OSCdefs.** Near the matrix OSCdefs (after `\mat_play` ~868), add:
|
||||
|
||||
```supercollider
|
||||
OSCdef(\mat_evolve, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matSetEvolve.((msg[1] ? 0).asInteger > 0)
|
||||
}, '/matrix/evolve');
|
||||
OSCdef(\mat_evolverate, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matEvolveRate = (msg[1] ? 0.15).asFloat.clip(0, 1);
|
||||
~matEvolvePush.()
|
||||
}, '/matrix/evolverate');
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Balance check.** `validating-scd-files` on both files. Expected `P:0 B:0`.
|
||||
|
||||
- [ ] **Step 8: Run to verify it PASSES.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST PASS`, zero `ERROR`, clean exit.
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
|
||||
git commit -m "feat(matrix): evolve edit/save/load + OSC routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: web ÉVOLUE toggle + rate slider
|
||||
|
||||
**Files:**
|
||||
- Modify: `web_realart/public/control/js/matrix-grid.js` (or the matrix transport/timeline area — grep for the matrix play/loop controls) + `web_realart/public/control/css/control.css`
|
||||
- Modify: `web_realart/public/control/js/osc.js` consumers — add an `on("/matrix/evolve")` handler
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `send`, `on` (osc.js). Produces: a toggle that sends `/matrix/evolve [0/1]` and a slider that sends `/matrix/evolverate [0..1]`, both reflecting the echoed `/matrix/evolve [state, rate]`.
|
||||
|
||||
- [ ] **Step 1: Locate the matrix transport controls.** `grep -rn "matrix/play\|matrix/loop\|matrix-timeline" web_realart/public/control/js` to find where the matrix play/stop/loop UI is built. Add the evolve controls beside them.
|
||||
|
||||
- [ ] **Step 2: Add the toggle + slider.** In that module, add a button `ÉVOLUE` (class `mat-evolve`) and a range input (min 0, max 1, step 0.01) near the matrix transport:
|
||||
|
||||
```javascript
|
||||
const evBtn = document.createElement("button");
|
||||
evBtn.className = "mat-evolve"; evBtn.textContent = "ÉVOLUE";
|
||||
evBtn.addEventListener("click", () => {
|
||||
const on = !evBtn.classList.contains("on");
|
||||
send("/matrix/evolve", on ? 1 : 0);
|
||||
});
|
||||
const evRate = document.createElement("input");
|
||||
evRate.type = "range"; evRate.min = 0; evRate.max = 1; evRate.step = 0.01; evRate.value = 0.15;
|
||||
evRate.title = "taux d'évolution";
|
||||
evRate.addEventListener("change", () => send("/matrix/evolverate", +evRate.value));
|
||||
// append evBtn, evRate to the matrix transport container (match the existing pattern)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reflect the echoed state.** Add a handler (in the same module or osc.js consumers):
|
||||
|
||||
```javascript
|
||||
on("/matrix/evolve", (args) => {
|
||||
const on = Math.round(Number(args[0])) === 1;
|
||||
const rate = Number(args[1]);
|
||||
evBtn.classList.toggle("on", on);
|
||||
if (Number.isFinite(rate)) evRate.value = rate;
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Style the toggle.** Append to `control.css`:
|
||||
|
||||
```css
|
||||
.mat-evolve { padding: 2px 8px; }
|
||||
.mat-evolve.on { background: #1a8; color: #000; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Syntax + regression.**
|
||||
|
||||
Run: `cd web_realart && node --check public/control/js/matrix-grid.js && node --test test/*.test.mjs`
|
||||
Expected: no `--check` output; the full suite stays green (currently 29/29). UI behavior is verified live (macm1) / Playwright — note the deferral.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/js web_realart/public/control/css/control.css
|
||||
git commit -m "feat(web): matrix evolve toggle and rate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- Base/working grid model + bounded mutation → Task 1 (`~matBaseGrid`, `~matEvolveStep` re-derives from base). ✓
|
||||
- Recolour active cells only, differ from base, 0 stays 0 → Task 1 Step 3 + test density/differ assertions. ✓
|
||||
- Enable/disable snapshot/restore → Task 1 `~matSetEvolve`. ✓
|
||||
- Wrap trigger once per cycle → Task 1 Step 4 (`~lp[\matBar] < ~matPrevBar`). ✓
|
||||
- Live-edit dual-write + save base + load re-snapshot → Task 2. ✓
|
||||
- OSC `/matrix/evolve` + `/matrix/evolverate` + echo → Task 2 OSCdefs + `~matEvolvePush`. ✓
|
||||
- Web toggle/slider → Task 3. ✓
|
||||
- Default OFF, rate 0.15 env-tunable → Task 1 decls. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. The routine wrap-trigger is integration-tested by the macm1 live smoke (the routine needs the clock); `~matEvolveStep`/`~matSetEvolve`/OSCdefs are directly unit-tested. Web UI behavior deferred to Playwright/live (noted), with node --check + suite-green as the automated gate.
|
||||
|
||||
**Type consistency:** `~matEvolveStep` mutates `~lp[\matrix]` (Array of Arrays of Int) from `~matBaseGrid` (same shape, 2-level copy). `~matSetEvolve.(Bool)`. `~matEvolveRate` Float 0..1. OSCdef `\mat_evolve` calls `~matSetEvolve`, `\mat_evolverate` sets `~matEvolveRate` — matching Task 1's names. `/matrix/evolve` echo is `[stateInt(0/1), rate]` (Task 1 `~matEvolvePush`) consumed by Task 3's handler (`args[0]` state, `args[1]` rate). `~matSetCell` dual-write guarded by `~matEvolve and ~matBaseGrid.notNil`. Save uses `~matBaseGrid ? ~lp[\matrix]` fallback.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Matrix hand-authored pattern families — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline) — this is creative musical content authoring; subagent delegation adds little (a fresh agent can't judge musicality and the review can only check validity). The author with full context writes the patterns; the validity/contrast tests + the user's ear are the gates. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Hand-author 6-colour pattern families (dub/psy/house/ambient for mel voices, complete techno/breaks/dnb + percu-voice families for drums) so genre presets feel musically distinct.
|
||||
|
||||
**Architecture:** Add explicit 6-pattern entries to the existing `DRUMS`/`MEL` dicts (drum families = 6 sixteen-char strings; mel families = 6 `_mel([...])` motif lists), following the colour-role convention for contrast. Retarget `PRESET_FAMILY` so presets reference the new families. No engine/web change; `patterns_for` and SP-C1 derivation are untouched (they only affect `default`).
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`), the rhythm DSL + `_mel`, SC validation harness.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: **uv** only. No emojis in code/docs/commits. Commits: subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no underscore in commit scope.
|
||||
- Step shapes: drum family = list of 6 sixteen-char strings over `. o - x X` (= vel 0/.30/.50/.80/1.0); mel family = list of 6 `_mel([...])` results (16 steps of `None`/`(degree, vel)`).
|
||||
- **Colour-role convention** (each new family's 6 colours, hand-authored): 1 = base · 2 = busier · 3 = broken/syncopated · 4 = double-time/denser · 5 = sparse · 6 = fill (last steps active). Hand-written, NOT derived.
|
||||
- **Velocity-humanized**: vary `X` accents / `o` ghosts / `-` soft; no all-`x` rows.
|
||||
- Existing families (`MEL["sub"]["dub"]`, `MEL["acid"]["psy"]`, all drum style families) and every `default` family: UNTOUCHED.
|
||||
- SC validation: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd` (no `timeout` binary). Push only on user request. The macm1 live rig is up for the user's batch review.
|
||||
|
||||
## Family × voice matrix (what to author)
|
||||
|
||||
**MEL new families** (sub `dub` + acid `psy` already exist — author only the listed voices):
|
||||
| Family | Voices | Feel |
|
||||
|--------|--------|------|
|
||||
| `dub` | reese, pad, chord, bells, melody | deep, sparse, off-beat skank stabs, long rests |
|
||||
| `psy` | sub, reese, arp, lead, stab | rolling 16th basslines, fast tight arps, minor |
|
||||
| `house` | pad, chord, stab, bells, lead, melody | chord stabs on off-beats, syncopated, soulful |
|
||||
| `ambient` | pad, bells, melody, sub, reese | very sparse, long sustained-feel, few notes, wide |
|
||||
|
||||
**DRUMS percu-voice families** (the 6 fallback-only voices get authored style families):
|
||||
| Voice | Families to add | Feel |
|
||||
|-------|-----------------|------|
|
||||
| `ride` | techno, house | rolling 8ths / off-beat bell |
|
||||
| `shaker` | techno, house, breaks | 16th groove with accent swing-feel via velocity |
|
||||
| `crash` | techno, breaks | downbeat crashes + section-end fill |
|
||||
| `rim` | techno, dnb | syncopated clicks / ghost-heavy |
|
||||
| `fx` | techno, ambient | sparse risers/hits |
|
||||
| `sweep` | trance, ambient | long swells (sparse onsets) |
|
||||
|
||||
**Complete skewed drum styles**: add base patterns for voices `techno`/`breaks`/`dnb` currently omit so each genre feels coherent across its active kit (e.g. `breaks` for perc, shaker; `dnb` for perc, ride, shaker).
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix_presets/patterns.py` | add new `DRUMS`/`MEL` family entries; retarget `PRESET_FAMILY` |
|
||||
| `sound_algo/data_only/matrix_presets/test_families.py` | create — validity/contrast/untouched/wiring assertions |
|
||||
| `sound_algo/data_only/matrix_presets/*.matrix` | regenerated (28) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: validity/contrast/wiring test harness
|
||||
|
||||
**Files:**
|
||||
- Create: `sound_algo/data_only/matrix_presets/test_families.py`
|
||||
|
||||
**Interfaces:** consumes `DRUMS`, `MEL`, `PRESET_FAMILY`, `_rhythm`, `_mel`, `VOICES`, `DRUM_VOICES`, `patterns_for` from `patterns.py`.
|
||||
|
||||
- [ ] **Step 1: Write the harness.** Create `test_families.py`:
|
||||
|
||||
```python
|
||||
"""Validity + contrast + untouched + wiring checks for the pattern bank.
|
||||
Run: cd sound_algo/data_only/matrix_presets && uv run python test_families.py"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from patterns import DRUMS, MEL, PRESET_FAMILY, _rhythm, _mel, patterns_for
|
||||
|
||||
ok = True
|
||||
def check(c, m):
|
||||
global ok
|
||||
if not c: ok = False; print("FAIL:", m)
|
||||
|
||||
_VELCH = set(". o - x X")
|
||||
# every drum family = 6 valid 16-char strings
|
||||
for v, fams in DRUMS.items():
|
||||
for fam, cols in fams.items():
|
||||
check(len(cols) == 6, "DRUMS[%s][%s] has %d cols" % (v, fam, len(cols)))
|
||||
for i, s in enumerate(cols):
|
||||
check(isinstance(s, str) and len(s) == 16 and set(s) <= _VELCH,
|
||||
"DRUMS[%s][%s][%d] invalid: %r" % (v, fam, i, s))
|
||||
# every mel family = 6 valid 16-step motif lists
|
||||
for v, fams in MEL.items():
|
||||
for fam, cols in fams.items():
|
||||
check(len(cols) == 6, "MEL[%s][%s] has %d cols" % (v, fam, len(cols)))
|
||||
for i, m in enumerate(cols):
|
||||
check(isinstance(m, list) and len(m) == 16
|
||||
and all(s is None or (isinstance(s[0], int) and isinstance(s[1], float)) for s in m),
|
||||
"MEL[%s][%s][%d] invalid" % (v, fam, i))
|
||||
|
||||
def ddist(cols): return len({c for c in cols})
|
||||
def mdist(cols): return len({tuple(c) for c in cols})
|
||||
def dhits(s): return sum(1 for ch in s if ch not in ". ")
|
||||
# contrast: new (non-default) families have >=4 distinct colours; col5 sparse <= col1; col6 fill last-4 active
|
||||
for v, fams in DRUMS.items():
|
||||
for fam, cols in fams.items():
|
||||
if fam == "default": continue
|
||||
check(ddist(cols) >= 4, "DRUMS[%s][%s] only %d distinct" % (v, fam, ddist(cols)))
|
||||
check(dhits(cols[4]) <= dhits(cols[0]), "DRUMS[%s][%s] sparse !<= base" % (v, fam))
|
||||
check(any(ch not in ". " for ch in cols[5][12:16]), "DRUMS[%s][%s] fill last-4 empty" % (v, fam))
|
||||
|
||||
# PRESET_FAMILY wiring resolves (no missing-key crash) for every preset
|
||||
for pname, (dfam, mfam) in PRESET_FAMILY.items():
|
||||
for v in DRUMS: check(patterns_for(v, pname) is not None or v not in DRUMS, "drum %s/%s" % (v, pname))
|
||||
# mel resolution: family present or falls back to default (which exists)
|
||||
for v in MEL: check(("default" in MEL[v]) or (mfam in MEL[v]), "mel %s/%s no default" % (v, pname))
|
||||
|
||||
print("TEST PASS" if ok else "TEST FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it** (passes against the current bank — it's a validity floor).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_families.py`
|
||||
Expected: `TEST PASS` (the existing families already satisfy validity; the contrast checks only apply to non-default families, all currently valid).
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/test_families.py
|
||||
git commit -m "test(presets): pattern family validity harness"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MEL families — dub, psy, house, ambient
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/patterns.py` (`MEL` dict)
|
||||
- Test: `test_families.py`
|
||||
|
||||
**Interfaces:** adds `MEL[v][fam]` entries per the matrix (dub: reese/pad/chord/bells/melody; psy: sub/reese/arp/lead/stab; house: pad/chord/stab/bells/lead/melody; ambient: pad/bells/melody/sub/reese).
|
||||
|
||||
- [ ] **Step 1: Author the motifs.** For each (voice, family) in the MEL matrix, add an entry `MEL[v][fam] = [_mel([...]), ...6 motifs...]` to the `MEL` dict, following the colour-role convention and the family feel (table above). Worked template — `dub` for `reese` (deep off-beat skank, A-minor degrees, long rests, humanized vels):
|
||||
|
||||
```python
|
||||
MEL["reese"]["dub"] = [
|
||||
_mel([(0,.8),N,N,N,(0,.5),N,N,N,(3,.6),N,N,N,(0,.5),N,N,N]), # 1 base: root pulse
|
||||
_mel([(0,.8),N,(0,.4),N,(0,.6),N,(3,.4),N,(3,.6),N,N,N,(0,.5),N,(0,.4),N]), # 2 busier
|
||||
_mel([N,N,(0,.7),N,N,(0,.5),N,N,(3,.6),N,N,(0,.4),N,N,(5,.5),N]), # 3 broken: off-beat skank
|
||||
_mel([(0,.8),N,(0,.5),N,(3,.6),N,(3,.5),N,(5,.6),N,(5,.5),N,(3,.6),N,(0,.5),N]), # 4 double
|
||||
_mel([(0,.7),N,N,N,N,N,N,N,(3,.5),N,N,N,N,N,N,N]), # 5 sparse
|
||||
_mel([(0,.8),N,(0,.5),N,(3,.6),(3,.5),(5,.6),(5,.5),(7,.6),N,(5,.5),N,(3,.6),(3,.5),(0,.7),(0,.5)]), # 6 fill: rising run
|
||||
]
|
||||
```
|
||||
(`N` is `None`, already defined in patterns.py.) Repeat for every (voice, family) in the MEL matrix — author each by hand to the family's feel, varying degree content per voice register (`BASE_OCT` already shifts octaves: sub/reese low, lead/arp/bells high). Keep colour-5 sparser and colour-6 a fill, vary velocities.
|
||||
|
||||
- [ ] **Step 2: Confirm existing-family integrity.** Add to `test_families.py` (before the final print): assert the two pre-existing authored mel families are byte-identical to a captured snapshot — simplest: assert they still parse and that `len(MEL["sub"]["dub"]) == 6` and `len(MEL["acid"]["psy"]) == 6` AND that you did NOT edit those keys (review the diff: the `MEL["sub"]["dub"]` / `MEL["acid"]["psy"]` lines must not appear in the diff).
|
||||
|
||||
- [ ] **Step 3: Run the harness.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_families.py`
|
||||
Expected: `TEST PASS` — all new mel families are 6 valid motif lists.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/patterns.py sound_algo/data_only/matrix_presets/test_families.py
|
||||
git commit -m "feat(presets): mel dub/psy/house/ambient families"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DRUMS — percu voices + complete styles
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/patterns.py` (`DRUMS` dict)
|
||||
- Test: `test_families.py`
|
||||
|
||||
**Interfaces:** adds `DRUMS[v][fam]` entries per the percu matrix (ride/shaker/crash/rim/fx/sweep) + the missing voices in techno/breaks/dnb.
|
||||
|
||||
- [ ] **Step 1: Author the rhythms.** For each (voice, family) add `DRUMS[v][fam] = [<6 sixteen-char strings>]` following the colour-role convention + the feel table. Worked template — `ride` `techno` (rolling 8ths, accent variation, humanized):
|
||||
|
||||
```python
|
||||
DRUMS["ride"]["techno"] = [
|
||||
"x.x.x.x.x.x.x.x.", # 1 base: straight 8ths
|
||||
"xXx.xXx.xXx.xXx.", # 2 busier: accent each beat
|
||||
"x..x.x.x..x.x.x.", # 3 broken: syncopated
|
||||
"xxxxxxxxxxxxxxxx", # 4 double: 16ths
|
||||
"x...x...x...x...", # 5 sparse: quarters
|
||||
"x.x.x.x.x.x.xXXX", # 6 fill: tail accent roll
|
||||
]
|
||||
```
|
||||
Repeat for every (voice, family) in the DRUMS matrix + the techno/breaks/dnb voice-completion. Keep colour-5 sparse, colour-6 a fill (last steps active = the harness checks this), humanize velocities (`X`/`o`/`-`).
|
||||
|
||||
- [ ] **Step 2: Run the harness.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_families.py`
|
||||
Expected: `TEST PASS` — drum contrast checks (≥4 distinct, sparse ≤ base, fill last-4 active) pass for every new family.
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/patterns.py
|
||||
git commit -m "feat(presets): percu families + complete styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: PRESET_FAMILY wiring + regenerate + validate
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/patterns.py` (`PRESET_FAMILY`)
|
||||
- Modify: `*.matrix` (regenerated), `test_families.py` (wiring assertion)
|
||||
|
||||
- [ ] **Step 1: Retarget `PRESET_FAMILY`.** Update entries so genre presets reference the new families, e.g. `dub_techno -> ("techno", "dub")`, `psytrance_roll/minimal_hypno -> (drum, "psy")`, the house/electro presets -> mel `house`, `ambient_intro -> (drum, "ambient")`, trance presets keep/gain mel families that now exist. Only set a mel family for a preset when the voices it activates have that family (else they fall back to `default`, which is fine).
|
||||
|
||||
- [ ] **Step 2: Run the harness** (wiring assertions resolve).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_families.py`
|
||||
Expected: `TEST PASS`.
|
||||
|
||||
- [ ] **Step 3: Regenerate the 28 presets.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python generate_presets.py`
|
||||
Expected: 28 `.matrix` files, no errors. Run TWICE; 2nd run = zero git diff (determinism).
|
||||
|
||||
- [ ] **Step 4: SC load validation.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd`
|
||||
Expected: `VALIDATE PASS` (28/28).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets
|
||||
git commit -m "feat(presets): wire new families, regenerate"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: User batch review.** Push (on request) + macm1 pull; the user reloads the genre presets on the live rig (`dub_techno`, `psytrance_roll`, a house preset, `ambient_intro`) and listens. Capture which families sound wrong → a focused follow-up authoring pass (re-author those specific bases, re-run harness + regen). This is the musical gate the automated tests cannot provide.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- Hand-authored 6-colour families (not derived) → Tasks 2/3 (explicit `MEL`/`DRUMS` entries). ✓
|
||||
- Mel dub/psy/house/ambient → Task 2 matrix. ✓
|
||||
- Percu voices + complete techno/breaks/dnb → Task 3. ✓
|
||||
- Velocity humanization → convention + examples (varied `X`/`o`/`-`). ✓
|
||||
- Existing families + default untouched → Task 2 Step 2 (diff check) + the harness only adds, never edits existing keys. ✓
|
||||
- PRESET_FAMILY wiring → Task 4. ✓
|
||||
- Tests (validity/contrast/untouched/wiring) → Task 1 harness, extended per task. ✓
|
||||
- Regenerate + SC validate → Task 4. ✓
|
||||
- Musical review (user, batch) → Task 4 Step 6. ✓
|
||||
|
||||
**Placeholder scan:** The per-(voice,family) patterns are authored at execution — this is creative content, not boilerplate; the plan supplies the convention, the exact matrix, a complete worked template per family-type (drum + mel), and the validity/contrast harness as the gate. That is the maximum a plan can specify for hand-authored music without pre-composing every bar (which would defeat the "hand-authored" intent). No TBD/TODO in the mechanical parts (harness, wiring, regen).
|
||||
|
||||
**Type consistency:** drum family = `list[str]` of six 16-char strings (parsed by `_rhythm` in `patterns_for`); mel family = `list` of six `_mel([...])` results. Matches the existing dict shapes. `N` = `None` (defined in patterns.py). The harness reads `DRUMS`/`MEL`/`PRESET_FAMILY`/`patterns_for` — all existing names. Colour indices: `cols[4]` = sparse, `cols[5]` = fill (0-based), consistent across the harness and the authoring convention.
|
||||
@@ -0,0 +1,320 @@
|
||||
# Matrix pitch + res modulation targets — 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:** Add `res` and `pitch` as per-voice modulation targets in the matrix sequencer so pose sources can drive resonance (res-voices) and in-key transpose (melodic step voices).
|
||||
|
||||
**Architecture:** `res` becomes an independent additive Pbind key in `~matModPairs` (like cutoff/rev). `pitch` is special — frequency is computed by a custom `\freq` Pfunc from scale degrees, so a pitch binding adds a bipolar integer scale-degree offset (`~matPitchOffset`) inside that Pfunc; it is excluded from `~matModPairs`. Target eligibility is enforced by the existing `~matModTargets` gating in `~matSetVoiceMod`. Web mirrors the new per-voice targets + labels.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang, no server for tests), vanilla ES modules + `node --test` (`web_realart`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No emojis in code/docs/commits.
|
||||
- Commits: subject ≤ 50 chars, body ≤ 72 chars/line, no AI attribution, no `--no-verify`, no underscore in commit scope.
|
||||
- `.scd` files MUST stay balanced P:0 B:0 — run the `validating-scd-files` skill after every `.scd` edit.
|
||||
- Modulation source/target IDs are SYMBOLS on the wire and in presets — never renamed; FR labels are web display-only.
|
||||
- SC tests live in `sound_algo/data_only/test/test_matrix.scd` (NO server booted; stubs `~toscSend`/`~toscTouch`; dummy `Pdef(\lp_*)`; `~lp=(clock:TempoClock.default)`; loads `../matrix.scd`; `pass = pass and: {...}` + `try{}`; ends `0.exit`; final `TEST PASS`). Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd` (binary not on PATH; there is no `timeout`/`gtimeout` — the script self-terminates). Test OSCdef/data logic by calling functions directly.
|
||||
- Web tests: `cd web_realart && node --test test/*.test.mjs` — the GLOB form is required (a bare `node --test test/` mis-parses "test" as a single failing test). Currently 26/26.
|
||||
- Push only on user request (Gitea `gitea-https` remote = `https://electron@git.saillant.cc/electron-rare/AV-Live.git`). macm1 is the live engine host.
|
||||
|
||||
## Eligibility (single source of truth)
|
||||
|
||||
- **Pitch-capable** (10, the freq-Pfunc step voices): `sub, acid, arp, lead, stab, pad, reese, bells, perc, tom`.
|
||||
- **Res-capable** (10 = `~matResVoices`): `sub, acid, arp, lead, stab, pad, reese, bells, melody, chord`.
|
||||
- Overlap (both): `sub, acid, arp, lead, stab, pad, reese, bells`. Pitch-only: `perc, tom`. Res-only: `melody, chord`.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix.scd` | `~matModPairs` (+res branch, exclude pitch), `~matModTargets` (+res/+pitch), `~matPitchOffset`/`~matPitchRange`, freq Pfunc patch |
|
||||
| `sound_algo/data_only/test/test_matrix.scd` | res additive, pitch offset, freq integration, gating assertions |
|
||||
| `web_realart/public/control/js/matrix-state.js` | `MATRIX_MOD_TARGETS` (+res/+pitch per voice), `MOD_TARGET_LABELS` (+pitch/+res) |
|
||||
| `web_realart/test/matrix-state.test.mjs` | target/label coverage assertions |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `res` modulation target
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` — `~matModPairs` (currently ~156-193), `~matModTargets` (currently ~28-39)
|
||||
- Test: `sound_algo/data_only/test/test_matrix.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~matVoiceMods`, `~matModSourceVal`, `~matResVoices`.
|
||||
- Produces: `~matModPairs` now emits a `[\res, Pfunc]` pair (additive `Σ src·depth` clip 0..1) for bindings whose target is `\res`; `~matModTargets[v]` includes `\res` for the 10 res-voices.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Add to `test_matrix.scd` (near the per-voice additive model block, after the existing rev test ~line 463), following the file's style:
|
||||
|
||||
```supercollider
|
||||
// --- res target (cycle 1) ---
|
||||
// res: additive, two sources sum and clip 0..1
|
||||
~matVoiceMods[\acid] = [ (source: \rHandY, target: \res, depth: 0.5),
|
||||
(source: \bodyY, target: \res, depth: 0.5) ];
|
||||
~matModCache[\rHandY] = 1.0; ~matModCache[\bodyY] = 1.0; // 1*0.5 + 1*0.5 = 1.0 (clip)
|
||||
pass = pass and: {
|
||||
var pairs, pf;
|
||||
pairs = ~matModPairs.(\acid, (amp: 1.0), { 1.0 });
|
||||
pf = pairs[pairs.indexOf(\res) + 1];
|
||||
pf.notNil and: { (pf.asStream.next(()) - 1.0).abs < 0.001 }
|
||||
};
|
||||
~matVoiceMods[\acid] = [];
|
||||
// res target is gated to res-voices: ~matModTargets[\hats] must NOT include \res
|
||||
pass = pass and: { (~matModTargets[\hats] ? []).includes(\res).not };
|
||||
pass = pass and: { (~matModTargets[\acid] ? []).includes(\res) };
|
||||
```
|
||||
(`Pfunc.value` returns self in SC — evaluate via `pf.asStream.next(())`, as the existing additive tests do.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST FAIL` (or an exception) — `~matModPairs` has no `\res` branch (a `\res` target currently falls into the `amp` catch-all, so `indexOf(\res)` is -1), and `~matModTargets[\acid]` lacks `\res`.
|
||||
|
||||
- [ ] **Step 3: Add the `\res` branch to `~matModPairs`.** Replace the `\rev`→catch-all tail of the `byTarget.keysValuesDo` block. Current (matrix.scd ~179-190):
|
||||
|
||||
```supercollider
|
||||
(tgt == \rev).if({
|
||||
pairs = pairs ++ [\rev, Pfunc {
|
||||
var a = 0; list.do { |b| a = a + (~matModSourceVal.(b[\source]) * b[\depth]) };
|
||||
a.clip(0, 1)
|
||||
}]
|
||||
}, {
|
||||
pairs = pairs ++ [\amp, Pfunc {
|
||||
var base = (spec[\amp] ? 1.0) * volOf.value;
|
||||
list.do { |b| base = base * (1 + (b[\depth] * (~matModSourceVal.(b[\source]) * 2 - 1))).max(0) };
|
||||
base
|
||||
}]
|
||||
}) }) })
|
||||
```
|
||||
becomes (insert a `\res` branch before the amp catch-all):
|
||||
```supercollider
|
||||
(tgt == \rev).if({
|
||||
pairs = pairs ++ [\rev, Pfunc {
|
||||
var a = 0; list.do { |b| a = a + (~matModSourceVal.(b[\source]) * b[\depth]) };
|
||||
a.clip(0, 1)
|
||||
}]
|
||||
}, {
|
||||
(tgt == \res).if({
|
||||
pairs = pairs ++ [\res, Pfunc {
|
||||
var a = 0; list.do { |b| a = a + (~matModSourceVal.(b[\source]) * b[\depth]) };
|
||||
a.clip(0, 1)
|
||||
}]
|
||||
}, {
|
||||
pairs = pairs ++ [\amp, Pfunc {
|
||||
var base = (spec[\amp] ? 1.0) * volOf.value;
|
||||
list.do { |b| base = base * (1 + (b[\depth] * (~matModSourceVal.(b[\source]) * 2 - 1))).max(0) };
|
||||
base
|
||||
}]
|
||||
}) }) }) })
|
||||
```
|
||||
(Note the extra `)` added to close the new nested `.if` — count the closing braces/parens carefully and balance-check.)
|
||||
|
||||
- [ ] **Step 4: Add `\res` to `~matModTargets` for the 10 res-voices.** In the `~matModTargets` IdentityDictionary (matrix.scd ~28-39), append `\res` to each of: `sub, acid, arp, lead, stab, pad, reese, bells, melody, chord`. Example edits:
|
||||
- `\sub->[\amp,\pan,\rev]` → `\sub->[\amp,\pan,\rev,\res]`
|
||||
- `\acid->[\amp,\cutoff,\pan,\rev]` → `\acid->[\amp,\cutoff,\pan,\rev,\res]`
|
||||
- …same for arp, lead, stab, pad, reese (all currently `[\amp,\cutoff,\pan,\rev]` → `+\res`)
|
||||
- `\bells->[\amp,\pan,\rev]` → `+\res`
|
||||
- `\melody->[\amp,\cutoff,\pan,\rev]` → `+\res`; `\chord->[\amp,\cutoff,\pan,\rev]` → `+\res`
|
||||
Do NOT add `\res` to any other voice (perc/tom/kick/etc. stay as-is). Keep the list values consistent with `~matResVoices` (matrix.scd:46).
|
||||
|
||||
- [ ] **Step 5: Balance check.** Run the `validating-scd-files` skill on `matrix.scd` and `test_matrix.scd`. Expected `P:0 B:0` each.
|
||||
|
||||
- [ ] **Step 6: Run to verify it PASSES.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST PASS`, zero `ERROR`, clean exit.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
|
||||
git commit -m "feat(matrix): res modulation target"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `pitch` modulation target (bipolar scale-degree offset)
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` — `~matModPairs` select (exclude pitch), new `~matPitchOffset`/`~matPitchRange`, freq Pfunc (the step-pattern site), `~matModTargets` (+pitch)
|
||||
- Test: `sound_algo/data_only/test/test_matrix.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~matVoiceMods`, `~matModSourceVal`, `~lpNote`, `~matModTargets`.
|
||||
- Produces: `~matPitchOffset.(voiceSymbol)` → integer (bipolar sum of scale-degree offsets); `~matPitchRange` (env-tunable, default 7); the step-pattern `\freq` Pfunc adds the offset to the degree; `~matModTargets[v]` includes `\pitch` for the 10 pitch-capable voices; `~matModPairs` never emits `\pitch`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Add to `test_matrix.scd` after the Task 1 res block:
|
||||
|
||||
```supercollider
|
||||
// --- pitch target (cycle 1): bipolar scale-degree offset ---
|
||||
~matPitchRange = 7; // make the assertion deterministic regardless of env
|
||||
// +full deflection: source 1.0, depth 1.0 -> +7
|
||||
~matVoiceMods[\acid] = [ (source: \rHandY, target: \pitch, depth: 1.0) ];
|
||||
~matModCache[\rHandY] = 1.0;
|
||||
pass = pass and: { ~matPitchOffset.(\acid) == 7 };
|
||||
// -full deflection: source 0.0 -> -7
|
||||
~matModCache[\rHandY] = 0.0;
|
||||
pass = pass and: { ~matPitchOffset.(\acid) == -7 };
|
||||
// neutral: source 0.5 -> 0
|
||||
~matModCache[\rHandY] = 0.5;
|
||||
pass = pass and: { ~matPitchOffset.(\acid) == 0 };
|
||||
// additive: two bindings sum (1.0*+7 and 0.5-source*0 -> +7)
|
||||
~matVoiceMods[\acid] = [ (source: \rHandY, target: \pitch, depth: 1.0),
|
||||
(source: \bodyY, target: \pitch, depth: 1.0) ];
|
||||
~matModCache[\rHandY] = 1.0; ~matModCache[\bodyY] = 0.5; // +7 + 0 = +7
|
||||
pass = pass and: { ~matPitchOffset.(\acid) == 7 };
|
||||
~matVoiceMods[\acid] = [];
|
||||
// gating: pitch allowed on acid (melodic) but NOT on kick (percussive)
|
||||
pass = pass and: { (~matModTargets[\acid] ? []).includes(\pitch) };
|
||||
pass = pass and: { (~matModTargets[\kick] ? []).includes(\pitch).not };
|
||||
// pitch is NOT emitted as a ~matModPairs pair (handled in the freq Pfunc)
|
||||
~matVoiceMods[\acid] = [ (source: \rHandY, target: \pitch, depth: 1.0) ];
|
||||
pass = pass and: { ~matModPairs.(\acid, (amp: 1.0), { 1.0 }).includes(\pitch).not };
|
||||
~matVoiceMods[\acid] = [];
|
||||
// freq integration: with ~lpNote stubbed, a +7 offset transposes the played note up
|
||||
~origLpNote = ~lpNote;
|
||||
~lpNote = { |deg, oct| 60 + deg }; // simple midi-note stub
|
||||
~matVoiceMods[\acid] = [ (source: \rHandY, target: \pitch, depth: 1.0) ];
|
||||
~matModCache[\rHandY] = 1.0; // +7
|
||||
~matColorDefs[5][2][\steps] = Array.fill(16, nil).put(0, (degree: 0, vel: 0.8));
|
||||
~pEv = ~matVariation.(\acid, 2, 5).asStream.next(());
|
||||
pass = pass and: { (~pEv[\freq] - ((60 + 0 + 7).midicps * (2 ** (~matColorDefs[5][2][\octave] ? 0)))).abs < 1.0 };
|
||||
~lpNote = ~origLpNote;
|
||||
~matVoiceMods[\acid] = [];
|
||||
~matColorDefs[5][2][\steps] = Array.fill(16, nil);
|
||||
```
|
||||
(If reading `~matColorDefs[5][2][\octave]` for `freqRatio` proves awkward, assert instead that `~pEv[\freq]` is strictly GREATER than the same event built with offset 0 — a direction check is acceptable and robust. Keep whichever you can make deterministic; do NOT leave an assertion that always passes.)
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST FAIL`/exception — `~matPitchOffset` undefined, `~matModTargets` lacks `\pitch`, freq Pfunc not yet offset.
|
||||
|
||||
- [ ] **Step 3: Add `~matPitchRange` + `~matPitchOffset`.** Near the other env-tunable constants and `~matModSourceVal` (so it is defined before `~matColorStepPattern` uses it; place it just after `~matModSourceVal`'s definition or in the same region as `~matModNeutralCut`). Add:
|
||||
|
||||
```supercollider
|
||||
~matPitchRange = ~matPitchRange ? 7; // +/- scale degrees at full deflection (env-tunable; 7 ~= 1 octave)
|
||||
~matPitchOffset = { |name|
|
||||
var off = 0;
|
||||
((~matVoiceMods[name] ? []).select { |b| b[\target] == \pitch }).do { |b|
|
||||
off = off + (((~matModSourceVal.(b[\source]) * 2 - 1) * b[\depth] * ~matPitchRange).round.asInteger)
|
||||
};
|
||||
off
|
||||
};
|
||||
```
|
||||
Ensure it is defined ABOVE the `~matColorStepPattern` definition (functions are assigned at load; `~matColorStepPattern`'s Pfunc only *calls* `~matPitchOffset` at play time, so definition order is not strictly required, but keep it with the mod helpers for clarity).
|
||||
|
||||
- [ ] **Step 4: Exclude `\pitch` from `~matModPairs`.** In the select filter (matrix.scd ~160-162), add `b[\target] != \pitch`:
|
||||
|
||||
```supercollider
|
||||
((~matVoiceMods[name] ? []).select { |b|
|
||||
b[\source].notNil and: { b[\target].notNil }
|
||||
and: { b[\target] != \pitch } and: { allowed.includes(b[\target]) }
|
||||
}).do { |b| byTarget[b[\target]] = (byTarget[b[\target]] ? []) ++ [b] };
|
||||
```
|
||||
This prevents a `\pitch` binding from falling into the `amp` catch-all.
|
||||
|
||||
- [ ] **Step 5: Patch the step-pattern freq Pfunc.** At the melodic `\freq` Pfunc (matrix.scd ~226), add the offset to the degree:
|
||||
|
||||
Current:
|
||||
```supercollider
|
||||
[\freq, Pfunc { |e| (~lpNote.notNil).if({ ~lpNote.(e[\degree], 0).midicps * freqRatio }, { 440 }) }]
|
||||
```
|
||||
becomes:
|
||||
```supercollider
|
||||
[\freq, Pfunc { |e| (~lpNote.notNil).if({ ~lpNote.(e[\degree] + ~matPitchOffset.(name), 0).midicps * freqRatio }, { 440 }) }]
|
||||
```
|
||||
Do NOT change the overlay-path freq Pfunc (matrix.scd ~205: `(e[\freq] ? 440) * freqRatio`) — it inherits the base pattern's freq and has no degree, so scale-quantized pitch does not apply there. Add a one-line comment at ~205 noting pitch mod is step-path only.
|
||||
|
||||
- [ ] **Step 6: Add `\pitch` to `~matModTargets` for the 10 pitch-capable voices.** Append `\pitch` to each of: `sub, acid, arp, lead, stab, pad, reese, bells, perc, tom`. After Task 1 these read e.g. `\sub->[\amp,\pan,\rev,\res]` → `\sub->[\amp,\pan,\rev,\res,\pitch]`; `\perc->[\amp,\cutoff,\pan,\rev]` → `+\pitch` (perc gets pitch, no res); `\tom->[\amp,\pan,\rev]` → `+\pitch`. Do NOT add `\pitch` to melody/chord (override path, no scale-degree freq) or to any non-melodic voice.
|
||||
|
||||
- [ ] **Step 7: Balance check.** `validating-scd-files` on `matrix.scd` and `test_matrix.scd`. Expected `P:0 B:0` each.
|
||||
|
||||
- [ ] **Step 8: Run to verify it PASSES.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/test/test_matrix.scd`
|
||||
Expected: `TEST PASS`, zero `ERROR`, clean exit.
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/test/test_matrix.scd
|
||||
git commit -m "feat(matrix): pitch modulation target"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Web mirror — target lists + labels
|
||||
|
||||
**Files:**
|
||||
- Modify: `web_realart/public/control/js/matrix-state.js` — `MATRIX_MOD_TARGETS`, `MOD_TARGET_LABELS`
|
||||
- Test: `web_realart/test/matrix-state.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: `MATRIX_MOD_TARGETS[v]` includes `"res"`/`"pitch"` matching SC eligibility; `MOD_TARGET_LABELS` has `pitch` and `res` entries.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Append to `web_realart/test/matrix-state.test.mjs` (match its `import assert from "node:assert"` style; add `MATRIX_MOD_TARGETS` / `MOD_TARGET_LABELS` to the existing import from `../public/control/js/matrix-state.js` if not already imported):
|
||||
|
||||
```javascript
|
||||
test("res target on res-voices only", () => {
|
||||
for (const v of ["sub","acid","arp","lead","stab","pad","reese","bells","melody","chord"])
|
||||
assert.ok(MATRIX_MOD_TARGETS[v].includes("res"), `${v} should allow res`);
|
||||
for (const v of ["kick","hats","perc","tom","fx"])
|
||||
assert.ok(!MATRIX_MOD_TARGETS[v].includes("res"), `${v} should NOT allow res`);
|
||||
});
|
||||
test("pitch target on melodic step voices only", () => {
|
||||
for (const v of ["sub","acid","arp","lead","stab","pad","reese","bells","perc","tom"])
|
||||
assert.ok(MATRIX_MOD_TARGETS[v].includes("pitch"), `${v} should allow pitch`);
|
||||
for (const v of ["kick","hats","clap","melody","chord","fx"])
|
||||
assert.ok(!MATRIX_MOD_TARGETS[v].includes("pitch"), `${v} should NOT allow pitch`);
|
||||
});
|
||||
test("MOD_TARGET_LABELS covers pitch and res", () => {
|
||||
assert.ok(MOD_TARGET_LABELS.pitch);
|
||||
assert.ok(MOD_TARGET_LABELS.res);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it FAILS.**
|
||||
|
||||
Run: `cd web_realart && node --test test/*.test.mjs`
|
||||
Expected: the 3 new tests FAIL (targets/labels missing); existing tests still pass.
|
||||
|
||||
- [ ] **Step 3: Update `MATRIX_MOD_TARGETS`.** In `matrix-state.js`, append to each voice's list to mirror SC exactly:
|
||||
- `+ "res"` on: sub, acid, arp, lead, stab, pad, reese, bells, melody, chord.
|
||||
- `+ "pitch"` on: sub, acid, arp, lead, stab, pad, reese, bells, perc, tom.
|
||||
Example: `acid:["none","amp","cutoff","pan","rev"]` → `acid:["none","amp","cutoff","pan","rev","res","pitch"]`; `perc:["none","amp","cutoff","pan","rev"]` → `+"pitch"`; `melody:["none","amp","cutoff","pan","rev"]` → `+"res"`; `tom:["none","amp","pan","rev"]` → `+"pitch"`; `sub:["none","amp","pan","rev"]` → `+"res","pitch"`. Leave kick/hats/clap/ride/rim/sweep/fx/snare/crash/shaker unchanged.
|
||||
|
||||
- [ ] **Step 4: Update `MOD_TARGET_LABELS`.** Add entries:
|
||||
```javascript
|
||||
export const MOD_TARGET_LABELS = { none:"—", amp:"volume", cutoff:"cutoff", pan:"pan", rev:"reverb", res:"résonance", pitch:"pitch" };
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it PASSES.**
|
||||
|
||||
Run: `cd web_realart && node --test test/*.test.mjs`
|
||||
Expected: all pass (existing + 3 new).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "feat(web): pitch and res mod targets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- §1 res target (independent additive Pbind key, res-voices) → Task 1. ✓
|
||||
- §2 pitch target (bipolar scale-degree offset, freq Pfunc, melodic voices, excluded from matModPairs) → Task 2. ✓
|
||||
- §2 "apply to every freq Pfunc / patch the one true site" → Task 2 Step 5 patches the step-pattern site and documents the overlay site is n/a. ✓
|
||||
- §3 gating via `~matModTargets` (no new gate) → Tasks 1/2 extend `~matModTargets`; existing `~matSetVoiceMod` enforces. ✓
|
||||
- §4 web labels + targets → Task 3. ✓
|
||||
- §Testing → Tasks 1/2 (SC), Task 3 (web). ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. The freq-integration test gives a deterministic assertion AND a robust direction-check fallback (explicitly "do not leave an assertion that always passes"). The `\res` branch edit shows the exact before/after with a brace-balance caution.
|
||||
|
||||
**Type consistency:** `~matPitchOffset.(name)` returns an integer, added to `e[\degree]` (integer) — consistent. `~matModTargets` symbols (`\res`,`\pitch`) ↔ web strings (`"res"`,`"pitch"`) ↔ `MOD_TARGET_LABELS` keys — consistent. Eligibility sets (pitch=10 freq voices, res=10 res-voices) are identical between the SC tasks, the web task, and the spec's eligibility table. `~matModPairs` excludes `\pitch` (Task 2 Step 4) so it never reaches the amp catch-all; `\res` has its own branch (Task 1 Step 3) before the catch-all.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Matrix presets v4 — intro/live/outro + families — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline) — creative musical content (56 hand-written arcs + ~38 pattern families); subagent delegation can't judge musicality. The author with full context writes it; the validity/section-shape/SC-load tests + the user's ear are the gates. Checkbox (`- [ ]`) steps.
|
||||
|
||||
**Goal:** Regenerate the matrix presets as 84 files — each of the 28 morceaux split into intro/live/outro (live = existing arc, intro+outro = hand-written) — and hand-author new pattern families (dub/psy/house/ambient + drum families).
|
||||
|
||||
**Architecture:** Restructure `generate_presets.py` from a flat `PRESETS` dict to a `MORCEAUX` dict carrying 3 section arcs per morceau; `main()` emits `<name>_<section>.matrix` (84). 56 new fully-independent `arc_intro_<m>`/`arc_outro_<m>` functions. New 6-colour families added to `DRUMS`/`MEL` in `patterns.py`. Tests gate validity/section-shape/family-contrast + SC loads all 84.
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`), the rhythm DSL + `_mel`, SC validation harness.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: **uv** only. No emojis in code/docs/commits. Commits: subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no underscore in commit scope.
|
||||
- 84 presets = 28 morceaux × {intro, live, outro}. `live` = the morceau's existing arc (unchanged). `intro`/`outro` = 56 fully-independent hand-written arc functions (no shared helper). Naming `<morceau>_<section>`.
|
||||
- Grid = 22 voices × 64 bars, cells 0-6. Section semantics: intro builds (sparse→full, voices enter), live = full groove, outro breaks down (full→sparse, voices exit).
|
||||
- Hand-authored 6-colour pattern families (NOT derived); existing families + every `default` untouched (SP-C1 derivation stays for `default`).
|
||||
- SC validation: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd` (no `timeout`). Push only on user request; macm1 rig is up for batch ear review.
|
||||
|
||||
## Existing structure (generate_presets.py)
|
||||
|
||||
- 8 shared arcs: `arc_four_floor` (+`ff(True)`), `arc_rolling`, `arc_acid`, `arc_breaks`, `arc_electro`, `arc_trance`, `arc_ambient`, `arc_industrial`.
|
||||
- `PRESETS = { name: (arc_fn, instkit, melodies, extra) }` (28). `kit(...)`, `ex_*`. `to_sc(name, arc_fn, instkit, melodies, extra)` → one `.matrix`. `main()` loops + writes + `validate_kit`.
|
||||
- `blank()` = empty 22×64 grid; `apply_colors(g)` = the section-colour overlay (cols 2-6 by section). The arcs fill grid cells with colour 1 then `apply_colors` adds variation; `~matMigrateGrid`/`emit` downstream.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `generate_presets.py` | `MORCEAUX` dict; 56 `arc_intro_*`/`arc_outro_*`; `main()` emits 84; `PRESET_FAMILY` per 84 (in patterns.py) |
|
||||
| `patterns.py` | new hand-authored `DRUMS`/`MEL` families; `PRESET_FAMILY` for the 84 names |
|
||||
| `test_families.py` | (exists) family validity/contrast |
|
||||
| `test_sections.py` | create — section-shape + 84-count |
|
||||
| `*.matrix` | regenerated (84) |
|
||||
| `launcher/concert/launch_concert.sh` | `MATRIX_PRESET` default → `techno_drive_live` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: generator restructure → MORCEAUX, emit 84, section-shape test
|
||||
|
||||
**Files:** Modify `generate_presets.py`; create `test_sections.py`.
|
||||
|
||||
**Interfaces:** Produces `MORCEAUX = { name: dict(live=arc, intro=arc, outro=arc, instkit, melodies, extra) }`; `main()` writes `<name>_<sec>.matrix` for sec in (intro,live,outro). `arc_intro_*`/`arc_outro_*` initially alias the live arc (placeholder) so 84 files generate; the section-shape test then drives authoring.
|
||||
|
||||
- [ ] **Step 1: Write the failing section test** `test_sections.py`:
|
||||
|
||||
```python
|
||||
"""Section-shape checks: each morceau -> intro/live/outro; intro builds, outro
|
||||
breaks down, live is densest. Run: cd .../matrix_presets && uv run python test_sections.py"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import generate_presets as G
|
||||
|
||||
ok = True
|
||||
def check(c, m):
|
||||
global ok
|
||||
if not c: ok = False; print("FAIL:", m)
|
||||
|
||||
def grid_of(name, sec):
|
||||
m = G.MORCEAUX[name]
|
||||
g = G.blank(); m[sec](g)
|
||||
if m.get("extra"): m["extra"](g)
|
||||
G.apply_colors(g)
|
||||
return g
|
||||
|
||||
def active_in(g, lo, hi): # count non-zero cells in bar range [lo,hi)
|
||||
return sum(1 for row in g for b in range(lo, hi) if row[b] != 0)
|
||||
|
||||
check(len(G.MORCEAUX) == 28, "28 morceaux, got %d" % len(G.MORCEAUX))
|
||||
for name, m in G.MORCEAUX.items():
|
||||
for sec in ("intro", "live", "outro"):
|
||||
check(sec in m and callable(m[sec]), "%s missing %s arc" % (name, sec))
|
||||
intro, live, outro = grid_of(name, "intro"), grid_of(name, "live"), grid_of(name, "outro")
|
||||
# intro builds: last quarter denser than first quarter
|
||||
check(active_in(intro, 48, 64) > active_in(intro, 0, 16), "%s intro not building" % name)
|
||||
# outro breaks down: first quarter denser than last quarter
|
||||
check(active_in(outro, 0, 16) > active_in(outro, 48, 64), "%s outro not breaking down" % name)
|
||||
# live is the densest section overall
|
||||
check(active_in(live, 0, 64) >= active_in(intro, 0, 64)
|
||||
and active_in(live, 0, 64) >= active_in(outro, 0, 64), "%s live not densest" % name)
|
||||
|
||||
print("TEST PASS" if ok else "TEST FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restructure `generate_presets.py`.** Replace the `PRESETS` dict with `MORCEAUX` — keep each morceau's existing arc as `live`, add `intro`/`outro` keys initially set to the live arc (placeholders, replaced in Tasks 3-N):
|
||||
```python
|
||||
MORCEAUX = {
|
||||
"techno_drive": dict(live=arc_four_floor, intro=arc_four_floor, outro=arc_four_floor,
|
||||
instkit=kit(K_909, {"sub":"lp_sub","chord":"juno"}),
|
||||
melodies={"sub": BASS_TECHNO}, extra=None),
|
||||
# ... all 28, live = the current arc, intro=outro=live for now ...
|
||||
}
|
||||
```
|
||||
Update `main()` to loop sections:
|
||||
```python
|
||||
for name, m in MORCEAUX.items():
|
||||
for sec in ("intro", "live", "outro"):
|
||||
sc = to_sc(f"{name}_{sec}", m[sec], m["instkit"], m["melodies"], m.get("extra"))
|
||||
# write to <name>_<sec>.matrix
|
||||
```
|
||||
(Carry over `validate_kit`. Keep `to_sc`'s signature.)
|
||||
|
||||
- [ ] **Step 3: Run section test — expect FAIL** (intro/outro alias live → not building/breaking).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_sections.py`
|
||||
Expected: `TEST FAIL` — `intro not building` / `outro not breaking down` for all morceaux (placeholders). The 28-count + per-section-present checks pass.
|
||||
|
||||
- [ ] **Step 4: Regenerate (84 files) + SC load.**
|
||||
|
||||
Run: `uv run python generate_presets.py` → 84 `.matrix`. Then `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd` → `VALIDATE PASS` (84/84) — the placeholder triplets all load.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_sections.py sound_algo/data_only/matrix_presets/*.matrix
|
||||
git commit -m "feat(presets): morceaux x intro/live/outro skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: hand-authored pattern families (merged SP-C2)
|
||||
|
||||
**Files:** Modify `patterns.py` (`DRUMS`/`MEL`); `test_families.py` (exists).
|
||||
|
||||
- [ ] **Step 1: Author the families.** Add explicit 6-colour entries to `DRUMS`/`MEL` per the SP-C2 matrix (mel `dub`: reese/pad/chord/bells/melody; `psy`: sub/reese/arp/lead/stab; `house`: pad/chord/stab/bells/lead/melody; `ambient`: pad/bells/melody/sub/reese — sub `dub`+acid `psy` already exist; drum `ride/shaker/crash/rim/fx/sweep` families + complete techno/breaks/dnb). Each = 6 hand-written colours (base/busier/broken/double/sparse/fill, humanized vels). Existing families + `default` untouched.
|
||||
|
||||
- [ ] **Step 2: Run the family harness.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_families.py`
|
||||
Expected: `TEST PASS` — every NEW (voice, family) present, 6 valid colours, ≥4 distinct; existing untouched.
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/patterns.py
|
||||
git commit -m "feat(presets): hand-authored pattern families"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tasks 3-9: author intro/outro arcs by style batch
|
||||
|
||||
Author the 56 independent `arc_intro_<m>`/`arc_outro_<m>` functions and point each morceau's `intro`/`outro` at them, in style batches. Each batch: write the arcs (intro builds voices in over 64 bars; outro drops them out), point `MORCEAUX[name]["intro"|"outro"]` at the new fns, run `test_sections.py` (those morceaux now pass building/breaking), regenerate, SC load, commit.
|
||||
|
||||
Arc-writing convention (grid `g` is 22×64; `VIDX` maps voice→row; set cells to colour 1, `apply_colors` adds variation): an intro enters voices on bar boundaries (e.g. kick from 0, hats +8, bass +16, pad +32, lead +48) and is fullest in its last 16 bars; an outro starts full and removes voices toward the end (sparsest in its last 16 bars). Reuse the morceau's voice set (from its live arc / instkit).
|
||||
|
||||
- [ ] **Task 3 — four-on-the-floor (5):** techno_drive, detroit_soul, peak_drop, rave_stab, dub_techno. Author `arc_intro_*`/`arc_outro_*` for each; wire; `test_sections.py` PASS for these; regen; SC load; commit `feat(presets): four-floor intro/outro arcs`.
|
||||
- [ ] **Task 4 — acid (2):** acid_journey, schranz_drive. Same flow; commit `feat(presets): acid intro/outro arcs`.
|
||||
- [ ] **Task 5 — trance/melodic (5):** trance_euphoria, melodic_builder, melodic_deep, progressive_lift, hypnotic_arp. Commit `feat(presets): trance intro/outro arcs`.
|
||||
- [ ] **Task 6 — psy/rolling/minimal (2):** psytrance_roll, minimal_hypno. Commit `feat(presets): psy intro/outro arcs`.
|
||||
- [ ] **Task 7 — breaks/dnb (7):** breakbeat, bass_science, jungle_amen, dnb_roller, footwork_chop, neuro_reese, tribal_perc. Commit `feat(presets): breaks intro/outro arcs`.
|
||||
- [ ] **Task 8 — electro/ebm (4):** electro_808, ebm_body, synthwave_drive, electro_funk. Commit `feat(presets): electro intro/outro arcs`.
|
||||
- [ ] **Task 9 — industrial+ambient (3):** industrial_grind, hardcore_punk, ambient_intro. Commit `feat(presets): industrial/ambient intro/outro arcs`.
|
||||
|
||||
Each task's gate: `uv run python test_sections.py` (the batch's morceaux pass), `uv run python generate_presets.py`, `sclang validate_presets.scd` → 84/84.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: family wiring + final regen + validate + launch default
|
||||
|
||||
**Files:** `patterns.py` (`PRESET_FAMILY`), `launcher/concert/launch_concert.sh`, regenerated `*.matrix`.
|
||||
|
||||
- [ ] **Step 1: Wire `PRESET_FAMILY` for the 84 names.** Map each `<morceau>_<section>` to `(drum_family, mel_family)` so genre presets use the new families (e.g. `dub_techno_* -> ("techno","dub")`, `psytrance_roll_* -> (drum,"psy")`, house/electro presets -> mel `house`, `ambient_intro_* -> (drum,"ambient")`). A section inherits its morceau's families. Unmapped → `("default","default")`.
|
||||
|
||||
- [ ] **Step 2: Full run.** `uv run python test_families.py` PASS · `uv run python test_sections.py` PASS · `uv run python generate_presets.py` (84, deterministic — 2nd run zero diff) · `sclang validate_presets.scd` → `VALIDATE PASS` 84/84.
|
||||
|
||||
- [ ] **Step 3: launch default → live.** In `launch_concert.sh`, change `MATRIX_PRESET="${MATRIX_PRESET:-techno_drive}"` to `:-techno_drive_live`.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets launcher/concert/launch_concert.sh
|
||||
git commit -m "feat(presets): wire families, 84 presets, live default"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: User batch ear review.** Push (on request) + macm1 pull; reload `<morceau>_intro/live/outro` of several morceaux on the live rig; iterate on weak arcs/families.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** 84 = 28×{intro,live,outro} → Task 1 + 3-9. Live = existing arc → Task 1. 56 independent intro/outro arcs → Tasks 3-9. Hand-authored families → Task 2. PRESET_FAMILY wiring → Task 10. 84/84 SC load → each task. launch default → Task 10. User ear review → Task 10 Step 5. Section semantics (intro builds/outro breaks/live densest) → `test_sections.py`. ✓
|
||||
|
||||
**Placeholder scan:** The 56 arc bodies + the family patterns are creative content authored at execution (inline); the plan supplies the restructure (complete code), both test harnesses (complete), the arc-writing + family conventions, and the morceau batches. Mechanical parts have no TBD. The section-shape + family-validity tests are the automated gates; musicality is the user's ear (Task 10 Step 5).
|
||||
|
||||
**Type consistency:** `MORCEAUX[name]` = dict(live/intro/outro=callable(g), instkit=dict, melodies=dict, extra=callable|None) — consumed by `to_sc(name, arc, instkit, melodies, extra)` (unchanged signature) and `test_sections.py` (`m[sec]`, `m["extra"]`). Section names `intro/live/outro` consistent across `main()`, MORCEAUX, and the tests. Family shapes per `test_families.py` (drum=6×16-char strings, mel=6×`_mel` lists).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
# ARKit-only body position 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:** Drive the matrix's body position/modulation 100% from the iPhone ARKit skeleton (2D + 3D) and cut the Mac MediaPipe pose/face inference under `--iphone-usb`.
|
||||
|
||||
**Architecture:** The iPhone already computes a 2D-projected skeleton for its overlay; we send it over USB as a new `skeleton2D` AVLiveWire frame. The Mac decodes it into `persons_arkit_2d`, builds the MP33 body (2D image-normalized) + `bodies3d` (3D world, from the existing 3D `skeleton`) from the ARKit joints, skips MediaPipe pose/face/hand inference, and emits `/pose/*` from the ARKit body. `arkit_fuse` is dropped (ARKit is the source).
|
||||
|
||||
**Tech Stack:** Swift (AVLiveWire package + the iPhone Swift app), Python 3 (data_only_viz), `swift test` + `pytest`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Commit subject ≤ 50 chars, no underscore in scope, no AI attribution, no `--no-verify`. No emojis. Keep `git commit` on its OWN command line (compound `grep && git commit` trips the block-no-verify hook).
|
||||
- 91 ARKit joints (`JOINT_COUNT = 91`). The matrix is single-person.
|
||||
- The 2D skeleton is sent VIEWPORT-NORMALIZED (0..1), `x` = left→right, `y` = top→down (image convention), matching MediaPipe's normalized landmark space so `/pose/center` etc. read identically.
|
||||
- The iPhone app rebuild (`xcodegen generate` → Xcode → ⌘R on the device) is a MANUAL USER STEP — the agent cannot build/deploy Swift on the device. The Mac pure-function parts ARE unit-tested here; the live integration is verified on the rig after the rebuild.
|
||||
- `swift test` runs in `shared/AVLiveWire` (the wire package) and is testable in this environment. The iPhone app Swift is NOT buildable here (needs ARKit/device).
|
||||
- ARKit→MP33 mapping in `arkit_joint_map.py` (`ARKIT91_TO_MP33`, 14 body slots). Extend with a head/neck → MP33 slot 0 anchor.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift` — add `skeleton2D = 5` tag.
|
||||
- `shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift` — add `Skeleton2DPayload` (91×(x,y) Float32 + 91 valid UInt8) encode/decode.
|
||||
- `shared/AVLiveWire/Tests/AVLiveWireTests/` — round-trip test for the new payload.
|
||||
- `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift` — send the 2D skeleton.
|
||||
- `data_only_viz/scripts/iphone_usb_bridge.py` — `TAG_SKELETON2D` + `decode_skeleton2D`.
|
||||
- `data_only_viz/iphone_usb_source.py` — handle `TAG_SKELETON2D` → `state.persons_arkit_2d`.
|
||||
- `data_only_viz/state.py` — `persons_arkit_2d` / `persons_arkit_2d_t` fields.
|
||||
- `data_only_viz/arkit_joint_map.py` — `arkit_body_from_2d` / `arkit_body3d_from_world` builders + head mapping.
|
||||
- `data_only_viz/multi.py` — under `--iphone-usb`, build the body from ARKit + skip MediaPipe pose/face.
|
||||
- `data_only_viz/tests/test_arkit_body.py` — builder unit tests.
|
||||
- `launcher/concert/launch_concert.sh` — drop `arkit_fuse` from POSE_FILTER.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: AVLiveWire — `skeleton2D` tag + payload (Swift, testable)
|
||||
|
||||
**Files:** Modify `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift`, `WirePayloads.swift`; add a test in `shared/AVLiveWire/Tests/AVLiveWireTests/`.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `FrameTag.skeleton2D` (raw 5); `Skeleton2DPayload` with `static let jointCount = 91`, `var points: [SIMD2<Float>]`, `var valid: [Bool]`, `func encoded() -> Data`, `static func decode(_:) -> Skeleton2DPayload?`. Wire layout: 91×(x:Float32,y:Float32) big-endian then 91×UInt8 valid.
|
||||
|
||||
- [ ] **Step 1: Read the existing `SkeletonPayload`** in `WirePayloads.swift` to mirror its style (it has `jointCount`, `joints: [SIMD3<Float>]`, `valid`, `encoded()`, `decode`). Then add a failing round-trip test in a new/existing test file:
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import AVLiveWire
|
||||
|
||||
final class Skeleton2DPayloadTests: XCTestCase {
|
||||
func testRoundTrip() {
|
||||
var p = Skeleton2DPayload()
|
||||
for i in 0..<Skeleton2DPayload.jointCount {
|
||||
p.points[i] = SIMD2(Float(i) * 0.001, Float(i) * 0.002)
|
||||
p.valid[i] = (i % 2 == 0)
|
||||
}
|
||||
let data = p.encoded()
|
||||
let r = Skeleton2DPayload.decode(data)
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.points[10].x, p.points[10].x, accuracy: 1e-6)
|
||||
XCTAssertEqual(r!.valid[10], p.valid[10])
|
||||
XCTAssertEqual(r!.valid[11], p.valid[11])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`Skeleton2DPayload` undefined).
|
||||
Run: `cd shared/AVLiveWire && swift test --filter Skeleton2DPayloadTests`
|
||||
Expected: build error / FAIL.
|
||||
|
||||
- [ ] **Step 3: Add the tag.** In `FrameHeader.swift`, add to `FrameTag`:
|
||||
```swift
|
||||
case skeleton2D = 5
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `Skeleton2DPayload`** in `WirePayloads.swift`, mirroring `SkeletonPayload` but 2 floats/joint:
|
||||
```swift
|
||||
public struct Skeleton2DPayload {
|
||||
public static let jointCount = 91
|
||||
public var points: [SIMD2<Float>]
|
||||
public var valid: [Bool]
|
||||
public init() {
|
||||
points = Array(repeating: SIMD2<Float>(0, 0), count: Self.jointCount)
|
||||
valid = Array(repeating: false, count: Self.jointCount)
|
||||
}
|
||||
public func encoded() -> Data {
|
||||
var d = Data(capacity: Self.jointCount * 8 + Self.jointCount)
|
||||
for p in points {
|
||||
for v in [p.x, p.y] { d.append(contentsOf: withUnsafeBytes(of: v.bitPattern.bigEndian) { Data($0) }) }
|
||||
}
|
||||
for b in valid { d.append(b ? 1 : 0) }
|
||||
return d
|
||||
}
|
||||
public static func decode(_ data: Data) -> Skeleton2DPayload? {
|
||||
let floatBytes = jointCount * 2 * 4
|
||||
guard data.count == floatBytes + jointCount else { return nil }
|
||||
var out = Skeleton2DPayload()
|
||||
let bytes = [UInt8](data)
|
||||
for i in 0..<jointCount {
|
||||
func f(_ off: Int) -> Float {
|
||||
let b = (UInt32(bytes[off]) << 24) | (UInt32(bytes[off+1]) << 16) | (UInt32(bytes[off+2]) << 8) | UInt32(bytes[off+3])
|
||||
return Float(bitPattern: b)
|
||||
}
|
||||
out.points[i] = SIMD2(f(i*8), f(i*8 + 4))
|
||||
out.valid[i] = bytes[floatBytes + i] != 0
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
```
|
||||
(If `SkeletonPayload` uses a different encoding helper, MATCH its style — the round-trip test is the gate, not this exact code.)
|
||||
|
||||
- [ ] **Step 5: Run — expect PASS.**
|
||||
Run: `cd shared/AVLiveWire && swift test --filter Skeleton2DPayloadTests` (and `swift test` for the full suite — no regression).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift shared/AVLiveWire/Tests/AVLiveWireTests
|
||||
git commit -m "feat(wire): skeleton2D frame payload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: iPhone app — send the 2D skeleton (Swift, device-only)
|
||||
|
||||
**Files:** Modify `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FrameTag.skeleton2D`, `Skeleton2DPayload` (Task 1); the app's `skeleton2D`/`projectPoint` viewport projection.
|
||||
- Produces: a `usb.send(tag: .skeleton2D, ...)` per tracked body, points normalized 0..1 by viewport.
|
||||
|
||||
NOTE: this Swift is NOT buildable in the agent environment (needs ARKit/device). The implementer writes it from the brief; the USER rebuilds (`xcodegen generate` → Xcode → ⌘R) and verifies on device.
|
||||
|
||||
- [ ] **Step 1: Send the 2D skeleton in `publishUSB`** (after the existing 3D `.skeleton` send). The 91 viewport-projected points already exist via `ARCamera.projectPoint`; build a `Skeleton2DPayload` with `x = projected.x / viewportSize.width`, `y = projected.y / viewportSize.height` (clamped finite; `valid[i] = isJointTracked(i) && finite`), and send it:
|
||||
```swift
|
||||
var p2 = Skeleton2DPayload()
|
||||
let orient = currentInterfaceOrientation()
|
||||
for i in 0..<min(Skeleton2DPayload.jointCount, transforms.count) {
|
||||
let w = root * transforms[i]
|
||||
let p3 = SIMD3<Float>(w.columns.3.x, w.columns.3.y, w.columns.3.z)
|
||||
let proj = camera.projectPoint(p3, orientation: orient, viewportSize: viewportSize)
|
||||
let nx = Float(proj.x / max(viewportSize.width, 1)),
|
||||
ny = Float(proj.y / max(viewportSize.height, 1))
|
||||
if nx.isFinite && ny.isFinite {
|
||||
p2.points[i] = SIMD2(nx, ny)
|
||||
p2.valid[i] = skeleton.isJointTracked(i)
|
||||
}
|
||||
}
|
||||
usb.send(tag: .skeleton2D, pid: Int16(clamping: pid), timestamp: timestamp, payload: p2.encoded())
|
||||
```
|
||||
(`camera` + `viewportSize` are the same the app uses for its overlay projection — wire them through to `publishUSB`; read the existing `updateSkeleton2D` for how the app gets `camera`/`viewportSize`/`orient`.)
|
||||
|
||||
- [ ] **Step 2: Commit** (the user rebuilds/deploys separately).
|
||||
```bash
|
||||
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
|
||||
git commit -m "feat(iphone): send 2D projected skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Mac decode — `decode_skeleton2D` + state
|
||||
|
||||
**Files:** Modify `data_only_viz/scripts/iphone_usb_bridge.py`, `data_only_viz/iphone_usb_source.py`, `data_only_viz/state.py`; add `data_only_viz/tests/test_arkit_body.py`.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `TAG_SKELETON2D = 5`; `decode_skeleton2D(payload) -> list[(x, y, valid)] | None` (len 91); `state.persons_arkit_2d: dict[int, np.ndarray]` (91×2) + `persons_arkit_2d_t: dict[int, float]`.
|
||||
|
||||
- [ ] **Step 1: Failing test** in `data_only_viz/tests/test_arkit_body.py`:
|
||||
```python
|
||||
import struct
|
||||
from data_only_viz.scripts.iphone_usb_bridge import decode_skeleton2D, JOINT_COUNT
|
||||
|
||||
def test_decode_skeleton2d_roundtrip():
|
||||
floats = []
|
||||
for i in range(JOINT_COUNT):
|
||||
floats += [i * 0.001, i * 0.002]
|
||||
payload = struct.pack(">" + "f" * (JOINT_COUNT * 2), *floats)
|
||||
payload += bytes(1 if i % 2 == 0 else 0 for i in range(JOINT_COUNT))
|
||||
out = decode_skeleton2D(payload)
|
||||
assert out is not None and len(out) == JOINT_COUNT
|
||||
assert abs(out[10][0] - 0.010) < 1e-5 and out[10][2] is True
|
||||
assert out[11][2] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL.**
|
||||
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
|
||||
Expected: ImportError on `decode_skeleton2D`.
|
||||
|
||||
- [ ] **Step 3: Add `TAG_SKELETON2D` + `decode_skeleton2D`** in `iphone_usb_bridge.py` (next to `decode_skeleton`):
|
||||
```python
|
||||
TAG_SKELETON2D = 5
|
||||
SKEL2D_FLOAT_BYTES = JOINT_COUNT * 2 * 4
|
||||
SKEL2D_BYTES = SKEL2D_FLOAT_BYTES + JOINT_COUNT
|
||||
|
||||
|
||||
def decode_skeleton2D(payload: bytes):
|
||||
"""Return [(x, y, valid), ...] of length 91 (normalized 0..1), or None."""
|
||||
if len(payload) != SKEL2D_BYTES:
|
||||
return None
|
||||
floats = struct.unpack(">" + "f" * (JOINT_COUNT * 2), payload[:SKEL2D_FLOAT_BYTES])
|
||||
valid = payload[SKEL2D_FLOAT_BYTES:]
|
||||
return [(floats[i * 2], floats[i * 2 + 1], valid[i] != 0) for i in range(JOINT_COUNT)]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the state fields** in `state.py` (near `persons_arkit_joints`):
|
||||
```python
|
||||
persons_arkit_2d: dict[int, "np.ndarray"] = field(default_factory=dict)
|
||||
persons_arkit_2d_t: dict[int, float] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Handle `TAG_SKELETON2D` in `iphone_usb_source`** (mirror the `TAG_HANDS`/skeleton handlers; import `TAG_SKELETON2D, decode_skeleton2D`):
|
||||
```python
|
||||
elif tag == TAG_SKELETON2D and self.state is not None:
|
||||
pts = decode_skeleton2D(payload)
|
||||
if pts is not None:
|
||||
import numpy as np
|
||||
arr = np.array([[x, y] for (x, y, _v) in pts], dtype=np.float32)
|
||||
with self.state.lock():
|
||||
self.state.persons_arkit_2d[pid] = arr
|
||||
self.state.persons_arkit_2d_t[pid] = time.perf_counter()
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run — expect PASS.**
|
||||
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add data_only_viz/scripts/iphone_usb_bridge.py data_only_viz/iphone_usb_source.py data_only_viz/state.py data_only_viz/tests/test_arkit_body.py
|
||||
git commit -m "feat(pose): decode iphone 2D skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Mac body-build from ARKit + cut MediaPipe
|
||||
|
||||
**Files:** Modify `data_only_viz/arkit_joint_map.py` (builders + head map), `data_only_viz/multi.py` (use them under iphone-usb, skip MP pose/face); extend `data_only_viz/tests/test_arkit_body.py`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ARKIT91_TO_MP33`, `persons_arkit_2d`, `persons_arkit_joints`.
|
||||
- Produces: `arkit_body_2d(arr2d) -> list[PoseKp]` (MP33, image-normalized, c=1 for mapped slots else 0), `arkit_body_3d(arr3d) -> list[Kp3D]`; `ARKIT91_TO_MP33` gains a head→0 entry.
|
||||
|
||||
- [ ] **Step 1: Failing builder test** — append to `test_arkit_body.py`:
|
||||
```python
|
||||
import numpy as np
|
||||
from data_only_viz.arkit_joint_map import arkit_body_2d, ARKIT91_TO_MP33
|
||||
|
||||
def test_arkit_body_2d_maps_slots():
|
||||
arr = np.zeros((91, 2), dtype=np.float32)
|
||||
for ai, _mp in ARKIT91_TO_MP33:
|
||||
arr[ai] = [0.3 + ai * 0.001, 0.4]
|
||||
body = arkit_body_2d(arr)
|
||||
assert len(body) == 33
|
||||
# a mapped slot is confident; an unmapped face slot is not
|
||||
mapped_mp = ARKIT91_TO_MP33[0][1]
|
||||
assert body[mapped_mp].c > 0.5
|
||||
assert body[1].c < 0.5 # MP slot 1 (eye) has no ARKit source
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`arkit_body_2d` undefined).
|
||||
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py::test_arkit_body_2d_maps_slots -q`
|
||||
|
||||
- [ ] **Step 3: Add the head map + builders** in `arkit_joint_map.py`. Add a head anchor (the ARKit `head_joint` index — find it in the Apple enum; commonly index 51 in the 91-list; CONFIRM by checking which ARKit index sits above the shoulders in a live frame, else use the neck/spine7. Use `(51, 0)` as the head→nose anchor and note it for live verification). Append it to `ARKIT91_TO_MP33`. Then:
|
||||
```python
|
||||
from .pose_filter import PoseKp # if PoseKp lives there; else from .state
|
||||
# (use the project's PoseKp/Kp3D types — read multi.py imports)
|
||||
|
||||
def arkit_body_2d(arr2d):
|
||||
"""91x2 normalized ARKit 2D -> MP33 PoseKp list (mapped slots c=1, else c=0)."""
|
||||
from .state import PoseKp # adjust import to the real location
|
||||
body = [PoseKp(x=0.5, y=0.5, z=0.0, c=0.0) for _ in range(33)]
|
||||
for ai, mp in ARKIT91_TO_MP33:
|
||||
if mp < 33 and ai < len(arr2d):
|
||||
body[mp] = PoseKp(x=float(arr2d[ai][0]), y=float(arr2d[ai][1]), z=0.0, c=1.0)
|
||||
return body
|
||||
|
||||
def arkit_body_3d(arr3d):
|
||||
"""91x3 world ARKit -> MP33 Kp3D list (mapped slots c=1, else c=0)."""
|
||||
from .state import Kp3D # adjust import
|
||||
body = [Kp3D(x=0.0, y=0.0, z=0.0, c=0.0) for _ in range(33)]
|
||||
for ai, mp in ARKIT91_TO_MP33:
|
||||
if mp < 33 and ai < len(arr3d):
|
||||
body[mp] = Kp3D(x=float(arr3d[ai][0]), y=float(arr3d[ai][1]), z=float(arr3d[ai][2]), c=1.0)
|
||||
return body
|
||||
```
|
||||
(Read `multi.py`/`state.py` for the exact `PoseKp`/`Kp3D` constructors and import paths; the test gates correctness.)
|
||||
|
||||
- [ ] **Step 4: Run — expect PASS.**
|
||||
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
|
||||
|
||||
- [ ] **Step 5: Wire into `multi.py` under `--iphone-usb`.** In the per-frame loop: skip the MediaPipe pose+face inference (`pose_res`/`face_res = None if self.iphone_usb else ...`); when `self.iphone_usb`, build `bodies`/`bodies3d` from `state.persons_arkit_2d`/`persons_arkit_joints` (one pid → one body) via `arkit_body_2d`/`arkit_body_3d` instead of the MediaPipe `pose_res` parse; set `faces = []`. Guard the MP-derived `bodies`/`faces` build + the body3d arkit_fuse so they don't run under iphone-usb (the ARKit body is the source). Keep the `/pose/*` emit + the `state` write (now fed by the ARKit body). Read the loop carefully — this mirrors the hands cut (commit `4c0794a`) but for pose+face.
|
||||
|
||||
- [ ] **Step 6: Verify syntax + the builder tests still pass.**
|
||||
`python3 -m py_compile data_only_viz/multi.py` (no output); `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q` (PASS).
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add data_only_viz/arkit_joint_map.py data_only_viz/multi.py data_only_viz/tests/test_arkit_body.py
|
||||
git commit -m "feat(pose): build body from arkit, cut mediapipe pose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Launcher — drop `arkit_fuse`
|
||||
|
||||
**Files:** Modify `launcher/concert/launch_concert.sh`.
|
||||
|
||||
- [ ] **Step 1:** Change `export POSE_FILTER=median+kalman+lookahead+ik+arkit_fuse` to `export POSE_FILTER=median+kalman+lookahead+ik` (ARKit is now the source, not a fuse). Lint: `zsh -n launcher/concert/launch_concert.sh`.
|
||||
|
||||
- [ ] **Step 2: Commit.**
|
||||
```bash
|
||||
git add launcher/concert/launch_concert.sh
|
||||
git commit -m "chore(launcher): drop arkit_fuse, arkit is source"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deploy + live smoke (USER-DRIVEN, iPhone rebuild)
|
||||
|
||||
**Files:** none.
|
||||
|
||||
- [ ] **Step 1:** `git push origin main`; `ssh macm1 'cd ~/Documents/Projets/AV-Live && git pull --ff-only'`. THE USER rebuilds the iPhone app: `cd iphone-arbody && xcodegen generate`, open in Xcode, ⌘R on the connected iPhone (so it sends `skeleton2D`). Then restart the pose pipeline (full launcher restart, or restart `data_only_viz.main` with the env).
|
||||
- [ ] **Step 2:** Confirm: the body position tracks from ARKit (stable, no MediaPipe jitter); `/pose/center`/`kin`/`sho_span`/`limb_span` respond to movement; CPU drops (no MediaPipe pose/face inference); the head anchor (`/pose/head`) is sane (adjust the head ARKit index if off); the face mesh + `/pose/mouth` are gone (accepted). Report to the user; iterate the head index / 2D orientation if the position is mirrored/offset.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** skeleton2D wire tag+payload → Task 1; iPhone send → Task 2; Mac decode+state → Task 3; ARKit body build + cut MediaPipe + head map → Task 4; drop arkit_fuse → Task 5; deploy+iPhone-rebuild+smoke → Task 6. ✓
|
||||
|
||||
**Placeholder scan:** the Swift exact encoding + the `PoseKp`/`Kp3D` import paths + the head ARKit index are flagged "read the existing code / confirm live" rather than guessed — these are genuinely environment-specific and gated by the round-trip/builder tests + the live smoke, not inventable from here. All testable logic has concrete code.
|
||||
|
||||
**Type consistency:** `Skeleton2DPayload` (91×SIMD2 + valid) ↔ `decode_skeleton2D` (91×(x,y,valid)) ↔ `persons_arkit_2d` (91×2 ndarray) ↔ `arkit_body_2d` (33 PoseKp). `FrameTag.skeleton2D = 5` ↔ `TAG_SKELETON2D = 5`. `ARKIT91_TO_MP33` extended with head→0, consumed by both builders. The matrix's `/pose/*` emit is unchanged (it reads `bodies`/`bodies3d`, now ARKit-fed).
|
||||
|
||||
## Note for the controller (live-iteration items)
|
||||
|
||||
These can only be nailed on the rig (after the iPhone rebuild): the head ARKit
|
||||
joint index, the 2D orientation/mirroring (the app's `projectPoint` already
|
||||
handles interface orientation — reuse it), and whether the dropped `arkit_fuse`
|
||||
+ ik filter still feels right. Expect 1-2 iPhone-rebuild iterations.
|
||||
@@ -0,0 +1,774 @@
|
||||
# Phase 1 — Faithful ARKit Skeleton 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:** Render the real ARKit 91-joint body skeleton in `data_only_viz`, driven by a joint topology sent from the iPhone, instead of the degraded 14-segment MediaPipe-33 funnel.
|
||||
|
||||
**Architecture:** The iPhone already streams the full 91-joint 3D + 2D skeleton over AVLiveWire/USB. Phase 1 adds one new self-describing frame (`topology` = joint names + parent indices, sent once per connection), then reroutes the Metal renderer to draw all 91 joints from `state.persons_arkit_2d` + that topology — bypassing `arkit_joint_map.ARKIT91_TO_MP33`. The sound/matrix path (`multi.py` → `/pose/*` OSC) is untouched: it keeps using the MP33 reduction. The new renderer path is behind an env flag (`ARKIT_FULL_SKELETON`, default on) with fallback to the old MP33 rendering.
|
||||
|
||||
**Tech Stack:** Swift 5.10 (iPhone + `shared/AVLiveWire` SwiftPM package, XCTest), Python 3.11+ via `uv` (`data_only_viz`, pytest, pyobjc Metal), AVLiveWire framed TCP over usbmuxd.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: use `uv` exclusively (never pip/poetry/conda). Run tests with `cd data_only_viz && uv run pytest tests/ -v`.
|
||||
- Swift package tests: `cd shared/AVLiveWire && swift test --filter <TestClass>`.
|
||||
- No emojis in code, docs, or commits.
|
||||
- Commits: subject <= 50 chars, body <= 72 chars/line, no AI attribution, no `--no-verify`, no underscore in the commit scope (hooks enforce).
|
||||
- This phase keeps AVLiveWire magic `AVL1` and the 19-byte header unchanged. `topology` is added as a new tag (7) on the existing v1 framing — non-breaking. The full v2 header (`AVL2`) is Phase 2, out of scope here.
|
||||
- Do NOT modify the sound/matrix path. `multi.py` keeps calling `arkit_joint_map.arkit_body_2d/3d` to feed `state.persons_body` / `persons_body3d` for the `/pose/*` OSC sound bridge. Only the renderer changes.
|
||||
- Shared state in `data_only_viz` is mutated only under `with state.lock():`.
|
||||
- No `print` in the render loop — use the module logger.
|
||||
- The iPhone must run in LANDSCAPE for ARKit body tracking (portrait spams `ABPKPersonIDTracker: Portrait image is not supported`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: AVLiveWire `topology` tag + `TopologyPayload`
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift:3-10`
|
||||
- Modify: `shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift` (append new struct)
|
||||
- Test: `shared/AVLiveWire/Tests/AVLiveWireTests/TopologyPayloadTests.swift`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FrameTag`, `Data.appendBE`, `UInt16(bigEndianBytes:)` (existing in `FrameHeader.swift`).
|
||||
- Produces:
|
||||
- `FrameTag.topology` (raw value `7`).
|
||||
- `TopologyPayload(jointNames: [String], parents: [Int16])` with `func encoded() -> Data` and `init?(decoding: Data)`.
|
||||
- Wire layout (big-endian): `u16 jointCount`, then per joint `u8 nameLen` + `nameLen` UTF-8 bytes, then per joint `i16 parent` (-1 = root).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `shared/AVLiveWire/Tests/AVLiveWireTests/TopologyPayloadTests.swift`:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import AVLiveWire
|
||||
|
||||
final class TopologyPayloadTests: XCTestCase {
|
||||
func testFrameTagTopology() {
|
||||
XCTAssertEqual(FrameTag.topology.rawValue, 7)
|
||||
}
|
||||
|
||||
func testRoundTrip() {
|
||||
let names = ["root_joint", "hips_joint", "left_arm_joint"]
|
||||
let parents: [Int16] = [-1, 0, 1]
|
||||
let p = TopologyPayload(jointNames: names, parents: parents)
|
||||
let r = TopologyPayload(decoding: p.encoded())
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.jointNames, names)
|
||||
XCTAssertEqual(r!.parents, parents)
|
||||
}
|
||||
|
||||
func testRejectsTruncated() {
|
||||
// Claims 3 joints but provides no body.
|
||||
let bytes: [UInt8] = [0x00, 0x03]
|
||||
XCTAssertNil(TopologyPayload(decoding: Data(bytes)))
|
||||
}
|
||||
|
||||
func testEmpty() {
|
||||
let p = TopologyPayload(jointNames: [], parents: [])
|
||||
let r = TopologyPayload(decoding: p.encoded())
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.jointNames, [])
|
||||
XCTAssertEqual(r!.parents, [])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd shared/AVLiveWire && swift test --filter TopologyPayloadTests`
|
||||
Expected: COMPILE FAILURE — `FrameTag.topology` and `TopologyPayload` are undefined.
|
||||
|
||||
- [ ] **Step 3: Add the `topology` tag**
|
||||
|
||||
In `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift`, change the enum (lines 3-10):
|
||||
|
||||
```swift
|
||||
public enum FrameTag: UInt8 {
|
||||
case skeleton = 1
|
||||
case video = 2
|
||||
case meta = 3
|
||||
case hands = 4
|
||||
case face = 5
|
||||
case skeleton2D = 6
|
||||
case topology = 7
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `TopologyPayload`**
|
||||
|
||||
Append to `shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift`:
|
||||
|
||||
```swift
|
||||
/// Self-describing skeleton topology: joint names + parent indices.
|
||||
/// Sent once per connection so consumers draw bones generically
|
||||
/// (parent -> child) with no hardcoded joint mapping.
|
||||
/// Layout (big-endian): u16 jointCount; per joint [u8 nameLen + utf8];
|
||||
/// then per joint i16 parent (-1 = root).
|
||||
public struct TopologyPayload: Equatable {
|
||||
public var jointNames: [String]
|
||||
public var parents: [Int16]
|
||||
public init(jointNames: [String], parents: [Int16]) {
|
||||
self.jointNames = jointNames
|
||||
self.parents = parents
|
||||
}
|
||||
|
||||
public func encoded() -> Data {
|
||||
var d = Data()
|
||||
d.appendBE(UInt16(min(jointNames.count, 65535)))
|
||||
for name in jointNames {
|
||||
let bytes = Array(name.utf8.prefix(255))
|
||||
d.append(UInt8(bytes.count))
|
||||
d.append(contentsOf: bytes)
|
||||
}
|
||||
for p in parents { d.appendBE(UInt16(bitPattern: p)) }
|
||||
return d
|
||||
}
|
||||
|
||||
public init?(decoding data: Data) {
|
||||
let b = [UInt8](data)
|
||||
guard b.count >= 2 else { return nil }
|
||||
let n = Int(UInt16(bigEndianBytes: b[0..<2]))
|
||||
var o = 2
|
||||
var names: [String] = []
|
||||
for _ in 0..<n {
|
||||
guard o < b.count else { return nil }
|
||||
let len = Int(b[o]); o += 1
|
||||
guard o + len <= b.count else { return nil }
|
||||
names.append(String(decoding: b[o..<o+len], as: UTF8.self))
|
||||
o += len
|
||||
}
|
||||
guard o + n * 2 <= b.count else { return nil }
|
||||
var ps: [Int16] = []
|
||||
for _ in 0..<n {
|
||||
ps.append(Int16(bitPattern: UInt16(bigEndianBytes: b[o..<o+2])))
|
||||
o += 2
|
||||
}
|
||||
jointNames = names
|
||||
parents = ps
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd shared/AVLiveWire && swift test --filter TopologyPayloadTests`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift \
|
||||
shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift \
|
||||
shared/AVLiveWire/Tests/AVLiveWireTests/TopologyPayloadTests.swift
|
||||
git commit -m "feat(wire): topology payload + tag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: iPhone emits topology once on connect
|
||||
|
||||
**Files:**
|
||||
- Modify: `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift` (init closure ~lines 66-81, add `publishTopology()` near `publishUSB()` ~line 299)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ARSkeletonDefinition.defaultBody3D.jointNames` (`[String]`), `bodyParents` (`[Int]`, existing member), `usb.send(tag:pid:timestamp:payload:)`, `TopologyPayload` (Task 1).
|
||||
- Produces: a `.topology` frame (`pid: -1`) sent on each transition into `.connected`.
|
||||
|
||||
This task is device/integration wiring; the encodable unit (`TopologyPayload`) is already tested in Task 1. Verification here is a successful build plus the on-device receive check in Task 7.
|
||||
|
||||
- [ ] **Step 1: Track the previous USB state and emit on transition**
|
||||
|
||||
In `ARBodySession.swift`, the `init()` closure currently is:
|
||||
|
||||
```swift
|
||||
usb.onState = { [weak self] s in
|
||||
Task { @MainActor in self?.usbState = s }
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```swift
|
||||
usb.onState = { [weak self] s in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let was = self.usbState
|
||||
self.usbState = s
|
||||
if s == .connected && was != .connected {
|
||||
self.publishTopology()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `publishTopology()`**
|
||||
|
||||
Add this method next to `publishUSB(...)` (after it, around line 339):
|
||||
|
||||
```swift
|
||||
/// Send the ARKit body joint names + parent indices once, so the Mac
|
||||
/// renders bones generically. Static data — available before any body
|
||||
/// is tracked, so it is safe to emit right after connect.
|
||||
@MainActor
|
||||
private func publishTopology() {
|
||||
guard usbState == .connected else { return }
|
||||
let def = ARSkeletonDefinition.defaultBody3D
|
||||
let names = def.jointNames
|
||||
let parents = def.parentIndices.map { Int16(clamping: $0) }
|
||||
let payload = TopologyPayload(jointNames: names, parents: parents)
|
||||
usb.send(tag: .topology, pid: -1,
|
||||
timestamp: lastFrameTime,
|
||||
payload: payload.encoded())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the app to verify it compiles**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /Users/electron/Documents/Projets/AV-Live/iphone-arbody && \
|
||||
xcodegen generate && \
|
||||
xcodebuild -project ARBodyTracker.xcodeproj -scheme ARBodyTracker \
|
||||
-configuration Debug -destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -5
|
||||
```
|
||||
Expected: `** BUILD SUCCEEDED **` (the `nonisolated(unsafe)` Vision warning may remain; no new errors).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
|
||||
git commit -m "feat(iphone): emit skeleton topology on connect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Python `decode_topology` + `encode_topology`
|
||||
|
||||
**Files:**
|
||||
- Create: `data_only_viz/arkit_topology.py`
|
||||
- Test: `data_only_viz/tests/test_arkit_topology.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `decode_topology(payload: bytes) -> tuple[list[str], list[int]] | None` — mirror of the Swift `TopologyPayload` layout.
|
||||
- `encode_topology(names: list[str], parents: list[int]) -> bytes` — used by tests and downstream consumers.
|
||||
- Consumes: nothing (pure stdlib `struct`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_arkit_topology.py`:
|
||||
|
||||
```python
|
||||
"""AVLiveWire topology payload round-trip (mirror of Swift TopologyPayload)."""
|
||||
from data_only_viz.arkit_topology import decode_topology, encode_topology
|
||||
|
||||
|
||||
def test_round_trip():
|
||||
names = ["root_joint", "hips_joint", "left_arm_joint"]
|
||||
parents = [-1, 0, 1]
|
||||
payload = encode_topology(names, parents)
|
||||
out = decode_topology(payload)
|
||||
assert out is not None
|
||||
got_names, got_parents = out
|
||||
assert got_names == names
|
||||
assert got_parents == parents
|
||||
|
||||
|
||||
def test_empty():
|
||||
payload = encode_topology([], [])
|
||||
out = decode_topology(payload)
|
||||
assert out == ([], [])
|
||||
|
||||
|
||||
def test_rejects_truncated():
|
||||
# Claims 3 joints, provides no body.
|
||||
assert decode_topology(b"\x00\x03") is None
|
||||
|
||||
|
||||
def test_rejects_too_short():
|
||||
assert decode_topology(b"\x00") is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_topology.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: data_only_viz.arkit_topology`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `data_only_viz/arkit_topology.py`:
|
||||
|
||||
```python
|
||||
"""AVLiveWire topology payload codec.
|
||||
|
||||
Mirror of the Swift `TopologyPayload` (shared/AVLiveWire). Layout
|
||||
(big-endian): u16 jointCount; per joint [u8 nameLen + utf8 name];
|
||||
then per joint i16 parent (-1 = root).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
|
||||
def encode_topology(names: list[str], parents: list[int]) -> bytes:
|
||||
out = bytearray()
|
||||
out += struct.pack(">H", len(names))
|
||||
for nm in names:
|
||||
b = nm.encode("utf-8")[:255]
|
||||
out.append(len(b))
|
||||
out += b
|
||||
for p in parents:
|
||||
out += struct.pack(">h", p)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_topology(payload: bytes):
|
||||
"""Return (joint_names, parents) or None if malformed."""
|
||||
if len(payload) < 2:
|
||||
return None
|
||||
n = struct.unpack(">H", payload[:2])[0]
|
||||
o = 2
|
||||
names: list[str] = []
|
||||
for _ in range(n):
|
||||
if o >= len(payload):
|
||||
return None
|
||||
ln = payload[o]
|
||||
o += 1
|
||||
if o + ln > len(payload):
|
||||
return None
|
||||
names.append(payload[o:o + ln].decode("utf-8", "replace"))
|
||||
o += ln
|
||||
if o + n * 2 > len(payload):
|
||||
return None
|
||||
parents = list(struct.unpack(">" + "h" * n, payload[o:o + n * 2]))
|
||||
return names, parents
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_topology.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/arkit_topology.py data_only_viz/tests/test_arkit_topology.py
|
||||
git commit -m "feat(presets): python topology codec"
|
||||
```
|
||||
|
||||
Note: scope `presets` avoids an underscore-bearing scope; `data_only_viz` would need a no-underscore alias. Use `iphone` if the hooks reject `presets` here:
|
||||
`git commit -m "feat(iphone): python topology codec"`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Store topology + 2D validity in shared state
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/state.py` (add fields near `persons_arkit_2d`, ~lines 149-150)
|
||||
- Modify: `data_only_viz/iphone_usb_source.py` (constants ~lines 26-31; tag dispatch ~lines 152-169)
|
||||
- Test: `data_only_viz/tests/test_arkit_topology_state.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `decode_topology` (Task 3), `decode_skeleton2D` (existing import), `state.lock()`.
|
||||
- Produces, on the shared `State`:
|
||||
- `arkit_joint_names: list[str]`
|
||||
- `arkit_parents: list[int]`
|
||||
- `persons_arkit_2d_valid: dict[int, np.ndarray]` (bool array, shape `(91,)`)
|
||||
- module constant `TAG_TOPOLOGY = 7` in `iphone_usb_source.py`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_arkit_topology_state.py`:
|
||||
|
||||
```python
|
||||
"""State carries ARKit topology + 2D validity for the renderer."""
|
||||
from data_only_viz.state import State
|
||||
|
||||
|
||||
def test_state_has_topology_fields():
|
||||
s = State()
|
||||
assert s.arkit_joint_names == []
|
||||
assert s.arkit_parents == []
|
||||
assert s.persons_arkit_2d_valid == {}
|
||||
|
||||
|
||||
def test_topology_tag_constant():
|
||||
from data_only_viz.iphone_usb_source import TAG_TOPOLOGY
|
||||
assert TAG_TOPOLOGY == 7
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_topology_state.py -v`
|
||||
Expected: FAIL — `AttributeError` on `arkit_joint_names` / `ImportError` on `TAG_TOPOLOGY`.
|
||||
|
||||
- [ ] **Step 3: Add the state fields**
|
||||
|
||||
In `data_only_viz/state.py`, next to the existing `persons_arkit_2d` fields:
|
||||
|
||||
```python
|
||||
persons_arkit_2d: dict[int, "np.ndarray"] = field(default_factory=dict)
|
||||
persons_arkit_2d_t: dict[int, float] = field(default_factory=dict)
|
||||
persons_arkit_2d_valid: dict = field(default_factory=dict)
|
||||
arkit_joint_names: list = field(default_factory=list)
|
||||
arkit_parents: list = field(default_factory=list)
|
||||
```
|
||||
|
||||
(The first two lines already exist — add the three new ones beneath them.)
|
||||
|
||||
- [ ] **Step 4: Wire the source — constant, import, 2D validity, topology dispatch**
|
||||
|
||||
In `data_only_viz/iphone_usb_source.py`, add the tag constant near the other `TAG_*`/`_ARKIT_JOINTS` constants (~lines 26-31):
|
||||
|
||||
```python
|
||||
TAG_TOPOLOGY = 7
|
||||
```
|
||||
|
||||
Add the import near the existing decode imports (top of file, where `decode_skeleton`/`decode_skeleton2D` are imported):
|
||||
|
||||
```python
|
||||
from .arkit_topology import decode_topology
|
||||
```
|
||||
|
||||
Replace the `TAG_SKELETON2D` branch (currently stores only points) with one that also stores validity:
|
||||
|
||||
```python
|
||||
elif tag == TAG_SKELETON2D and self.state is not None:
|
||||
pts = decode_skeleton2D(payload)
|
||||
if pts is not None:
|
||||
arr = np.array([[x, y] for (x, y, _v) in pts],
|
||||
dtype=np.float32)
|
||||
valid = np.array([v for (_x, _y, v) in pts],
|
||||
dtype=bool)
|
||||
with self.state.lock():
|
||||
self.state.persons_arkit_2d[pid] = arr
|
||||
self.state.persons_arkit_2d_valid[pid] = valid
|
||||
self.state.persons_arkit_2d_t[pid] = \
|
||||
time.perf_counter()
|
||||
```
|
||||
|
||||
Add a new branch handling the topology tag (place it right after the `TAG_SKELETON2D` branch):
|
||||
|
||||
```python
|
||||
elif tag == TAG_TOPOLOGY and self.state is not None:
|
||||
topo = decode_topology(payload)
|
||||
if topo is not None:
|
||||
names, parents = topo
|
||||
with self.state.lock():
|
||||
self.state.arkit_joint_names = names
|
||||
self.state.arkit_parents = parents
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_topology_state.py -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/state.py data_only_viz/iphone_usb_source.py \
|
||||
data_only_viz/tests/test_arkit_topology_state.py
|
||||
git commit -m "feat(iphone): store topology + 2d validity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Pure ARKit skeleton segment builder
|
||||
|
||||
**Files:**
|
||||
- Create: `data_only_viz/arkit_skeleton.py`
|
||||
- Test: `data_only_viz/tests/test_arkit_skeleton.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `bones_from_parents(parents: list[int]) -> list[tuple[int, int]]` — `(child, parent)` pairs where parent is a valid index.
|
||||
- `arkit_segments(arr2d, valid, parents) -> list[tuple[float, float, float, float]]` — `(ax, ay, bx, by)` per drawable bone, in normalized `[0,1]` image coords; skips bones whose either endpoint is invalid.
|
||||
- Consumes: nothing (pure; `arr2d` is any `(N,2)` indexable, `valid` is `(N,)` or `None`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_arkit_skeleton.py`:
|
||||
|
||||
```python
|
||||
"""Pure ARKit skeleton -> line segment builder."""
|
||||
from data_only_viz.arkit_skeleton import arkit_segments, bones_from_parents
|
||||
|
||||
|
||||
def test_bones_skip_root():
|
||||
parents = [-1, 0, 1]
|
||||
assert bones_from_parents(parents) == [(1, 0), (2, 1)]
|
||||
|
||||
|
||||
def test_bones_skip_out_of_range():
|
||||
parents = [-1, 0, 99] # 99 has no joint
|
||||
assert bones_from_parents(parents) == [(1, 0)]
|
||||
|
||||
|
||||
def test_segments_basic():
|
||||
arr2d = [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0)]
|
||||
valid = [True, True, True]
|
||||
parents = [-1, 0, 1]
|
||||
segs = arkit_segments(arr2d, valid, parents)
|
||||
assert segs == [(0.5, 0.5, 0.0, 0.0), (1.0, 1.0, 0.5, 0.5)]
|
||||
|
||||
|
||||
def test_segments_drop_invalid_endpoint():
|
||||
arr2d = [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0)]
|
||||
valid = [True, False, True] # joint 1 invalid
|
||||
parents = [-1, 0, 1]
|
||||
# bone (1,0) drops (1 invalid); bone (2,1) drops (1 invalid)
|
||||
assert arkit_segments(arr2d, valid, parents) == []
|
||||
|
||||
|
||||
def test_segments_valid_none_keeps_all():
|
||||
arr2d = [(0.0, 0.0), (0.5, 0.5)]
|
||||
parents = [-1, 0]
|
||||
assert arkit_segments(arr2d, None, parents) == [(0.5, 0.5, 0.0, 0.0)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_skeleton.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: data_only_viz.arkit_skeleton`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `data_only_viz/arkit_skeleton.py`:
|
||||
|
||||
```python
|
||||
"""Build line segments from a full ARKit skeleton + parent topology.
|
||||
|
||||
Pure functions, no Metal/state dependency, so they are unit-testable.
|
||||
The renderer feeds the resulting (ax, ay, bx, by) tuples straight into
|
||||
the GPU line buffer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def bones_from_parents(parents: list[int]) -> list[tuple[int, int]]:
|
||||
"""(child, parent) pairs for every joint with a valid parent."""
|
||||
n = len(parents)
|
||||
return [(i, p) for i, p in enumerate(parents) if 0 <= p < n]
|
||||
|
||||
|
||||
def arkit_segments(arr2d, valid, parents):
|
||||
"""Return (ax, ay, bx, by) for each bone with both endpoints valid.
|
||||
|
||||
arr2d: indexable of (x, y) normalized [0,1], length == len(parents).
|
||||
valid: indexable of bool (length == len(parents)) or None to keep all.
|
||||
parents: parent index per joint (-1 = root).
|
||||
"""
|
||||
n = len(parents)
|
||||
segs: list[tuple[float, float, float, float]] = []
|
||||
for child, parent in bones_from_parents(parents):
|
||||
if child >= n or parent >= n:
|
||||
continue
|
||||
if valid is not None and (not valid[child] or not valid[parent]):
|
||||
continue
|
||||
ax, ay = float(arr2d[child][0]), float(arr2d[child][1])
|
||||
bx, by = float(arr2d[parent][0]), float(arr2d[parent][1])
|
||||
segs.append((ax, ay, bx, by))
|
||||
return segs
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_arkit_skeleton.py -v`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/arkit_skeleton.py data_only_viz/tests/test_arkit_skeleton.py
|
||||
git commit -m "feat(iphone): arkit skeleton segment builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Renderer draws the full 91-joint body
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/renderer.py` (module-level flag near the top imports; `_update_skeleton` ~lines 311-396)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `arkit_segments` (Task 5); `state.arkit_parents`, `state.persons_arkit_2d`, `state.persons_arkit_2d_valid` (Task 4); existing `self._skel_cpu_buf`, `SKEL_MAX_SEGS`.
|
||||
- Produces: when `ARKIT_FULL_SKELETON` is on and topology+2D are present, the skeleton line buffer is filled from all 91 ARKit joints instead of the MP33 body; hands and face keep their existing rendering. Falls back to the MP33 body loop otherwise.
|
||||
|
||||
This task wires the (already unit-tested) segment builder into the Metal renderer. Metal cannot run headless, so the deliverable is verified on device in Task 7 via the `render: N segs` log (it jumps from ~14 to ~90) and the flag-off fallback.
|
||||
|
||||
- [ ] **Step 1: Add the env flag**
|
||||
|
||||
At the top of `data_only_viz/renderer.py`, near the other imports/module constants, add:
|
||||
|
||||
```python
|
||||
import os
|
||||
from .arkit_skeleton import arkit_segments
|
||||
|
||||
# Draw the full ARKit 91-joint body (topology-driven) instead of the
|
||||
# MP33-reduced body. Set ARKIT_FULL_SKELETON=0 to fall back to MP33.
|
||||
ARKIT_FULL = os.environ.get("ARKIT_FULL_SKELETON", "1") != "0"
|
||||
```
|
||||
|
||||
(If `import os` already exists at the top, do not duplicate it.)
|
||||
|
||||
- [ ] **Step 2: Compute the ARKit mode and guard early-return**
|
||||
|
||||
In `_update_skeleton`, replace the opening guard:
|
||||
|
||||
```python
|
||||
if not s.pose_alive():
|
||||
return 0
|
||||
|
||||
buf = self._skel_cpu_buf
|
||||
segs = 0
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
use_arkit = (ARKIT_FULL and bool(s.arkit_parents)
|
||||
and bool(s.persons_arkit_2d))
|
||||
if not use_arkit and not s.pose_alive():
|
||||
return 0
|
||||
|
||||
buf = self._skel_cpu_buf
|
||||
segs = 0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a float-segment push helper + draw the ARKit body**
|
||||
|
||||
Immediately after the existing `push(A, B, conf, pid)` closure definition (it ends with `return True`), add the float-based helper and the ARKit draw loop:
|
||||
|
||||
```python
|
||||
def push_seg(ax, ay, bx, by, conf, pid):
|
||||
"""Like push() but takes raw normalized [0,1] coords."""
|
||||
nonlocal segs
|
||||
if segs >= SKEL_MAX_SEGS:
|
||||
return False
|
||||
cax = ax * 2.0 - 1.0; cay = 1.0 - ay * 2.0
|
||||
cbx = bx * 2.0 - 1.0; cby = 1.0 - by * 2.0
|
||||
i = segs * 10
|
||||
buf[i+0] = cax; buf[i+1] = cay; buf[i+2] = 0.0
|
||||
buf[i+3] = conf; buf[i+4] = float(pid)
|
||||
buf[i+5] = cbx; buf[i+6] = cby; buf[i+7] = 0.0
|
||||
buf[i+8] = conf; buf[i+9] = float(pid)
|
||||
segs += 1
|
||||
return True
|
||||
|
||||
if use_arkit:
|
||||
parents = s.arkit_parents
|
||||
for pid, arr2d in s.persons_arkit_2d.items():
|
||||
valid = s.persons_arkit_2d_valid.get(pid)
|
||||
for (ax, ay, bx, by) in arkit_segments(arr2d, valid, parents):
|
||||
if not push_seg(ax, ay, bx, by, 1.0, pid):
|
||||
break
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Skip the MP33 body loop when in ARKit mode**
|
||||
|
||||
In the MediaPipe block, the body loop currently starts:
|
||||
|
||||
```python
|
||||
for i, body_kp in enumerate(s.persons_body):
|
||||
```
|
||||
|
||||
Change it to skip the MP33 body when ARKit drew it (face and hand loops below are unchanged):
|
||||
|
||||
```python
|
||||
for i, body_kp in enumerate([] if use_arkit else s.persons_body):
|
||||
```
|
||||
|
||||
Apply the same `[] if use_arkit else ...` guard to the single-person fallback body block (`if s.body_present:`) so it does not double-draw:
|
||||
|
||||
```python
|
||||
if not (s.persons_body or s.persons_face or s.persons_hands):
|
||||
if s.body_present and not use_arkit:
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Syntax check (no Metal device needed)**
|
||||
|
||||
Run: `cd data_only_viz && uv run python -c "import ast; ast.parse(open('renderer.py').read()); print('ok')"`
|
||||
Expected: `ok`.
|
||||
|
||||
- [ ] **Step 6: Run the full data_only_viz test suite (no regressions)**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/ -v`
|
||||
Expected: PASS (existing suite stays green; the new topology/skeleton tests pass).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/renderer.py
|
||||
git commit -m "feat(iphone): render full arkit body skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: End-to-end verification on device
|
||||
|
||||
**Files:** none (verification + reversibility check).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the full stack (iPhone app rebuilt with Tasks 1-2, `data_only_viz` with Tasks 3-6).
|
||||
|
||||
This task confirms the visible win and the flag fallback. No code change — if a check fails, the failing task is reopened.
|
||||
|
||||
- [ ] **Step 1: Rebuild + run the iPhone app**
|
||||
|
||||
In Xcode (iPhone connected, LANDSCAPE), select `GSM de clemsail` and Run (Cmd-R). Confirm the app connects (USB state -> connected).
|
||||
|
||||
- [ ] **Step 2: Launch the AV-Live stack**
|
||||
|
||||
Run: `open "$HOME/Desktop/AV-Live Concert.app"` and wait ~60 s for boot.
|
||||
|
||||
- [ ] **Step 3: Confirm the full skeleton renders**
|
||||
|
||||
Run: `grep -E "render: .* segs" /tmp/concert_viz.log | tail -3`
|
||||
Expected: the segment count is now ~80-90 (e.g. `render: 88 segs ... body=1`), up from the previous `14 segs`. A person in frame shows a full body skeleton (spine, neck, head, limbs) instead of a 14-segment stick figure.
|
||||
|
||||
- [ ] **Step 4: Confirm the topology arrived**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /Users/electron/Documents/Projets/AV-Live/data_only_viz && \
|
||||
uv run python -c "print('topology decode wired; see /tmp/concert_viz.log for segs')"
|
||||
```
|
||||
And confirm in `/tmp/concert_viz.log` there are no `decode_topology` errors and segments are present.
|
||||
|
||||
- [ ] **Step 5: Verify the fallback flag**
|
||||
|
||||
Stop the stack, then run the viz alone with the flag off:
|
||||
```bash
|
||||
pkill -9 -f data_only_viz.main 2>/dev/null
|
||||
cd /Users/electron/Documents/Projets/AV-Live && \
|
||||
ARKIT_FULL_SKELETON=0 data_only_viz/.venv/bin/python -m data_only_viz.main \
|
||||
--pose --iphone-usb > /tmp/viz_fallback.log 2>&1 &
|
||||
sleep 12 && grep -E "render: .* segs" /tmp/viz_fallback.log | tail -2
|
||||
```
|
||||
Expected: segment count drops back to ~14 (MP33 fallback), proving reversibility. Then kill it: `pkill -9 -f data_only_viz.main`.
|
||||
|
||||
- [ ] **Step 6: Confirm the sound/matrix path is unaffected**
|
||||
|
||||
Run: `grep -E "/pose/|body3d" /tmp/concert_viz.log | tail -5`
|
||||
Expected: `/pose/*` OSC + `body3d: n=1` still emitted (the MP33 reduction in `multi.py` is untouched), so matrix modulation still works.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (Phase 1 rows of the spec):**
|
||||
- "iPhone emits topology" -> Tasks 1-2. Covered.
|
||||
- "data_only_viz new renderer on the 91 decoded joints + hands, topology-driven" -> Tasks 4-6 (body 91-joint; hands keep existing path). Covered.
|
||||
- "bypass MP33 funnel for the iPhone path" -> Task 6 (`use_arkit` skips the MP33 body loop). Covered.
|
||||
- "flag, MP33 fallback" -> `ARKIT_FULL_SKELETON` (Task 6) + verified in Task 7 Step 5. Covered.
|
||||
- "sound/matrix path untouched" -> Global Constraints + Task 7 Step 6. Covered.
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; every code step shows full code; every command has expected output.
|
||||
|
||||
**Type consistency:** `TopologyPayload(jointNames:parents:)` (Swift, Task 1) mirrored by `encode_topology(names, parents)` / `decode_topology -> (names, parents)` (Python, Task 3). `arkit_segments(arr2d, valid, parents)` defined in Task 5, consumed identically in Task 6. State fields `arkit_parents`, `persons_arkit_2d`, `persons_arkit_2d_valid` defined in Task 4, consumed in Task 6. `TAG_TOPOLOGY = 7` matches `FrameTag.topology = 7`.
|
||||
|
||||
**Out-of-scope note (intentional):** hand 3D lifting, unified pose frame, Swift 6.2 actor refactor, AVL2 header, and removing `arkit_joint_map.py` are later phases (2-6), each with its own plan.
|
||||
@@ -0,0 +1,326 @@
|
||||
# Matrix fist-held gestures 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:** A held closed fist fires a global action per hand — left fist → reload the current matrix, right fist → next preset — detected SC-side from hand openness, with a live sensitivity slider and per-hand progress indicators.
|
||||
|
||||
**Architecture:** The `/pose/hands` openness (`lOpen`/`rOpen`, already in `~matModCache`) drives an SC fist edge detector (`~matFistEdge`, hysteresis + presence guard); the existing 30 Hz pinch poller also runs `~matFistTick`, which fires the action once the fist is held ≥ `~matPinchHoldMs` and streams `/matrix/fistprogress`. A `~matFistThresh` sensitivity is a live menu slider, saved per `.matrix`.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), Python 3 (uv), vanilla ES modules + `node --test`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- SC env vars `~xxx` lowercase. `matrix.scd` 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 via uv; web `cd web_realart && node --test test/*.test.mjs` (GLOB). No `data_only_viz` / launcher change (openness already flows). Deploy = SC reboot + web reload (NO full launcher restart).
|
||||
- Commit subject ≤ 50, no underscore in scope, no AI attribution, no `--no-verify`. No emojis. NOTE: keep `git commit` on its OWN command line (a compound `grep ... && git commit` trips the block-no-verify hook).
|
||||
- Fist: hand 0 = screen-left (`lOpen`, `x=lHandX`), hand 1 = screen-right. Engage `openness <= ~matFistThresh`, release `openness >= ~matFistThresh + 0.15`, presence `x > 0.001`. Hold reuses `~matPinchHoldMs`. `~matFistThresh` default 0.15, clamp 0.05..0.5, saved per `.matrix` as top-level `fistThresh`. pointG (hand 0) = reload current (`~matCurrentName`); pointD (hand 1) = `~matNextPreset`.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `sound_algo/data_only/matrix.scd` — `~matFistThresh`/state/`~matFistEdge`/`~matFistTick`/`~matFistReloadCurrent`/`~matFistThreshPush`; `/pose/hands` edge calls; poller adds `~matFistTick`; `/matrix/fisthresh[/get]`; `/matrix/fistprogress`; save/load.
|
||||
- `sound_algo/data_only/matrix_presets/{generate_presets.py,test_global_actions.{py,scd}}` — `fistThresh` emit + harness Q.
|
||||
- `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: SC — fist detection + hold-to-fire + progress
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` + `sound_algo/data_only/matrix_presets/test_global_actions.scd`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~matModCache`, `~matPinchHoldMs`, `~matCurrentName`, `~matNextPreset`, `~matLoad`, `~matSave`/`~matLoadFile`, `~toscSend`/`~toscTouch`, the pinch poller `~matPinchRoutine`.
|
||||
- Produces: `~matFistThresh`, `~matFistState`/`~matFistHeld`/`~matFistFired`, `~matFistEdge.(hand,x,openness)`, `~matFistTick`, `~matFistReloadCurrent`, `~matFistThreshPush`; `/matrix/fisthresh[/get]` set+echo; `/matrix/fistprogress [hand, prog]`; `fistThresh` saved per `.matrix`.
|
||||
|
||||
- [ ] **Step 1: Append failing harness group Q** to `test_global_actions.scd`, before the final `pass.if(...)`:
|
||||
```supercollider
|
||||
// -- Q: fist hold-to-fire (engage records, tick fires, release/absent cancel, threshold, save) --
|
||||
~matPinchRoutine !? { ~matPinchRoutine.stop };
|
||||
~matPinchHoldMs = 300; ~matFistThresh = 0.15;
|
||||
~matFistState = Array.fill(2, \unknown); ~matFistHeld = Array.fill(2, nil); ~matFistFired = Array.fill(2, false);
|
||||
~loadedFist = nil; ~matCurrentName = \zztest_morceau;
|
||||
~matLoadReal = ~matLoad; ~matLoad = { |n| ~loadedFist = n.asSymbol };
|
||||
~matFistEdge.(0, 0.5, 0.05); // L fist engage -> held, no fire
|
||||
(~matFistHeld[0].isNil).if({ pass = false; "FAIL: L fist engage not held".postln });
|
||||
~matFistTick.();
|
||||
(~loadedFist.notNil).if({ pass = false; "FAIL: L fist fired before hold".postln });
|
||||
~matFistHeld[0] = Main.elapsedTime - 0.5; // 500ms held (> 300)
|
||||
~matFistTick.();
|
||||
(~loadedFist == \zztest_morceau).not.if({ pass = false; "FAIL: L fist hold did not reload current".postln });
|
||||
~loadedFist = nil; ~matFistState = Array.fill(2, \unknown); ~matFistHeld = Array.fill(2, nil); ~matFistFired = Array.fill(2, false);
|
||||
~matFistEdge.(1, 0.5, 0.05); // R fist engage
|
||||
~matFistEdge.(1, 0.5, 0.40); // open (>= 0.30) -> release
|
||||
~matFistTick.();
|
||||
(~matFistHeld[1].isNil).not.if({ pass = false; "FAIL: R fist release did not clear".postln });
|
||||
~matFistState = Array.fill(2, \unknown); ~matFistHeld = Array.fill(2, nil);
|
||||
~matFistEdge.(0, 0.0, 0.0); // absent hand (x=0, openness 0) must NOT engage
|
||||
(~matFistHeld[0].isNil).not.if({ pass = false; "FAIL: absent hand engaged a fist".postln });
|
||||
OSCdef(\mat_fisthresh).func.value(['/matrix/fisthresh', 0.25]);
|
||||
(~matFistThresh == 0.25).not.if({ pass = false; "FAIL: fisthresh not set".postln });
|
||||
~matFistThresh = 0.15; ~matLoad = ~matLoadReal;
|
||||
~matInstruments = ~matInstruments ? Array.fill(~matVoices.size, { \default });
|
||||
~matColorDefs = ~matColorDefs ? Array.fill(~matVoices.size, { ~matDefaultColorDefs.value });
|
||||
~matSavedNames = ~matSavedNames ? Set.new;
|
||||
~matFistThresh = 0.22; ~matSave.("zz_fth_test"); ~matFistThresh = 0.15;
|
||||
~matLoadFile.(~matDir +/+ "zz_fth_test.matrix");
|
||||
(~matFistThresh == 0.22).not.if({ pass = false; "FAIL: fistThresh not restored".postln });
|
||||
("rm -f '" ++ (~matDir +/+ "zz_fth_test.matrix") ++ "'").systemCmd;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matFistEdge`/`~matFistTick` undefined).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: error / `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Add the fist state + detection + tick.** In the global-actions block (after `~matPinchHoldPush` / the pinch state), add:
|
||||
```supercollider
|
||||
~matFistThresh = ~matFistThresh ? 0.15;
|
||||
~matFistState = ~matFistState ? Array.fill(2, \unknown);
|
||||
~matFistHeld = ~matFistHeld ? Array.fill(2, nil);
|
||||
~matFistFired = ~matFistFired ? Array.fill(2, false);
|
||||
~matFistThreshPush = { ~toscSend !? { ~toscSend.("/matrix/fisthresh", ~matFistThresh) } };
|
||||
~matFistReloadCurrent = {
|
||||
~matCurrentName.notNil.if({
|
||||
~matLoad.(~matCurrentName);
|
||||
~toscSend !? { ~toscSend.("/matrix/loaded", ~matCurrentName.asString) };
|
||||
});
|
||||
};
|
||||
// fist edge from openness (hysteresis + presence guard). hand 0 = L, 1 = R.
|
||||
~matFistEdge = { |hand, x, openness|
|
||||
var present = x > 0.001;
|
||||
var prev = ~matFistState[hand] ? \unknown;
|
||||
var cur = present.if({
|
||||
(openness <= ~matFistThresh).if({ \fist },
|
||||
{ (openness >= (~matFistThresh + 0.15)).if({ \open }, { prev }) })
|
||||
}, { \unknown });
|
||||
((prev != \fist) and: { cur == \fist }).if({
|
||||
~matFistHeld[hand] = Main.elapsedTime; ~matFistFired[hand] = false;
|
||||
});
|
||||
((prev == \fist) and: { cur != \fist }).if({
|
||||
~matFistHeld[hand] = nil; ~matFistFired[hand] = false;
|
||||
~toscSend !? { ~toscSend.("/matrix/fistprogress", hand, 0) };
|
||||
});
|
||||
~matFistState[hand] = cur;
|
||||
};
|
||||
~matFistTick = {
|
||||
2.do { |hand|
|
||||
(~matFistHeld[hand].notNil).if({
|
||||
var prog = (((Main.elapsedTime - ~matFistHeld[hand]) * 1000) / ~matPinchHoldMs).clip(0, 1);
|
||||
~toscSend !? { ~toscSend.("/matrix/fistprogress", hand, prog) };
|
||||
((prog >= 1.0) and: { ~matFistFired[hand].not }).if({
|
||||
~matFistFired[hand] = true;
|
||||
(hand == 0).if({ ~matFistReloadCurrent.() }, { ~matNextPreset.() });
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Call the edge from `/pose/hands` + add to the poller.** In the `\mat_mod_hands` OSCdef, add before its closing `}, '/pose/hands');`:
|
||||
```supercollider
|
||||
~matFistEdge.(0, msg[2] ? 0, msg[4] ? 0);
|
||||
~matFistEdge.(1, msg[6] ? 0, msg[8] ? 0);
|
||||
```
|
||||
Change the pinch poller line to also tick fists. Replace:
|
||||
```supercollider
|
||||
~matPinchRoutine = Routine({ loop { ~matPinchTick.(); (1/30).wait } }).play(AppClock);
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matPinchRoutine = Routine({ loop { ~matPinchTick.(); ~matFistTick.(); (1/30).wait } }).play(AppClock);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: OSC routes.** After the `\mat_pinchhold_get` OSCdef, add:
|
||||
```supercollider
|
||||
OSCdef(\mat_fisthresh, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matFistThresh = (msg[1] ? 0.15).asFloat.clip(0.05, 0.5);
|
||||
~matFistThreshPush.()
|
||||
}, '/matrix/fisthresh');
|
||||
OSCdef(\mat_fisthresh_get, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matFistThreshPush.()
|
||||
}, '/matrix/fisthresh/get');
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Save/load `fistThresh`.** In `~matSave`'s `payload`, add after `pinchHoldMs: ~matPinchHoldMs`:
|
||||
```supercollider
|
||||
pinchHoldMs: ~matPinchHoldMs,
|
||||
fistThresh: ~matFistThresh
|
||||
```
|
||||
In `~matLoadFile`, just after the `pinchHoldMs` restore line, add:
|
||||
```supercollider
|
||||
~matFistThresh = ((raw.isArray.not) and: { raw[\fistThresh].notNil }).if({ raw[\fistThresh].asFloat.clip(0.05, 0.5) }, { 0.15 });
|
||||
```
|
||||
And after `~matPinchHoldPush.();` add `~matFistThreshPush.();`.
|
||||
|
||||
- [ ] **Step 7: Balance + harness.**
|
||||
`awk` balance → `P:0 B:0`; `sclang test_global_actions.scd` → `GLOBALACT PASS` (A–Q).
|
||||
|
||||
- [ ] **Step 8: Commit** (own command line):
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/matrix_presets/test_global_actions.scd
|
||||
git commit -m "feat(matrix): fist-held gestures (reload/next)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generator — emit `fistThresh`
|
||||
|
||||
**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_PHMS` block:
|
||||
```python
|
||||
EXPECT_FTH = "'fistThresh': 0.15"
|
||||
check(EXPECT_FTH in sample, "to_sc output missing fistThresh")
|
||||
missing_fth = [os.path.basename(f) for f in files
|
||||
if EXPECT_FTH not in open(f, encoding="utf-8").read()]
|
||||
check(not missing_fth, "fistThresh missing in: %s" % missing_fth[: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 fistThresh`.
|
||||
|
||||
- [ ] **Step 3: Emit it.** In `to_sc`, change the tail so `pinchHoldMs` ends with a comma and `fistThresh` is last. Replace:
|
||||
```python
|
||||
"'pinchHoldMs': 300\n)\n"
|
||||
```
|
||||
with:
|
||||
```python
|
||||
"'pinchHoldMs': 300,\n"
|
||||
"'fistThresh': 0.15\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** (own command line):
|
||||
```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 fist threshold"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Web — fist sensitivity slider + fist indicators
|
||||
|
||||
**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: SC `/matrix/fisthresh` + `/matrix/fistprogress`.
|
||||
- Produces: `matFistThresh` state + persistence; a "Sensibilité poing" slider; two fist indicator rows with progress fill.
|
||||
|
||||
- [ ] **Step 1: State test (failing).** Append to `matrix-state.test.mjs`:
|
||||
```javascript
|
||||
import { matFistThresh, setFistThresh } from "../public/control/js/matrix-state.js";
|
||||
test("default fist threshold + setter", () => {
|
||||
assert.equal(matFistThresh, 0.15);
|
||||
setFistThresh(0.3);
|
||||
assert.equal(matFistThresh, 0.3);
|
||||
setFistThresh(0.15);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL.**
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: import failure.
|
||||
|
||||
- [ ] **Step 3: Add state.** In `matrix-state.js`, after `matPinchHoldMs`, add:
|
||||
```javascript
|
||||
export let matFistThresh = 0.15;
|
||||
export function setFistThresh(v) { matFistThresh = v; }
|
||||
```
|
||||
In `saveMatState`, add `fth: matFistThresh` to the blob; in `loadMatState`, after the `phms` restore, add:
|
||||
```javascript
|
||||
if (Number.isFinite(raw.fth)) matFistThresh = raw.fth;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Container.** In `index.html`, inside `#morceau-panel` after `#morceau-settings`, add:
|
||||
```html
|
||||
<div class="morceau-title">Poings</div>
|
||||
<div id="morceau-fists" class="morceau-fists"></div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render fist indicators + sensitivity slider in `morceau-panel.js`.** After the Réglages block (and the pinch-hold slider), add:
|
||||
```javascript
|
||||
// --- Sensibilite poing slider (in Reglages) ---
|
||||
if (sHost) {
|
||||
const fRow = document.createElement("label");
|
||||
fRow.className = "morceau-set-row";
|
||||
const fSpan = document.createElement("span"); fSpan.textContent = "Sensibilité poing";
|
||||
const fInp = document.createElement("input");
|
||||
fInp.type = "range"; fInp.min = 0.05; fInp.max = 0.5; fInp.step = 0.01; fInp.value = matFistThresh;
|
||||
const fOut = document.createElement("output"); fOut.textContent = matFistThresh;
|
||||
fInp.addEventListener("input", () => { fOut.textContent = fInp.value; });
|
||||
fInp.addEventListener("change", () => { setFistThresh(+fInp.value); send("/matrix/fisthresh", +fInp.value); saveMatState(); });
|
||||
fRow.append(fSpan, fInp, fOut);
|
||||
sHost.appendChild(fRow);
|
||||
on("/matrix/fisthresh", (args) => {
|
||||
const v = Number(args[0]);
|
||||
if (Number.isFinite(v)) { fInp.value = v; fOut.textContent = v; setFistThresh(v); saveMatState(); }
|
||||
});
|
||||
onOpen(() => send("/matrix/fisthresh/get"));
|
||||
send("/matrix/fisthresh/get");
|
||||
}
|
||||
// --- Fist indicators (hand 0 = Poing G -> charger, hand 1 = Poing D -> suivant) ---
|
||||
const fistHost = document.getElementById("morceau-fists");
|
||||
if (fistHost) {
|
||||
const fistFills = [];
|
||||
[["Poing G → charger"], ["Poing D → suivant"]].forEach((lbl, hand) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "morceau-fist";
|
||||
row.textContent = lbl[0];
|
||||
const fl = document.createElement("div");
|
||||
fl.className = "morceau-slot-fill";
|
||||
row.appendChild(fl);
|
||||
fistFills[hand] = fl;
|
||||
fistHost.appendChild(row);
|
||||
});
|
||||
on("/matrix/fistprogress", (args) => {
|
||||
const hand = Math.round(Number(args[0]));
|
||||
const prog = Number(args[1]) || 0;
|
||||
const fl = fistFills[hand];
|
||||
if (!fl) return;
|
||||
fl.style.width = (prog * 100) + "%";
|
||||
fl.classList.toggle("fired", prog >= 1);
|
||||
});
|
||||
}
|
||||
```
|
||||
Add `matFistThresh, setFistThresh` to the `matrix-state.js` import.
|
||||
|
||||
- [ ] **Step 6: Style.** Append to `control.css`:
|
||||
```css
|
||||
.morceau-fists { display: flex; flex-direction: column; gap: 4px; }
|
||||
.morceau-fist { position: relative; font-size: 12px; color: #ccc; padding: 2px 4px;
|
||||
background: #1c1c1c; border-radius: 4px; }
|
||||
```
|
||||
(`.morceau-slot-fill` + `.fired` already exist from the pinch work.)
|
||||
|
||||
- [ ] **Step 7: 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 8: Commit** (own command line):
|
||||
```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): fist sensitivity slider + indicators"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 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'`. No launcher restart needed — SC-only reboot (kill `sclang boot.scd` + `scsynth`; relaunch `cd ~/Documents/Projets/AV-Live/sound_algo/data_only && MATRIX_ONLY=1 nohup script -q /tmp/sc_boot.log /Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd &`; wait `[matrix] ready`). Hard-reload the web page (the pose pipeline keeps running; openness keeps flowing).
|
||||
- [ ] **Step 2:** Hold a LEFT fist → the "Poing G" cell fills then reloads the current morceau; hold a RIGHT fist → next preset; the "Sensibilité poing" slider changes how low the openness must drop; a brief/open fist does nothing; hands out of frame never trigger. Report to the user.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** fist detection (hysteresis + presence) → Task 1 Step 3-4; hold-to-fire reusing `~matPinchHoldMs` + actions (reload-current / next) → Task 1 Step 3; progress stream → Task 1 Step 3-4; `/matrix/fisthresh` + save/load → Task 1 Steps 5-6; generator `fistThresh` → Task 2; web sensitivity slider + indicators → Task 3; smoke → Task 4. ✓
|
||||
|
||||
**Placeholder scan:** complete code; threshold 0.15/clamp 0.05-0.5, hysteresis +0.15, hand 0/1 indices concrete; no TBD.
|
||||
|
||||
**Type consistency:** `~matFistEdge.(hand, x, openness)` called with L `(0, msg[2], msg[4])` / R `(1, msg[6], msg[8])` (Task 1 Step 4). `~matFistTick` fires hand 0 → `~matFistReloadCurrent` (uses `~matCurrentName` + `~matLoad`), hand 1 → `~matNextPreset` (both exist). `/matrix/fistprogress [hand, prog]` emitted (Task 1) ↔ web `on("/matrix/fistprogress")` `fistFills[hand]` (Task 3). `/matrix/fisthresh` single-Float (SC clamp 0.05-0.5 ↔ web slider 0.05-0.5). `fistThresh` save field consistent SC ↔ generator literal. Hold reuses `~matPinchHoldMs` (no new hold setting). `.morceau-slot-fill`/`.fired` reused from the pinch CSS.
|
||||
@@ -0,0 +1,686 @@
|
||||
# Matrix global morceau actions — Phase 1 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:** Open/close either hand fires a momentary global action on the whole matrix (fill / drop / breakdown / relance+evolve), with a per-morceau gesture→action mapping editable in a new bottom panel of the matrix editor and saved in each `.matrix`.
|
||||
|
||||
**Architecture:** Detect hand open/closed SC-side from the `lOpen`/`rOpen` openness already in `/pose/hands` (hysteresis + presence via `x>0`). A single `~matTransient` override (kind + kept voices + bar countdown), consulted by the per-bar voice sourcing (`~matApplyBar` via `~matEffColor`), expresses all momentary effects; `relance+evolve` is a one-shot. The 4-slot mapping `~matGlobalActions` is stored, OSC-synced (`/matrix/globalaction`), saved/loaded per `.matrix`, generated as a default by `generate_presets.py`, and edited via 4 dropdowns in a new `morceau-panel.js`.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), Python 3 (`uv run python`), vanilla ES modules + `node --test` for the web control surface.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- SC env vars `~xxx` lowercase. `matrix.scd` is a single top-level block `( ... )` — keep parens balanced (`awk` P:0 B:0), no second top-level block.
|
||||
- Python: uv only. No emojis in code/docs/commits. Commit subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no `--no-verify`, no underscore in commit scope.
|
||||
- Slot order is fixed everywhere: `0 = LH open, 1 = LH closed, 2 = RH open, 3 = RH closed`.
|
||||
- Default mapping everywhere: `[fill, drop, evolve, breakdown]` (LH open=fill, LH closed=drop, RH open=evolve, RH closed=breakdown).
|
||||
- Phase-1 constants (Phase 2 turns these into editable settings): `~matOpenThresh = 0.65`, `~matCloseThresh = 0.30`, `~matTransientBars = 1`, `~matBreakdownKeep = [\kick, \sub]`.
|
||||
- `~matTransient` recolours/cuts ACTIVE-cell sourcing only via `~matEffColor`; it never edits `~lp[\matrix]` (the grid stays intact; the override is applied at source time).
|
||||
- No `data_only_viz` changes. The matrix is live on macm1 — push/deploy only on user request.
|
||||
- `/pose/hands` payload: `[addr, 0, lx, ly, lopen, lspeed, rx, ry, ropen, rspeed, dist]` so in the OSCdef `msg`: `lx=msg[2]`, `lopen=msg[4]`, `rx=msg[6]`, `ropen=msg[8]`. Absent hand slot = `[0,0,0,0]` → `x==0.0`; present hand → `x>0`.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `sound_algo/data_only/matrix.scd` — engine: `~matTransient` primitive, `~matEffColor`, `~matApplyBar` integration, `~matSetTransient`, `~matTransientTick` (in `~matPlay` routine), `~matRelanceEvolve`, `~matFireGlobal`, `~matGlobalActions` + defaults, gesture detection `~matHandFlip` in the `/pose/hands` OSCdef, OSC routes `/matrix/globalaction[/get]`, save/load integration.
|
||||
- `sound_algo/data_only/matrix_presets/generate_presets.py` — `to_sc` emits the default `globalActions` field.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.py` (new) — Python: every generated `.matrix` carries the default `globalActions`.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.scd` (new) — SC: transient apply/expire, breakdown keep set, fire dispatch, hand-flip detection, mapping save/load roundtrip.
|
||||
- `web_realart/public/control/js/matrix-state.js` — `GLOBAL_ACTION_CHOICES`, `GLOBAL_SLOTS`, `matGlobalActions`, `setGlobalAction`, `resetGlobalActions`, persistence (`gact`).
|
||||
- `web_realart/public/control/js/morceau-panel.js` (new) — render 4 dropdowns + OSC sync.
|
||||
- `web_realart/public/control/index.html` — `#morceau-panel` container in `#tab-matrix`.
|
||||
- `web_realart/public/control/control.css` — panel styles.
|
||||
- `web_realart/public/control/js/main.js` — import + `initMorceauPanel()`.
|
||||
- `web_realart/test/matrix-state.test.mjs` — extend with global-action coverage.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Engine — transient primitive + core actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` (`~matApplyBar` ~line 297-323; insert a new block after `~matSetEvolve` ~line 369; `~matPlay` routine ~line 430-440).
|
||||
- Create: `sound_algo/data_only/matrix_presets/test_global_actions.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (existing): `~matVoices` (Array of voice-name strings), `~lp[\matrix]` (22×64 Int grid), `~matLastColor` (Array, applied colour per voice), `~matApplyBar.(bar)`, `~matEvolveStep.()`, `~matBaseGrid`, `~lp[\matPlaying]`, `~matBars`.
|
||||
- Produces (for Task 2 + later phases): `~matTransient` (Event `(kind:Symbol, voices:[Symbol], bars:Int)` or `nil`), `~matEffColor.(name, vi, base) -> Int`, `~matSetTransient.(kind, voices)`, `~matTransientTick.()`, `~matRelanceEvolve.()`, `~matFireGlobal.(slot)`, `~matGlobalActions` (Array[4] of Symbol, default `[\fill, \drop, \evolve, \breakdown]`), `~matBreakdownKeep` (`[\kick, \sub]`), `~matTransientBars` (1).
|
||||
|
||||
- [ ] **Step 1: Write the failing SC harness** `sound_algo/data_only/matrix_presets/test_global_actions.scd`
|
||||
|
||||
```supercollider
|
||||
// Headless checks for global morceau actions (Phase 1).
|
||||
// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd
|
||||
(
|
||||
var feat = PathName(thisProcess.nowExecutingPath).pathOnly ++ "../matrix.scd";
|
||||
var pass = true;
|
||||
~lp = (clock: TempoClock.default); ~toscSend = { |path ...a| }; ~toscTouch = { |a| };
|
||||
["kick","hats","clap","perc","sub","acid","arp","lead","stab","pad","ride","rim",
|
||||
"tom","reese","bells","sweep","melody","chord","fx","snare","crash","shaker"].do { |v|
|
||||
Pdef(("lp_" ++ v).asSymbol, Pbind(\instrument, v.asSymbol, \dur, 1)) };
|
||||
feat.load;
|
||||
~lp[\matrix] = Array.fill(~matVoices.size, { Array.fill(~matBars, 1) }); // all active
|
||||
~lp[\matPlaying] = false;
|
||||
|
||||
// -- A: defaults --
|
||||
(~matGlobalActions == [\fill, \drop, \evolve, \breakdown]).not.if({ pass = false; "FAIL: default mapping".postln });
|
||||
|
||||
// -- B: fill -> active voices read colour 6, silent voices stay 0 --
|
||||
~matSetTransient.(\fill, []);
|
||||
(~matEffColor.(\kick, 0, 1) == 6).not.if({ pass = false; "FAIL: fill active->6".postln });
|
||||
(~matEffColor.(\kick, 0, 0) == 0).not.if({ pass = false; "FAIL: fill silent stays 0".postln });
|
||||
|
||||
// -- C: drop -> everything 0 --
|
||||
~matSetTransient.(\drop, []);
|
||||
(~matEffColor.(\kick, 0, 4) == 0).not.if({ pass = false; "FAIL: drop->0".postln });
|
||||
|
||||
// -- D: breakdown -> only kept voices survive --
|
||||
~matSetTransient.(\breakdown, ~matBreakdownKeep);
|
||||
(~matEffColor.(\kick, 0, 3) == 3).not.if({ pass = false; "FAIL: breakdown keeps kick".postln });
|
||||
(~matEffColor.(\hats, 1, 3) == 0).not.if({ pass = false; "FAIL: breakdown cuts hats".postln });
|
||||
|
||||
// -- E: transient expires after ~matTransientBars ticks --
|
||||
~matSetTransient.(\drop, []);
|
||||
~matTransientBars.do { ~matTransientTick.() };
|
||||
(~matTransient.isNil).not.if({ pass = false; "FAIL: transient not cleared after ticks".postln });
|
||||
(~matEffColor.(\kick, 0, 2) == 2).not.if({ pass = false; "FAIL: post-expire normal colour".postln });
|
||||
|
||||
// -- F: ~matFireGlobal dispatches the mapped action --
|
||||
~matGlobalActions = [\fill, \drop, \evolve, \breakdown];
|
||||
~matFireGlobal.(1); // slot 1 = drop
|
||||
(~matTransient.notNil and: { ~matTransient[\kind] == \drop }).not.if({ pass = false; "FAIL: fire slot1->drop".postln });
|
||||
~matTransient = nil;
|
||||
~matFireGlobal.(0); // slot 0 = fill
|
||||
(~matTransient.notNil and: { ~matTransient[\kind] == \fill }).not.if({ pass = false; "FAIL: fire slot0->fill".postln });
|
||||
|
||||
pass.if({ "GLOBALACT PASS".postln }, { "GLOBALACT FAIL".postln });
|
||||
0.exit;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (functions undefined → errors / FAIL).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: errors (e.g. `~matSetTransient` is nil / doesNotUnderstand) or `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Add the global-actions engine block** to `sound_algo/data_only/matrix.scd`, immediately after `~matSetEvolve` (the block ending ~line 369, before the next comment). Paste verbatim:
|
||||
|
||||
```supercollider
|
||||
|
||||
// -- global morceau actions: momentary transient overrides + gesture mapping --
|
||||
~matTransient = ~matTransient ? nil; // (kind:, voices:[Symbol], bars:Int) or nil
|
||||
~matTransientBars = ~matTransientBars ? 1; // Phase 1: 1 bar (Phase 2 reads settings)
|
||||
~matBreakdownKeep = ~matBreakdownKeep ? [\kick, \sub];
|
||||
~matGlobalActions = ~matGlobalActions ? [\fill, \drop, \evolve, \breakdown]; // slots 0..3
|
||||
~matOpenThresh = ~matOpenThresh ? 0.65;
|
||||
~matCloseThresh = ~matCloseThresh ? 0.30;
|
||||
~matHandState = ~matHandState ? IdentityDictionary[\L -> \unknown, \R -> \unknown];
|
||||
|
||||
// effective colour for a voice this bar, given any active transient (grid untouched)
|
||||
~matEffColor = { |name, vi, base|
|
||||
var t = ~matTransient;
|
||||
t.isNil.if({ base }, {
|
||||
var k = t[\kind];
|
||||
(k == \drop).if({ 0 },
|
||||
{ (k == \fill).if({ (base == 0).if({ 0 }, { 6 }) },
|
||||
{ (k == \breakdown).if({ ((t[\voices] ? []).includes(name)).if({ base }, { 0 }) },
|
||||
{ base }) }) })
|
||||
})
|
||||
};
|
||||
|
||||
// set a transient + re-source now (effect lands at the next bar via Pdef quant)
|
||||
~matSetTransient = { |kind, voices|
|
||||
~matTransient = (kind: kind, voices: (voices ? []), bars: ~matTransientBars);
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 };
|
||||
(~lp[\matPlaying] == true).if({ ~matApplyBar.(~lp[\matBar] ? 0) });
|
||||
};
|
||||
|
||||
// count one bar of transient use; clear + force re-source when exhausted (routine-only)
|
||||
~matTransientTick = {
|
||||
~matTransient.notNil.if({
|
||||
~matTransient[\bars] = ~matTransient[\bars] - 1;
|
||||
(~matTransient[\bars] <= 0).if({
|
||||
~matTransient = nil;
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// one mutation from a base snapshot + re-source (the evolve one-shot)
|
||||
~matRelanceEvolve = {
|
||||
~matBaseGrid.isNil.if({ ~matBaseGrid = ~lp[\matrix].collect({ |row| row.copy }) });
|
||||
~matEvolveStep.();
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 };
|
||||
(~lp[\matPlaying] == true).if({ ~matApplyBar.(~lp[\matBar] ? 0) });
|
||||
};
|
||||
|
||||
// dispatch the action mapped to a gesture slot (0=LHopen,1=LHclosed,2=RHopen,3=RHclosed)
|
||||
~matFireGlobal = { |slot|
|
||||
var act = (~matGlobalActions[slot]) ? \none;
|
||||
(act == \fill).if({ ~matSetTransient.(\fill, []) });
|
||||
(act == \drop).if({ ~matSetTransient.(\drop, []) });
|
||||
(act == \breakdown).if({ ~matSetTransient.(\breakdown, ~matBreakdownKeep) });
|
||||
(act == \evolve).if({ ~matRelanceEvolve.() });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Make `~matApplyBar` transient-aware.** In `~matApplyBar` (~line 299), replace the colour line:
|
||||
|
||||
Replace:
|
||||
```supercollider
|
||||
var color = ~lp[\matrix][vi][bar];
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
var color = ~matEffColor.(name, vi, ~lp[\matrix][vi][bar]);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Tick the transient once per bar in the `~matPlay` routine.** In the routine loop (~line 432), insert the tick right after `~matApplyBar.(~lp[\matBar]);`:
|
||||
|
||||
Replace:
|
||||
```supercollider
|
||||
~matApplyBar.(~lp[\matBar]);
|
||||
~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) };
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matApplyBar.(~lp[\matBar]);
|
||||
~matTransientTick.();
|
||||
~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) };
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify paren balance.**
|
||||
|
||||
Run: `awk 'BEGIN{p=0;b=0} {for(i=1;i<=length($0);i++){c=substr($0,i,1); if(c=="(")p++; if(c==")")p--; if(c=="[")b++; if(c=="]")b--}} END{print "P:"p" B:"b}' sound_algo/data_only/matrix.scd`
|
||||
Expected: `P:0 B:0`
|
||||
|
||||
- [ ] **Step 7: Run the SC harness — expect PASS.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: `GLOBALACT PASS`
|
||||
|
||||
- [ ] **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): transient primitive + core global actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Engine — hand-flip detection + OSC + save/load
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix.scd` (`/pose/hands` OSCdef ~line 1078-1087; add `~matHandFlip` + push + OSCdefs near the global-actions block; `~matSave` ~line 660-666; `~matLoadFile` ~line 683-767).
|
||||
- Modify: `sound_algo/data_only/matrix_presets/test_global_actions.scd` (append flip + roundtrip checks).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `~matFireGlobal`, `~matGlobalActions`, `~matOpenThresh`, `~matCloseThresh`, `~matHandState`; existing `~matSave`/`~matLoadFile`/`~matDir`/`~matGridValid`/`~toscSend`.
|
||||
- Produces: `~matHandFlip.(hand, x, openness, openSlot, closeSlot)`, `~matGlobalActionsPush.()`, OSC routes `/matrix/globalaction` (set+echo) and `/matrix/globalaction/get`, and `globalActions` persisted in the `.matrix` payload.
|
||||
|
||||
- [ ] **Step 1: Append failing checks** to `sound_algo/data_only/matrix_presets/test_global_actions.scd`, immediately before the `pass.if(...)` final line:
|
||||
|
||||
```supercollider
|
||||
// -- G: hand-flip detection (no fire on appear/disappear; fire on open<->closed) --
|
||||
~fired = nil; ~matFireGlobal = { |slot| ~fired = slot }; // stub to capture the slot
|
||||
~matHandState[\L] = \unknown;
|
||||
~matHandFlip.(\L, 0.5, 0.9, 0, 1); // appear OPEN -> baseline, no fire
|
||||
(~fired.isNil).not.if({ pass = false; "FAIL: fired on appearance".postln });
|
||||
~matHandFlip.(\L, 0.5, 0.1, 0, 1); // OPEN->CLOSED -> fire closeSlot(1)
|
||||
(~fired == 1).not.if({ pass = false; "FAIL: open->closed did not fire slot1".postln });
|
||||
~fired = nil;
|
||||
~matHandFlip.(\L, 0.5, 0.9, 0, 1); // CLOSED->OPEN -> fire openSlot(0)
|
||||
(~fired == 0).not.if({ pass = false; "FAIL: closed->open did not fire slot0".postln });
|
||||
~fired = nil;
|
||||
~matHandFlip.(\L, 0.0, 0.0, 0, 1); // disappear -> reset, no fire
|
||||
(~fired.isNil).not.if({ pass = false; "FAIL: fired on disappearance".postln });
|
||||
|
||||
// -- H: mapping save/load roundtrip --
|
||||
~matInstruments = ~matInstruments ? Array.fill(~matVoices.size, { \default });
|
||||
~matColorDefs = ~matColorDefs ? Array.fill(~matVoices.size, { ~matDefaultColorDefs.value });
|
||||
~matSavedNames = ~matSavedNames ? Set.new;
|
||||
~matGlobalActions = [\evolve, \breakdown, \fill, \drop]; // non-default
|
||||
~matSave.("zz_gact_test");
|
||||
~matGlobalActions = [\fill, \drop, \evolve, \breakdown]; // clobber, then reload
|
||||
~matLoadFile.(~matDir +/+ "zz_gact_test.matrix");
|
||||
(~matGlobalActions == [\evolve, \breakdown, \fill, \drop]).not.if({ pass = false; "FAIL: globalActions not round-tripped".postln });
|
||||
("rm -f '" ++ (~matDir +/+ "zz_gact_test.matrix") ++ "'").systemCmd;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matHandFlip` undefined; `globalActions` not saved).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: error on `~matHandFlip` or `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Add `~matHandFlip` + push + OSCdefs.** Append to the global-actions block created in Task 1 (right after `~matFireGlobal`):
|
||||
|
||||
```supercollider
|
||||
|
||||
// derive open/closed per hand from openness (hysteresis); fire only on an
|
||||
// established open<->closed flip. Absent hand (x<=0) resets state (no fire).
|
||||
~matHandFlip = { |hand, x, openness, openSlot, closeSlot|
|
||||
var present = x > 0.001;
|
||||
var prev = (~matHandState[hand]) ? \unknown;
|
||||
var cur = present.if({
|
||||
(openness >= ~matOpenThresh).if({ \open },
|
||||
{ (openness <= ~matCloseThresh).if({ \closed }, { prev }) })
|
||||
}, { \unknown });
|
||||
((prev == \open) and: { cur == \closed }).if({ ~matFireGlobal.(closeSlot) });
|
||||
((prev == \closed) and: { cur == \open }).if({ ~matFireGlobal.(openSlot) });
|
||||
~matHandState[hand] = cur;
|
||||
};
|
||||
|
||||
// push the 4-slot mapping to surfaces (symbols sent as strings)
|
||||
~matGlobalActionsPush = {
|
||||
4.do { |i| ~toscSend !? { ~toscSend.("/matrix/globalaction", i, (~matGlobalActions[i] ? \none).asString) } }
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the OSC routes.** Add near the other matrix OSCdefs (e.g. just after the `\mat_evolverate` OSCdef ~line 948):
|
||||
|
||||
```supercollider
|
||||
OSCdef(\mat_globalaction, { |msg, time, addr|
|
||||
var slot = (msg[1] ? 0).asInteger;
|
||||
var sym = (msg[2] ? \none).asSymbol;
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
(slot >= 0 and: { slot < 4 }).if({
|
||||
~matGlobalActions[slot] = sym;
|
||||
~toscSend !? { ~toscSend.("/matrix/globalaction", slot, sym.asString) };
|
||||
})
|
||||
}, '/matrix/globalaction');
|
||||
OSCdef(\mat_globalaction_get, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matGlobalActionsPush.()
|
||||
}, '/matrix/globalaction/get');
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Call detection from `/pose/hands`.** In the `\mat_mod_hands` OSCdef (~line 1078-1087), add two calls just before its closing `}, '/pose/hands');`:
|
||||
|
||||
```supercollider
|
||||
~matHandFlip.(\L, msg[2] ? 0, msg[4] ? 0, 0, 1);
|
||||
~matHandFlip.(\R, msg[6] ? 0, msg[8] ? 0, 2, 3);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Persist in `~matSave`.** In the `payload` Event (~line 660-666), add a `globalActions` entry. Replace:
|
||||
```supercollider
|
||||
voicePoses: ~matVoices.collect({ |name| ~matVoicePoses[name] ? [] })
|
||||
);
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
voicePoses: ~matVoices.collect({ |name| ~matVoicePoses[name] ? [] }),
|
||||
globalActions: ~matGlobalActions
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Restore in `~matLoadFile`.** Just before `~lp[\matPlaying].if({ ~matApplyBar.(~lp[\matBar]) });` (~line 761), add:
|
||||
|
||||
```supercollider
|
||||
// restore global morceau actions (default when absent/legacy)
|
||||
~matGlobalActions = [\fill, \drop, \evolve, \breakdown];
|
||||
((raw.isArray.not) and: { raw[\globalActions].notNil and: { raw[\globalActions].isArray } }).if({
|
||||
raw[\globalActions].do { |s, i| (i < 4 and: { s.notNil }).if({ ~matGlobalActions[i] = s.asSymbol }) }
|
||||
});
|
||||
```
|
||||
Then, just after `~matEvolvePush.();` (~line 767), add:
|
||||
```supercollider
|
||||
~matGlobalActionsPush.();
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify paren balance.**
|
||||
|
||||
Run: `awk 'BEGIN{p=0;b=0} {for(i=1;i<=length($0);i++){c=substr($0,i,1); if(c=="(")p++; if(c==")")p--; if(c=="[")b++; if(c=="]")b--}} END{print "P:"p" B:"b}' sound_algo/data_only/matrix.scd`
|
||||
Expected: `P:0 B:0`
|
||||
|
||||
- [ ] **Step 9: Run the SC harness — expect PASS.**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: `GLOBALACT PASS`
|
||||
|
||||
- [ ] **Step 10: 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): hand-flip detection, OSC, save/load mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Generator — emit default `globalActions` in every preset
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/matrix_presets/generate_presets.py` (`to_sc` ~line 1147-1150).
|
||||
- Create: `sound_algo/data_only/matrix_presets/test_global_actions.py`
|
||||
- Regenerate: `sound_algo/data_only/matrix_presets/*.matrix`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `to_sc(name, arc_fn, instkit, melodies, extra, post=None)`, `MORCEAUX`, `main()`.
|
||||
- Produces: each `.matrix` file contains the literal `'globalActions': [ \fill, \drop, \evolve, \breakdown ]` matching the SC default + load defaults from Task 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing Python test** `sound_algo/data_only/matrix_presets/test_global_actions.py`
|
||||
|
||||
```python
|
||||
"""Every generated .matrix must carry the default globalActions mapping
|
||||
(slots: LH open=fill, LH closed=drop, RH open=evolve, RH closed=breakdown).
|
||||
Run: cd .../matrix_presets && uv run python test_global_actions.py"""
|
||||
import sys, os, glob
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import generate_presets as G
|
||||
|
||||
EXPECT = r"'globalActions': [ \fill, \drop, \evolve, \breakdown ]"
|
||||
ok = True
|
||||
|
||||
def check(c, m):
|
||||
global ok
|
||||
if not c: ok = False; print("FAIL:", m)
|
||||
|
||||
# to_sc emits the field
|
||||
sample = G.to_sc("techno_drive_live", G.MORCEAUX["techno_drive"]["live"],
|
||||
G.MORCEAUX["techno_drive"]["instkit"],
|
||||
G.MORCEAUX["techno_drive"]["melodies"],
|
||||
G.MORCEAUX["techno_drive"]["extra"])
|
||||
check(EXPECT in sample, "to_sc output missing globalActions default")
|
||||
|
||||
# every emitted .matrix on disk carries it (run generate first)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
files = glob.glob(os.path.join(here, "*.matrix"))
|
||||
check(len(files) >= 84, "expected >=84 .matrix files, got %d" % len(files))
|
||||
missing = [os.path.basename(f) for f in files
|
||||
if EXPECT not in open(f, encoding="utf-8").read()]
|
||||
check(not missing, "globalActions missing in: %s" % missing[:5])
|
||||
|
||||
print("GLOBALACT-PY PASS" if ok else "GLOBALACT-PY FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (`to_sc` does not emit the field yet; on-disk files lack it).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_global_actions.py`
|
||||
Expected: `FAIL: to_sc output missing globalActions default` then `GLOBALACT-PY FAIL`.
|
||||
|
||||
- [ ] **Step 3: Emit the field in `to_sc`.** In `generate_presets.py`, change the return (~line 1147-1150). Replace:
|
||||
```python
|
||||
return ("(\n'grid': [\n%s\n],\n'instruments': [ %s ],\n"
|
||||
"'colorDefs': [\n%s\n],\n"
|
||||
"'voiceMods': [ %s ],\n"
|
||||
"'voicePoses': [ %s ]\n)\n" % (rows, isyms, cds, vms, vps))
|
||||
```
|
||||
with:
|
||||
```python
|
||||
return ("(\n'grid': [\n%s\n],\n'instruments': [ %s ],\n"
|
||||
"'colorDefs': [\n%s\n],\n"
|
||||
"'voiceMods': [ %s ],\n"
|
||||
"'voicePoses': [ %s ],\n"
|
||||
"'globalActions': [ \\fill, \\drop, \\evolve, \\breakdown ]\n)\n"
|
||||
% (rows, isyms, cds, vms, vps))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate the presets (twice — must be deterministic).**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python generate_presets.py && uv run python generate_presets.py && git status --porcelain '*.matrix' | wc -l`
|
||||
Expected: 84 files written each run; the second run leaves the working tree unchanged for already-tracked files (only the new field differs from HEAD, consistently).
|
||||
|
||||
- [ ] **Step 5: Run the Python test — expect PASS.**
|
||||
|
||||
Run: `uv run python test_global_actions.py`
|
||||
Expected: `GLOBALACT-PY PASS`
|
||||
|
||||
- [ ] **Step 6: Regression — section + family tests still green.**
|
||||
|
||||
Run: `uv run python test_sections.py && uv run python test_families.py`
|
||||
Expected: `TEST PASS` then `TEST PASS`.
|
||||
|
||||
- [ ] **Step 7: Verify a generated preset loads in SC (defaults intact).**
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: `GLOBALACT PASS` (unchanged — the engine reads the same default; this confirms nothing regressed).
|
||||
|
||||
- [ ] **Step 8: 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 global actions mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Web state — choices, mapping, persistence
|
||||
|
||||
**Files:**
|
||||
- Modify: `web_realart/public/control/js/matrix-state.js`
|
||||
- Modify: `web_realart/test/matrix-state.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `MATRIX_STORAGE_KEY`, `saveMatState`/`loadMatState` (extend the persisted blob with a `gact` field).
|
||||
- Produces: `GLOBAL_ACTION_CHOICES` (Array of `{id,label}`), `GLOBAL_SLOTS` (Array[4] of `{key,label}`), `matGlobalActions` (Array[4] of String), `setGlobalAction(slot, id)`, `resetGlobalActions()`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Append to `web_realart/test/matrix-state.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { GLOBAL_ACTION_CHOICES, GLOBAL_SLOTS, matGlobalActions, setGlobalAction }
|
||||
from "../public/control/js/matrix-state.js";
|
||||
|
||||
test("global action choices include the 4 core actions + none", () => {
|
||||
const ids = GLOBAL_ACTION_CHOICES.map((c) => c.id);
|
||||
for (const id of ["none", "fill", "drop", "breakdown", "evolve"])
|
||||
assert.ok(ids.includes(id), `missing action ${id}`);
|
||||
});
|
||||
test("four gesture slots in fixed order", () => {
|
||||
assert.equal(GLOBAL_SLOTS.length, 4);
|
||||
assert.deepEqual(GLOBAL_SLOTS.map((s) => s.key), [0, 1, 2, 3]);
|
||||
});
|
||||
test("default mapping is fill/drop/evolve/breakdown", () => {
|
||||
assert.deepEqual(matGlobalActions, ["fill", "drop", "evolve", "breakdown"]);
|
||||
});
|
||||
test("setGlobalAction mutates a slot", () => {
|
||||
setGlobalAction(2, "fill");
|
||||
assert.equal(matGlobalActions[2], "fill");
|
||||
setGlobalAction(2, "evolve"); // restore default
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (named exports undefined).
|
||||
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: import/assertion failures (`GLOBAL_ACTION_CHOICES` undefined).
|
||||
|
||||
- [ ] **Step 3: Add the state + choices.** In `matrix-state.js`, after `POSE_EVENT_CHOICES` (~line 62), add:
|
||||
|
||||
```javascript
|
||||
// Global morceau actions: fixed 4 gesture slots, editable mapping (Phase 1).
|
||||
export const GLOBAL_ACTION_CHOICES = [
|
||||
{ id: "none", label: "—" }, { id: "fill", label: "Fill" },
|
||||
{ id: "drop", label: "Drop" }, { id: "breakdown", label: "Breakdown" },
|
||||
{ id: "evolve", label: "Relance + evolve" },
|
||||
];
|
||||
export const GLOBAL_SLOTS = [
|
||||
{ key: 0, label: "Main G ouverte" }, { key: 1, label: "Main G fermée" },
|
||||
{ key: 2, label: "Main D ouverte" }, { key: 3, label: "Main D fermée" },
|
||||
];
|
||||
export let matGlobalActions = ["fill", "drop", "evolve", "breakdown"];
|
||||
export function setGlobalAction(slot, id) {
|
||||
if (slot >= 0 && slot < 4) matGlobalActions[slot] = id;
|
||||
}
|
||||
export function resetGlobalActions() {
|
||||
matGlobalActions = ["fill", "drop", "evolve", "breakdown"];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Persist `gact`.** In `saveMatState` (~line 153-157), add `gact: matGlobalActions` to the JSON blob. Replace:
|
||||
```javascript
|
||||
JSON.stringify({ grid: matGrid, inst: matInst, cdef: matColorDefs,
|
||||
vmod: matVoiceMods, vpose: matVoicePoses })); } catch (_e) {}
|
||||
```
|
||||
with:
|
||||
```javascript
|
||||
JSON.stringify({ grid: matGrid, inst: matInst, cdef: matColorDefs,
|
||||
vmod: matVoiceMods, vpose: matVoicePoses,
|
||||
gact: matGlobalActions })); } catch (_e) {}
|
||||
```
|
||||
In `loadMatState`, just before the `return; }` that closes the new-format branch (~line 182), add:
|
||||
```javascript
|
||||
if (Array.isArray(raw.gact) && raw.gact.length === 4) matGlobalActions = raw.gact;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the test — expect PASS.**
|
||||
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: all tests pass (0 failures).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "feat(control): global action state + persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Web UI — the "Morceau" bottom panel
|
||||
|
||||
**Files:**
|
||||
- Create: `web_realart/public/control/js/morceau-panel.js`
|
||||
- Modify: `web_realart/public/control/index.html` (`#tab-matrix`, before `</section>` ~line 181)
|
||||
- Modify: `web_realart/public/control/control.css`
|
||||
- Modify: `web_realart/public/control/js/main.js` (import + init call)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `send`, `on`, `onOpen` from `./osc.js`; `GLOBAL_ACTION_CHOICES`, `GLOBAL_SLOTS`, `matGlobalActions`, `setGlobalAction`, `saveMatState` from `./matrix-state.js`.
|
||||
- Produces: `export function init()` — renders 4 dropdowns into `#morceau-slots`, sends `/matrix/globalaction slot id` on change, syncs from `on("/matrix/globalaction")`, requests state on connect.
|
||||
|
||||
- [ ] **Step 1: Create the panel module** `web_realart/public/control/js/morceau-panel.js`
|
||||
|
||||
```javascript
|
||||
// morceau-panel.js — bottom-of-editor panel for matrix-wide ("morceau") actions.
|
||||
// Phase 1: the 4 hand-gesture slots -> global action. DOM-only; state in
|
||||
// matrix-state.js, transport over osc.js. Later phases add settings + global controls.
|
||||
import { send, on, onOpen } from "./osc.js";
|
||||
import {
|
||||
GLOBAL_ACTION_CHOICES, GLOBAL_SLOTS, matGlobalActions,
|
||||
setGlobalAction, saveMatState,
|
||||
} from "./matrix-state.js";
|
||||
|
||||
export function init() {
|
||||
const host = document.getElementById("morceau-slots");
|
||||
if (!host) return;
|
||||
const selects = [];
|
||||
GLOBAL_SLOTS.forEach((slot) => {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "morceau-slot";
|
||||
wrap.append(slot.label + " ");
|
||||
const sel = document.createElement("select");
|
||||
GLOBAL_ACTION_CHOICES.forEach((c) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = c.id; o.textContent = c.label;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.value = matGlobalActions[slot.key] || "none";
|
||||
sel.addEventListener("change", () => {
|
||||
setGlobalAction(slot.key, sel.value);
|
||||
send("/matrix/globalaction", slot.key, sel.value);
|
||||
saveMatState();
|
||||
});
|
||||
selects[slot.key] = sel;
|
||||
wrap.appendChild(sel);
|
||||
host.appendChild(wrap);
|
||||
});
|
||||
on("/matrix/globalaction", (args) => {
|
||||
const slot = Math.round(Number(args[0]));
|
||||
const id = String(args[1] || "none");
|
||||
if (slot >= 0 && slot < 4 && selects[slot]) {
|
||||
selects[slot].value = id;
|
||||
setGlobalAction(slot, id);
|
||||
saveMatState();
|
||||
}
|
||||
});
|
||||
onOpen(() => send("/matrix/globalaction/get"));
|
||||
send("/matrix/globalaction/get");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the container to `index.html`.** Inside `#tab-matrix`, just before its closing `</section>` (after `<div id="matrix-mixer" ...></div>`, ~line 180):
|
||||
|
||||
```html
|
||||
<div id="morceau-panel" class="morceau-panel">
|
||||
<div class="morceau-title">Actions globales (morceau)</div>
|
||||
<div id="morceau-slots" class="morceau-slots"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Style the panel.** Append to `web_realart/public/control/control.css`:
|
||||
|
||||
```css
|
||||
.morceau-panel { margin-top: 12px; padding: 8px; border-top: 1px solid #333;
|
||||
background: #161616; border-radius: 6px; }
|
||||
.morceau-title { font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: #888; margin-bottom: 6px; }
|
||||
.morceau-slots { display: grid; grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 12px; }
|
||||
.morceau-slot { display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 6px; font-size: 13px; color: #ccc; }
|
||||
.morceau-slot select { flex: 0 0 auto; background: #222; color: #eee;
|
||||
border: 1px solid #444; border-radius: 4px; padding: 2px 4px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire it into `main.js`.** Add the import after the `voice-editor` import (~line 12):
|
||||
```javascript
|
||||
import { init as initMorceauPanel } from "./morceau-panel.js";
|
||||
```
|
||||
And add the init call after `initGrid();` (~line 59):
|
||||
```javascript
|
||||
initMorceauPanel();
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Smoke the bundle (no syntax errors, panel renders).** Start the bridge and load the control page in a headless check.
|
||||
|
||||
Run: `cd web_realart && node --check public/control/js/morceau-panel.js && node --check public/control/js/main.js`
|
||||
Expected: no output (both files parse). (Full DOM render is verified live in Task 6.)
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git add web_realart/public/control/js/morceau-panel.js web_realart/public/control/index.html web_realart/public/control/control.css web_realart/public/control/js/main.js
|
||||
git commit -m "feat(control): morceau panel with gesture-action slots"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deploy + live smoke (macm1)
|
||||
|
||||
**Files:** none (deploy + manual verification).
|
||||
|
||||
- [ ] **Step 1: Push + pull on macm1 + regen.**
|
||||
|
||||
```bash
|
||||
git push gitea-https main
|
||||
ssh macm1 'cd ~/AV-Live/sound_algo/data_only/matrix_presets && git pull && uv run python generate_presets.py'
|
||||
```
|
||||
(Use the actual macm1 AV-Live path. Push only after user confirms — the rig is live.)
|
||||
|
||||
- [ ] **Step 2: Reload the engine + a live preset, open the control surface.**
|
||||
Reboot the matrix SC engine on macm1 (launch_concert.sh path), `/matrix/load techno_drive_live`, `/matrix/play`. Open `http://supra-m1.local:4400/control/`, MATRICE tab → confirm the "Actions globales (morceau)" panel shows 4 dropdowns defaulting to Fill / Drop / Relance+evolve / Breakdown.
|
||||
|
||||
- [ ] **Step 3: Verify gestures (both hands visible).**
|
||||
Open left hand → momentary fill (1 bar, all voices busier) then back. Close left hand → 1-bar drop then back. Open right hand → re-source + one evolve mutation. Close right hand → 1-bar breakdown to kick+sub then back. Confirm no false fires when hands leave frame.
|
||||
|
||||
- [ ] **Step 4: Verify the editor + persistence.**
|
||||
In the panel, re-map a slot (e.g. MG ouverte → Breakdown); confirm the new mapping fires. `SAUVER` the matrix, reload it → the mapping persists. Load an old preset → defaults apply (fill/drop/evolve/breakdown).
|
||||
|
||||
- [ ] **Step 5: Report to the user (their ear).** Summarise what landed and ask them to confirm by hand before Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage (Phase 1 scope):**
|
||||
- Detection §1 (hysteresis + presence via `x>0`, flip-only, reset on disappear) → Task 2 Step 3/5 + harness G.
|
||||
- `~matTransient` primitive §2 (apply/expire, per-bar sourcing) → Task 1 Steps 3-5 + harness B-E.
|
||||
- 4 core actions (fill/drop/breakdown/evolve) → Task 1 Step 3 (`~matFireGlobal`) + harness F.
|
||||
- Mapping storage + OSC get/set + echo §5 → Task 2 Steps 3-4.
|
||||
- Save/load per `.matrix` §5 → Task 2 Steps 6-7 + harness H.
|
||||
- Generator default §5 → Task 3.
|
||||
- Bottom panel with 4 slots §6 → Tasks 4-5.
|
||||
- Defaults `[fill,drop,evolve,breakdown]` + thresholds 0.65/0.30 + transient 1 bar + breakdownKeep kick+sub → Global Constraints, used identically in Task 1 (SC), Task 3 (generator), Task 4 (web). ✓
|
||||
- Phase-2/3 items (extended vocabulary, settings UI, global-control consolidation, `/matrix/bpm`) are explicitly OUT of this plan.
|
||||
|
||||
**Placeholder scan:** every code step has complete code; no TBD/TODO; thresholds and slot order are concrete.
|
||||
|
||||
**Type consistency:** slot order `0=LHopen,1=LHclosed,2=RHopen,3=RHclosed` identical across `~matHandFlip` calls (Task 2 Step 5), `~matFireGlobal`/`~matGlobalActions` (Task 1), `GLOBAL_SLOTS` (Task 4), panel (Task 5). Action symbols `fill/drop/breakdown/evolve/none` match between SC (`~matEffColor`/`~matFireGlobal`), generator literal, and web `GLOBAL_ACTION_CHOICES`. `globalActions` field name identical in `~matSave`, `~matLoadFile`, generator literal, and the Python test's `EXPECT`. `~matEffColor.(name, vi, base)` signature matches its calls in `~matApplyBar` and the harness. Persisted web key `gact` matches between `saveMatState` and `loadMatState`.
|
||||
@@ -0,0 +1,596 @@
|
||||
# Matrix global morceau actions — Phase 2 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 the global-action thresholds/duration/kept-voices editable per morceau (the "Réglages" — including the close-hand sensitivity the user asked for), and extend the action vocabulary with mute-drums, mute-mélo, double-time, half-time, wash-reverb, and "next preset".
|
||||
|
||||
**Architecture:** Phase 1's four hard-coded constants (`~matOpenThresh`/`~matCloseThresh`/`~matTransientBars`/`~matBreakdownKeep`) become one editable `~matGlobalSettings` Event, synced over `/matrix/globalsettings`, saved per `.matrix`, generated as a default, and edited by a new "Réglages" group in the Morceau panel. The transient primitive gains two param channels (a global stretch factor + a rev boost read in `~matVariation`) so double/half-time and wash-reverb express through it; mute-groups reuse the colour-level override (`~matEffColor` `\mute` kind over a voice group); "next" loads the next preset in the sorted list.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), Python 3 (`uv run python`), vanilla ES modules + `node --test`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- SC env vars `~xxx` lowercase. `matrix.scd` stays ONE top-level block — `awk` balance `P:0 B:0` after every edit. Validate with:
|
||||
`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`). SC harness: `cd sound_algo/data_only/matrix_presets && sclang test_global_actions.scd` → expect `GLOBALACT PASS`.
|
||||
- Python via uv. Web tests: `cd web_realart && node --test test/*.test.mjs` (GLOB form) or a specific file.
|
||||
- Commit subject ≤ 50 chars, no underscore in commit scope, no AI attribution, no `--no-verify`. No emojis.
|
||||
- `~matGlobalSettings` keys + defaults (EXACT): `openThresh: 0.65`, `closeThresh: 0.30`, `transientBars: 1`, `breakdownKeep: [\kick, \sub]`. Invariant `openThresh > closeThresh` (hysteresis deadband) must be enforced on set.
|
||||
- Action symbols (Phase 1 + new): `none, fill, drop, breakdown, evolve, muteDrums, muteMelo, doubleTime, halfTime, washReverb, next` — identical strings across SC, generator, web.
|
||||
- Transient param channels: `doubleTime` → stretch ×0.5, `halfTime` → stretch ×2.0, `washReverb` → rev +0.4 (clamped 0..1). Mute-groups and the Phase-1 colour transients leave stretch=1.0, rev=0.0.
|
||||
- Voice groups (mirror the web `VOICE_CLASS`; "semi" perc/tom group WITH drums for mute purposes): `~matDrumVoices = [\kick,\hats,\clap,\ride,\rim,\sweep,\fx,\snare,\crash,\shaker,\perc,\tom]`, `~matMeloVoices = [\sub,\acid,\arp,\lead,\stab,\pad,\reese,\bells,\melody,\chord]`.
|
||||
- `~matTransient` never edits `~lp[\matrix]`. No `data_only_viz` changes. Deploy on user request only (live rig).
|
||||
|
||||
## File Structure
|
||||
|
||||
- `sound_algo/data_only/matrix.scd` — `~matGlobalSettings` + settings-aware `~matHandFlip`/`~matSetTransient`/breakdown; `/matrix/globalsettings[/get]`; save/load. Then: `~matEffColor` `\mute`; transient stretch/rev factors in `~matVariation`/`~matVariationOverlay`/`~matColorStepPattern`; `~matDrumVoices`/`~matMeloVoices`; `~matCurrentName`/`~matAllNames`/`~matNextPreset`; extended `~matFireGlobal`.
|
||||
- `sound_algo/data_only/matrix_presets/generate_presets.py` — `to_sc` emits the default `globalSettings`.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.{py,scd}` — extended.
|
||||
- `web_realart/public/control/js/matrix-state.js` — `matGlobalSettings` + setters + persistence; extended `GLOBAL_ACTION_CHOICES`.
|
||||
- `web_realart/public/control/js/morceau-panel.js` — new "Réglages" group.
|
||||
- `web_realart/public/control/control.css` — Réglages styles.
|
||||
- `web_realart/test/matrix-state.test.mjs` — settings + new choices coverage.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SC — `~matGlobalSettings` editable settings
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` (global-actions block ~line 326-345; `~matHandFlip`; `~matSetTransient`; `~matFireGlobal` breakdown; OSCdefs near `\mat_globalaction` ~line 950; `~matSave` payload; `~matLoadFile` tail). Modify `sound_algo/data_only/matrix_presets/test_global_actions.scd`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Phase 1): `~matHandFlip`, `~matSetTransient`, `~matFireGlobal`, `~matGlobalActionsPush`, `~matSave`, `~matLoadFile`, `~toscSend`/`~toscTouch`.
|
||||
- Produces: `~matGlobalSettings` (Event `(openThresh:, closeThresh:, transientBars:, breakdownKeep:[Symbol])`), `~matGlobalSettingsPush.()`, OSC `/matrix/globalsettings` (set+echo) + `/matrix/globalsettings/get`, `globalSettings` saved/restored in `.matrix`.
|
||||
|
||||
- [ ] **Step 1: Append failing harness checks** to `test_global_actions.scd`, immediately before the final `pass.if(...)` line:
|
||||
|
||||
```supercollider
|
||||
// -- J: settings drive detection thresholds + transient duration --
|
||||
~matGlobalSettings[\openThresh] = 0.8; ~matGlobalSettings[\closeThresh] = 0.2;
|
||||
~matHandState[\L] = \unknown;
|
||||
~matHandFlip.(\L, 0.5, 0.5, 0, 1); // 0.5 is in the new deadband -> stays unknown, no fire
|
||||
~fired = nil; ~matFireGlobal = { |slot| ~fired = slot };
|
||||
~matHandFlip.(\L, 0.5, 0.85, 0, 1); // >=0.8 open -> baseline (from unknown, no fire)
|
||||
(~fired.isNil).not.if({ pass = false; "FAIL: settings open baseline fired".postln });
|
||||
~matHandFlip.(\L, 0.5, 0.15, 0, 1); // <=0.2 -> closed -> fire slot1
|
||||
(~fired == 1).not.if({ pass = false; "FAIL: settings close threshold no fire".postln });
|
||||
~matGlobalSettings[\transientBars] = 3;
|
||||
~matFireGlobal = { |slot| }; // restore no-op-ish (real one not needed here)
|
||||
~matSetTransient.(\drop, []);
|
||||
(~matTransient[\bars] == 3).not.if({ pass = false; "FAIL: transientBars not from settings".postln });
|
||||
~matTransient = nil;
|
||||
// -- K: settings save/load roundtrip --
|
||||
~matInstruments = ~matInstruments ? Array.fill(~matVoices.size, { \default });
|
||||
~matColorDefs = ~matColorDefs ? Array.fill(~matVoices.size, { ~matDefaultColorDefs.value });
|
||||
~matSavedNames = ~matSavedNames ? Set.new;
|
||||
~matGlobalSettings = (openThresh: 0.7, closeThresh: 0.25, transientBars: 2, breakdownKeep: [\kick, \hats]);
|
||||
~matSave.("zz_gset_test");
|
||||
~matGlobalSettings = (openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]);
|
||||
~matLoadFile.(~matDir +/+ "zz_gset_test.matrix");
|
||||
(~matGlobalSettings[\openThresh] == 0.7).not.if({ pass = false; "FAIL: openThresh not restored".postln });
|
||||
(~matGlobalSettings[\transientBars] == 2).not.if({ pass = false; "FAIL: transientBars not restored".postln });
|
||||
(~matGlobalSettings[\breakdownKeep] == [\kick, \hats]).not.if({ pass = false; "FAIL: breakdownKeep not restored".postln });
|
||||
("rm -f '" ++ (~matDir +/+ "zz_gset_test.matrix") ++ "'").systemCmd;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matGlobalSettings` undefined / thresholds still hard-coded).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: error on `~matGlobalSettings` or `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Replace the four Phase-1 constants with the settings Event.** In `matrix.scd`, find the global-actions state block and replace these four lines:
|
||||
```supercollider
|
||||
~matTransientBars = ~matTransientBars ? 1; // Phase 1: 1 bar (Phase 2 reads settings)
|
||||
~matBreakdownKeep = ~matBreakdownKeep ? [\kick, \sub];
|
||||
~matOpenThresh = ~matOpenThresh ? 0.65;
|
||||
~matCloseThresh = ~matCloseThresh ? 0.30;
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalSettings = ~matGlobalSettings ? (
|
||||
openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Read settings in the three consumers.**
|
||||
In `~matSetTransient`, change `bars: ~matTransientBars` to `bars: (~matGlobalSettings[\transientBars] ? 1)`.
|
||||
In `~matFireGlobal`, change the breakdown line to:
|
||||
```supercollider
|
||||
(act == \breakdown).if({ ~matSetTransient.(\breakdown, ~matGlobalSettings[\breakdownKeep] ? [\kick, \sub]) });
|
||||
```
|
||||
In `~matHandFlip`, change the two threshold comparisons:
|
||||
```supercollider
|
||||
(openness >= (~matGlobalSettings[\openThresh] ? 0.65)).if({ \open },
|
||||
{ (openness <= (~matGlobalSettings[\closeThresh] ? 0.30)).if({ \closed }, { prev }) })
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the settings push + OSC routes.** Append to the global-actions block (after `~matGlobalActionsPush`):
|
||||
```supercollider
|
||||
|
||||
// push settings to surfaces: openThresh, closeThresh, transientBars, keepN, keep0..
|
||||
~matGlobalSettingsPush = {
|
||||
var s = ~matGlobalSettings; var keep = s[\breakdownKeep] ? [];
|
||||
~toscSend !? { ~toscSend.valueArray(["/matrix/globalsettings",
|
||||
s[\openThresh], s[\closeThresh], s[\transientBars], keep.size] ++ keep.collect({ |v| v.asString })) }
|
||||
};
|
||||
```
|
||||
Add the OSCdefs just after `\mat_globalaction_get`:
|
||||
```supercollider
|
||||
OSCdef(\mat_globalsettings, { |msg, time, addr|
|
||||
var op = (msg[1] ? 0.65).asFloat.clip(0.5, 0.95);
|
||||
var cl = (msg[2] ? 0.30).asFloat.clip(0.05, 0.6);
|
||||
var tb = (msg[3] ? 1).asInteger.clip(1, 8);
|
||||
var keepN = (msg[4] ? 0).asInteger.clip(0, 22);
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
cl = cl.min(op - 0.05); // enforce close < open (deadband)
|
||||
~matGlobalSettings[\openThresh] = op;
|
||||
~matGlobalSettings[\closeThresh] = cl;
|
||||
~matGlobalSettings[\transientBars] = tb;
|
||||
(keepN > 0).if({
|
||||
~matGlobalSettings[\breakdownKeep] = (0..(keepN - 1)).collect({ |k| (msg[5 + k] ? \kick).asSymbol })
|
||||
});
|
||||
~matGlobalSettingsPush.()
|
||||
}, '/matrix/globalsettings');
|
||||
OSCdef(\mat_globalsettings_get, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matGlobalSettingsPush.()
|
||||
}, '/matrix/globalsettings/get');
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Persist in `~matSave`.** In the `payload` Event, add after `globalActions: ~matGlobalActions`:
|
||||
```supercollider
|
||||
globalActions: ~matGlobalActions,
|
||||
globalSettings: ~matGlobalSettings
|
||||
```
|
||||
(i.e. add a comma after the `globalActions` line and the `globalSettings` line below it.)
|
||||
|
||||
- [ ] **Step 7: Restore in `~matLoadFile`.** Just after the `globalActions` restore block added in Phase 1, add:
|
||||
```supercollider
|
||||
// restore global settings (default when absent/legacy)
|
||||
~matGlobalSettings = (openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]);
|
||||
((raw.isArray.not) and: { raw[\globalSettings].notNil and: { raw[\globalSettings].isKindOf(Event) } }).if({
|
||||
var g = raw[\globalSettings];
|
||||
g[\openThresh].notNil.if({ ~matGlobalSettings[\openThresh] = g[\openThresh].asFloat });
|
||||
g[\closeThresh].notNil.if({ ~matGlobalSettings[\closeThresh] = g[\closeThresh].asFloat });
|
||||
g[\transientBars].notNil.if({ ~matGlobalSettings[\transientBars] = g[\transientBars].asInteger });
|
||||
(g[\breakdownKeep].notNil and: { g[\breakdownKeep].isArray }).if({
|
||||
~matGlobalSettings[\breakdownKeep] = g[\breakdownKeep].collect({ |v| v.asSymbol }) });
|
||||
});
|
||||
```
|
||||
Then, just after `~matGlobalActionsPush.();` (added in Phase 1), add `~matGlobalSettingsPush.();`.
|
||||
|
||||
- [ ] **Step 8: Balance + harness.**
|
||||
Run the `awk` balance check → `P:0 B:0`. Then `sclang test_global_actions.scd` → `GLOBALACT PASS` (A–K).
|
||||
|
||||
- [ ] **Step 9: 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): editable global settings + thresholds"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generator — emit default `globalSettings`
|
||||
|
||||
**Files:** Modify `generate_presets.py` (`to_sc` return ~line 1147-1150); modify `test_global_actions.py`; regenerate `*.matrix`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `to_sc`, the Phase-1 `globalActions` emission.
|
||||
- Produces: every `.matrix` carries `'globalSettings': ( 'openThresh': 0.65, 'closeThresh': 0.3, 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] )`.
|
||||
|
||||
- [ ] **Step 1: Extend the Python test (failing).** In `test_global_actions.py`, add a second expected literal + check, after the existing `EXPECT` block:
|
||||
```python
|
||||
EXPECT_SET = ("'globalSettings': ( 'openThresh': 0.65, 'closeThresh': 0.3, "
|
||||
"'transientBars': 1, 'breakdownKeep': [ \\kick, \\sub ] )")
|
||||
check(EXPECT_SET in sample, "to_sc output missing globalSettings default")
|
||||
missing_set = [os.path.basename(f) for f in files
|
||||
if EXPECT_SET not in open(f, encoding="utf-8").read()]
|
||||
check(not missing_set, "globalSettings missing in: %s" % missing_set[:5])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`to_sc` lacks the field).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_global_actions.py`
|
||||
Expected: `FAIL: to_sc output missing globalSettings default`.
|
||||
|
||||
- [ ] **Step 3: Emit the field.** In `to_sc`, change the return to append `globalSettings` after `globalActions`. Replace:
|
||||
```python
|
||||
"'voicePoses': [ %s ],\n"
|
||||
"'globalActions': [ \\fill, \\drop, \\evolve, \\breakdown ]\n)\n"
|
||||
% (rows, isyms, cds, vms, vps))
|
||||
```
|
||||
with:
|
||||
```python
|
||||
"'voicePoses': [ %s ],\n"
|
||||
"'globalActions': [ \\fill, \\drop, \\evolve, \\breakdown ],\n"
|
||||
"'globalSettings': ( 'openThresh': 0.65, 'closeThresh': 0.3, "
|
||||
"'transientBars': 1, 'breakdownKeep': [ \\kick, \\sub ] )\n)\n"
|
||||
% (rows, isyms, cds, vms, vps))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate (twice — deterministic) + verify.**
|
||||
`uv run python generate_presets.py` (twice; second run leaves files byte-identical). 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`.
|
||||
|
||||
- [ ] **Step 5: Confirm a generated preset still loads in SC with settings intact.**
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd` → `GLOBALACT PASS`.
|
||||
|
||||
- [ ] **Step 6: 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 global settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Web — settings state + persistence
|
||||
|
||||
**Files:** Modify `web_realart/public/control/js/matrix-state.js`; modify `web_realart/test/matrix-state.test.mjs`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `saveMatState`/`loadMatState` (extend blob with `gset`).
|
||||
- Produces: `matGlobalSettings` (`{openThresh, closeThresh, transientBars, breakdownKeep:[String]}`), `setGlobalSetting(key, value)`, `parseGlobalSettings(args)` → `{openThresh, closeThresh, transientBars, breakdownKeep}`, `resetGlobalSettings()`.
|
||||
|
||||
- [ ] **Step 1: Failing test.** Append to `matrix-state.test.mjs`:
|
||||
```javascript
|
||||
import { matGlobalSettings, setGlobalSetting, parseGlobalSettings }
|
||||
from "../public/control/js/matrix-state.js";
|
||||
|
||||
test("default global settings", () => {
|
||||
assert.equal(matGlobalSettings.openThresh, 0.65);
|
||||
assert.equal(matGlobalSettings.closeThresh, 0.30);
|
||||
assert.equal(matGlobalSettings.transientBars, 1);
|
||||
assert.deepEqual(matGlobalSettings.breakdownKeep, ["kick", "sub"]);
|
||||
});
|
||||
test("setGlobalSetting mutates one key", () => {
|
||||
setGlobalSetting("closeThresh", 0.4);
|
||||
assert.equal(matGlobalSettings.closeThresh, 0.4);
|
||||
setGlobalSetting("closeThresh", 0.30);
|
||||
});
|
||||
test("parseGlobalSettings decodes the OSC arg layout", () => {
|
||||
const s = parseGlobalSettings([0.7, 0.2, 3, 2, "kick", "snare"]);
|
||||
assert.equal(s.openThresh, 0.7);
|
||||
assert.equal(s.closeThresh, 0.2);
|
||||
assert.equal(s.transientBars, 3);
|
||||
assert.deepEqual(s.breakdownKeep, ["kick", "snare"]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (exports undefined).
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: import failures.
|
||||
|
||||
- [ ] **Step 3: Add the settings state.** In `matrix-state.js`, after the `matGlobalActions` block, add:
|
||||
```javascript
|
||||
export let matGlobalSettings = { openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: ["kick", "sub"] };
|
||||
export function setGlobalSetting(key, value) { matGlobalSettings[key] = value; }
|
||||
export function resetGlobalSettings() {
|
||||
matGlobalSettings = { openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: ["kick", "sub"] };
|
||||
}
|
||||
// Decode /matrix/globalsettings args: [openThresh, closeThresh, transientBars, keepN, keep0..]
|
||||
export function parseGlobalSettings(args) {
|
||||
const openThresh = Number(args[0]);
|
||||
const closeThresh = Number(args[1]);
|
||||
const transientBars = Math.round(Number(args[2]));
|
||||
const keepN = Math.round(Number(args[3])) || 0;
|
||||
const breakdownKeep = [];
|
||||
for (let k = 0; k < keepN; k++) breakdownKeep.push(String(args[4 + k]));
|
||||
return { openThresh, closeThresh, transientBars, breakdownKeep };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Persist `gset`.** In `saveMatState`, add `gset: matGlobalSettings` to the JSON blob (after `gact: matGlobalActions`). In `loadMatState`, after the `gact` restore line, add:
|
||||
```javascript
|
||||
if (raw.gset && typeof raw.gset === "object" && Array.isArray(raw.gset.breakdownKeep))
|
||||
matGlobalSettings = raw.gset;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run target + full suite — expect PASS.**
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs` then `node --test test/*.test.mjs` → all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "feat(control): global settings state + persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Web — the "Réglages" panel group (close sensitivity)
|
||||
|
||||
**Files:** Modify `web_realart/public/control/js/morceau-panel.js`; modify `web_realart/public/control/index.html` (add a `#morceau-settings` host inside `#morceau-panel`); modify `web_realart/public/control/control.css`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `matGlobalSettings`, `setGlobalSetting`, `parseGlobalSettings`, `saveMatState`, `MATRIX_VOICES` from `matrix-state.js`; `send`/`on`/`onOpen` from `osc.js`.
|
||||
- Produces: a Réglages group rendering 2 threshold sliders (open/close), a transient-bars number input, and a kept-voices checkbox grid; sends `/matrix/globalsettings`; syncs from `on("/matrix/globalsettings")`.
|
||||
|
||||
- [ ] **Step 1: Add the container.** In `index.html`, inside `#morceau-panel`, after `<div id="morceau-slots" ...></div>`, add:
|
||||
```html
|
||||
<div class="morceau-title">Réglages</div>
|
||||
<div id="morceau-settings" class="morceau-settings"></div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Render the Réglages group in `morceau-panel.js`.** At the end of `init()` (before the `onOpen`/`get` lines for globalaction), add a settings renderer + sync. Insert:
|
||||
```javascript
|
||||
// --- Réglages: thresholds, transient duration, breakdown kept-voices ---
|
||||
const sHost = document.getElementById("morceau-settings");
|
||||
if (sHost) {
|
||||
const sendSettings = () => {
|
||||
const s = matGlobalSettings;
|
||||
send("/matrix/globalsettings", s.openThresh, s.closeThresh, s.transientBars,
|
||||
s.breakdownKeep.length, ...s.breakdownKeep);
|
||||
saveMatState();
|
||||
};
|
||||
const slider = (key, label, min, max, step) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "morceau-set-row";
|
||||
const span = document.createElement("span"); span.textContent = label;
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "range"; inp.min = min; inp.max = max; inp.step = step;
|
||||
inp.value = matGlobalSettings[key];
|
||||
const out = document.createElement("output"); out.textContent = matGlobalSettings[key];
|
||||
inp.addEventListener("input", () => { out.textContent = inp.value; });
|
||||
inp.addEventListener("change", () => {
|
||||
setGlobalSetting(key, key === "transientBars" ? Math.round(+inp.value) : +inp.value);
|
||||
sendSettings();
|
||||
});
|
||||
row.append(span, inp, out);
|
||||
sHost.appendChild(row);
|
||||
return { inp, out };
|
||||
};
|
||||
const ctl = {
|
||||
openThresh: slider("openThresh", "Seuil ouvert", 0.5, 0.95, 0.01),
|
||||
closeThresh: slider("closeThresh", "Seuil fermé (sensibilité)", 0.05, 0.6, 0.01),
|
||||
transientBars: slider("transientBars", "Durée (mesures)", 1, 8, 1),
|
||||
};
|
||||
const keepWrap = document.createElement("div");
|
||||
keepWrap.className = "morceau-keep";
|
||||
const keepTitle = document.createElement("span");
|
||||
keepTitle.textContent = "Voix gardées (breakdown)";
|
||||
keepWrap.appendChild(keepTitle);
|
||||
const keepBoxes = {};
|
||||
MATRIX_VOICES.forEach((v) => {
|
||||
const lab = document.createElement("label"); lab.className = "morceau-keep-box";
|
||||
const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = v;
|
||||
cb.checked = matGlobalSettings.breakdownKeep.includes(v);
|
||||
cb.addEventListener("change", () => {
|
||||
const keep = MATRIX_VOICES.filter((x) => keepBoxes[x].checked);
|
||||
setGlobalSetting("breakdownKeep", keep);
|
||||
sendSettings();
|
||||
});
|
||||
keepBoxes[v] = cb;
|
||||
lab.append(cb, document.createTextNode(v));
|
||||
keepWrap.appendChild(lab);
|
||||
});
|
||||
sHost.appendChild(keepWrap);
|
||||
on("/matrix/globalsettings", (args) => {
|
||||
const s = parseGlobalSettings(args);
|
||||
setGlobalSetting("openThresh", s.openThresh);
|
||||
setGlobalSetting("closeThresh", s.closeThresh);
|
||||
setGlobalSetting("transientBars", s.transientBars);
|
||||
setGlobalSetting("breakdownKeep", s.breakdownKeep);
|
||||
ctl.openThresh.inp.value = s.openThresh; ctl.openThresh.out.textContent = s.openThresh;
|
||||
ctl.closeThresh.inp.value = s.closeThresh; ctl.closeThresh.out.textContent = s.closeThresh;
|
||||
ctl.transientBars.inp.value = s.transientBars; ctl.transientBars.out.textContent = s.transientBars;
|
||||
MATRIX_VOICES.forEach((v) => { keepBoxes[v].checked = s.breakdownKeep.includes(v); });
|
||||
saveMatState();
|
||||
});
|
||||
onOpen(() => send("/matrix/globalsettings/get"));
|
||||
send("/matrix/globalsettings/get");
|
||||
}
|
||||
```
|
||||
Add the new imports at the top of `morceau-panel.js`:
|
||||
```javascript
|
||||
import {
|
||||
GLOBAL_ACTION_CHOICES, GLOBAL_SLOTS, matGlobalActions, setGlobalAction, saveMatState,
|
||||
matGlobalSettings, setGlobalSetting, parseGlobalSettings,
|
||||
} from "./matrix-state.js";
|
||||
import { MATRIX_VOICES } from "./matrix-state.js";
|
||||
```
|
||||
(Replace the existing single import block with the combined one — do not import `saveMatState` twice.)
|
||||
|
||||
- [ ] **Step 3: Style it.** Append to `control.css`:
|
||||
```css
|
||||
.morceau-settings { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
|
||||
.morceau-set-row { display: grid; grid-template-columns: 1fr 2fr auto; align-items: center; gap: 6px; font-size: 12px; color: #ccc; }
|
||||
.morceau-set-row output { min-width: 2.5em; text-align: right; color: #8c8; }
|
||||
.morceau-keep { display: flex; flex-wrap: wrap; gap: 2px 8px; margin-top: 4px; font-size: 11px; color: #aaa; }
|
||||
.morceau-keep > span { flex-basis: 100%; color: #888; }
|
||||
.morceau-keep-box { display: inline-flex; align-items: center; gap: 2px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 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 5: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/js/morceau-panel.js web_realart/public/control/index.html web_realart/public/control/control.css
|
||||
git commit -m "feat(control): reglages group in morceau panel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SC — extended action vocabulary
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` (global-actions block; `~matEffColor`; `~matVariationOverlay` ~line 219; `~matColorStepPattern` ~line 248; `~matVariation` rev cdPairs ~line 277; `~matLoad` ~line 869; `~matFireGlobal`). Modify `test_global_actions.scd`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~matSetTransient`, `~matEffColor`, `~matTransient`, `~matVariation`, `~matNames`, `~matPresetDir`/`~matDir`, `~matLoad`, `~matFireGlobal`, `~matGlobalSettings`.
|
||||
- Produces: `~matDrumVoices`/`~matMeloVoices`, `~matEffColor` `\mute` kind, transient `stretch`/`rev` channels (read in `~matVariation`), `~matTxStretch`/`~matTxRev`, `~matCurrentName`, `~matAllNames.()`, `~matNextPreset.()`, extended `~matFireGlobal` (muteDrums/muteMelo/doubleTime/halfTime/washReverb/next).
|
||||
|
||||
- [ ] **Step 1: Append failing harness checks** to `test_global_actions.scd`, before the final `pass.if(...)`:
|
||||
```supercollider
|
||||
// -- L: mute group silences only its voices --
|
||||
~matSetTransient.(\mute, ~matDrumVoices);
|
||||
(~matEffColor.(\kick, 0, 3) == 0).not.if({ pass = false; "FAIL: muteDrums keeps kick".postln });
|
||||
(~matEffColor.(\sub, 4, 3) == 3).not.if({ pass = false; "FAIL: muteDrums silenced melo sub".postln });
|
||||
~matTransient = nil;
|
||||
// -- M: param transients set stretch / rev channels (via ~matSetTransient directly;
|
||||
// ~matFireGlobal was stubbed by earlier groups, and the 6 dispatch lines are
|
||||
// trivial transcription verified live in Task 7) --
|
||||
~matSetTransient.(\doubleTime, []);
|
||||
(~matTxStretch.value == 0.5).not.if({ pass = false; "FAIL: doubleTime stretch != 0.5".postln });
|
||||
~matTransient = nil;
|
||||
~matSetTransient.(\washReverb, []);
|
||||
(~matTxRev.value > 0).not.if({ pass = false; "FAIL: washReverb rev not boosted".postln });
|
||||
~matTransient = nil;
|
||||
~matSetTransient.(\halfTime, []);
|
||||
(~matTxStretch.value == 2.0).not.if({ pass = false; "FAIL: halfTime stretch != 2.0".postln });
|
||||
~matTransient = nil;
|
||||
// -- N: next loads the following preset in sorted order --
|
||||
~loaded = nil; ~matLoadReal = ~matLoad; ~matLoad = { |n| ~loaded = n.asSymbol };
|
||||
~matAllNames = { [\aaa_x, \bbb_y, \ccc_z] }; // stub the list deterministically
|
||||
~matCurrentName = \aaa_x;
|
||||
~matNextPreset.();
|
||||
(~loaded == \bbb_y).not.if({ pass = false; "FAIL: next did not load bbb_y".postln });
|
||||
~matCurrentName = \ccc_z; // wrap to first
|
||||
~matNextPreset.();
|
||||
(~loaded == \aaa_x).not.if({ pass = false; "FAIL: next did not wrap to aaa_x".postln });
|
||||
~matLoad = ~matLoadReal;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matDrumVoices`/`~matTxStretch`/`~matNextPreset` undefined).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: error / `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Add voice groups + transient param accessors + `\mute`.** In the global-actions block, after `~matGlobalSettings`, add:
|
||||
```supercollider
|
||||
~matDrumVoices = ~matDrumVoices ? [\kick,\hats,\clap,\ride,\rim,\sweep,\fx,\snare,\crash,\shaker,\perc,\tom];
|
||||
~matMeloVoices = ~matMeloVoices ? [\sub,\acid,\arp,\lead,\stab,\pad,\reese,\bells,\melody,\chord];
|
||||
// transient param channels read by ~matVariation (1.0/0.0 when no transient or a colour-only kind)
|
||||
~matTxStretch = { (~matTransient.notNil).if({ ~matTransient[\stretch] ? 1.0 }, { 1.0 }) };
|
||||
~matTxRev = { (~matTransient.notNil).if({ ~matTransient[\rev] ? 0.0 }, { 0.0 }) };
|
||||
```
|
||||
In `~matEffColor`, add a `\mute` branch before the final `base` fallback. Replace:
|
||||
```supercollider
|
||||
(k == \breakdown).if({ ((t[\voices] ? []).includes(name)).if({ base }, { 0 }) },
|
||||
{ base }) }) })
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
(k == \breakdown).if({ ((t[\voices] ? []).includes(name)).if({ base }, { 0 }) },
|
||||
{ (k == \mute).if({ ((t[\voices] ? []).includes(name)).if({ 0 }, { base }) },
|
||||
{ base }) }) }) })
|
||||
```
|
||||
(note the extra closing `)` to match the added nesting).
|
||||
|
||||
- [ ] **Step 4: Carry stretch/rev in the transient.** Change `~matSetTransient` to stamp the param channels by kind:
|
||||
```supercollider
|
||||
~matSetTransient = { |kind, voices|
|
||||
var stretch = (kind == \doubleTime).if({ 0.5 }, { (kind == \halfTime).if({ 2.0 }, { 1.0 }) });
|
||||
var rev = (kind == \washReverb).if({ 0.4 }, { 0.0 });
|
||||
~matTransient = (kind: kind, voices: (voices ? []), bars: (~matGlobalSettings[\transientBars] ? 1),
|
||||
stretch: stretch, rev: rev);
|
||||
~matVoices.size.do { |i| ~matLastColor[i] = -1 };
|
||||
(~lp[\matPlaying] == true).if({ ~matApplyBar.(~lp[\matBar] ? 0) });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Apply stretch in the two pattern builders.** In `~matVariationOverlay`, change `\stretch, spec[\stretch]` to `\stretch, (spec[\stretch] * ~matTxStretch.value)`. In `~matColorStepPattern`, change `\stretch, spec[\stretch]` to `\stretch, (spec[\stretch] * ~matTxStretch.value)`.
|
||||
|
||||
- [ ] **Step 6: Apply the rev boost in `~matVariation`.** Replace the rev cdPair block:
|
||||
```supercollider
|
||||
(spec[\rev].notNil).if({
|
||||
cdPairs = cdPairs ++ [\rev, spec[\rev]] });
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
((spec[\rev].notNil) or: { ~matTxRev.value > 0 }).if({
|
||||
cdPairs = cdPairs ++ [\rev, ((spec[\rev] ? 0) + ~matTxRev.value).clip(0, 1)] });
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Track current name + next-preset.** In `~matLoad`, add as its first line `~matCurrentName = ~matSafeName.(name).asSymbol;`. In the global-actions block, after `~matGlobalSettingsPush` (or near `~matFireGlobal`), add:
|
||||
```supercollider
|
||||
~matCurrentName = ~matCurrentName ? nil;
|
||||
~matAllNames = { (~matNames.(~matPresetDir) ++ ~matNames.(~matDir)).as(Set).asArray.sort };
|
||||
~matNextPreset = {
|
||||
var names = ~matAllNames.value;
|
||||
(names.size > 0).if({
|
||||
var i = names.indexOf(~matCurrentName) ? -1;
|
||||
var nxt = names[(i + 1) % names.size];
|
||||
~matLoad.(nxt);
|
||||
~toscSend !? { ~toscSend.("/matrix/loaded", nxt.asString) };
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Extend `~matFireGlobal` dispatch.** After the `\evolve` line, add:
|
||||
```supercollider
|
||||
(act == \muteDrums).if({ ~matSetTransient.(\mute, ~matDrumVoices) });
|
||||
(act == \muteMelo).if({ ~matSetTransient.(\mute, ~matMeloVoices) });
|
||||
(act == \doubleTime).if({ ~matSetTransient.(\doubleTime, []) });
|
||||
(act == \halfTime).if({ ~matSetTransient.(\halfTime, []) });
|
||||
(act == \washReverb).if({ ~matSetTransient.(\washReverb, []) });
|
||||
(act == \next).if({ ~matNextPreset.() });
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Balance + harness.**
|
||||
`awk` balance → `P:0 B:0`; `sclang test_global_actions.scd` → `GLOBALACT PASS` (A–N).
|
||||
|
||||
- [ ] **Step 10: 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): mute groups, time-warp, wash, next"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Web — extended action choices
|
||||
|
||||
**Files:** Modify `web_realart/public/control/js/matrix-state.js` (`GLOBAL_ACTION_CHOICES`); modify `web_realart/test/matrix-state.test.mjs`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GLOBAL_ACTION_CHOICES`.
|
||||
- Produces: the 6 new action ids in the dropdown vocabulary (the panel's dropdowns pick them up automatically).
|
||||
|
||||
- [ ] **Step 1: Failing test.** Append to `matrix-state.test.mjs`:
|
||||
```javascript
|
||||
test("extended action choices include phase-2 actions", () => {
|
||||
const ids = GLOBAL_ACTION_CHOICES.map((c) => c.id);
|
||||
for (const id of ["muteDrums", "muteMelo", "doubleTime", "halfTime", "washReverb", "next"])
|
||||
assert.ok(ids.includes(id), `missing action ${id}`);
|
||||
});
|
||||
```
|
||||
(`GLOBAL_ACTION_CHOICES` is already imported in this file.)
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (ids absent).
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: assertion failure on `muteDrums`.
|
||||
|
||||
- [ ] **Step 3: Extend the vocabulary.** In `matrix-state.js`, change `GLOBAL_ACTION_CHOICES` to append the 6 entries before the closing bracket:
|
||||
```javascript
|
||||
export const GLOBAL_ACTION_CHOICES = [
|
||||
{ id: "none", label: "—" }, { id: "fill", label: "Fill" },
|
||||
{ id: "drop", label: "Drop" }, { id: "breakdown", label: "Breakdown" },
|
||||
{ id: "evolve", label: "Relance + evolve" },
|
||||
{ id: "muteDrums", label: "Mute drums" }, { id: "muteMelo", label: "Mute mélo" },
|
||||
{ id: "doubleTime", label: "Double-time" }, { id: "halfTime", label: "Half-time" },
|
||||
{ id: "washReverb", label: "Wash reverb" }, { id: "next", label: "Suivant" },
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run target + full suite — expect PASS.**
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs` then `node --test test/*.test.mjs` → all green.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "feat(control): extended global action choices"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 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'` (the committed `.matrix` already carry the new field; regen optional). SC engine reboot (kill `sclang boot.scd` + `scsynth`, relaunch `cd ~/Documents/Projets/AV-Live/sound_algo/data_only && MATRIX_ONLY=1 nohup script -q /tmp/sc_boot.log /Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd & ; wait for `[matrix] ready`).
|
||||
- [ ] **Step 2:** Reglages: drop the "Seuil fermé (sensibilité)" slider and confirm the closed-hand fires more/less easily; raise transient duration and confirm the effect lasts longer; toggle a kept-voice and confirm breakdown keeps it. Save → reload → settings persist.
|
||||
- [ ] **Step 3:** Vocabulary: map slots to muteDrums / muteMelo / doubleTime / halfTime / washReverb / next and confirm each by ear (drums cut, melo cut, tempo doubles/halves for the transient, reverb swells, next preset loads). Report to the user.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage (Phase 2 of the design):** `~matGlobalSettings` + thresholds (incl. close sensitivity) + duration + kept-voices → Task 1; generator default → Task 2; web settings state → Task 3; Réglages UI → Task 4; extended vocabulary muteDrums/muteMelo/doubleTime/halfTime/washReverb/next → Task 5; web choices → Task 6; smoke → Task 7. ✓
|
||||
|
||||
**Placeholder scan:** all code present; thresholds, factors (0.5/2.0/0.4), voice groups, OSC arg layouts are concrete; no TBD.
|
||||
|
||||
**Type consistency:** `~matGlobalSettings` keys (`openThresh`/`closeThresh`/`transientBars`/`breakdownKeep`) identical across SC (Task 1), generator literal (Task 2), web `matGlobalSettings`/`parseGlobalSettings` (Task 3-4). OSC arg layout `[openThresh, closeThresh, transientBars, keepN, keep0..]` identical in `~matGlobalSettingsPush`/`\mat_globalsettings` (SC) and `parseGlobalSettings`/`sendSettings` (web). Action ids (`muteDrums/muteMelo/doubleTime/halfTime/washReverb/next`) identical across `~matFireGlobal` (Task 5), `GLOBAL_ACTION_CHOICES` (Task 6). Transient `stretch`/`rev` keys set in `~matSetTransient` (Task 5 Step 4) and read by `~matTxStretch`/`~matTxRev` (Step 3) → used in `~matVariation*` (Steps 5-6). `~matDrumVoices`/`~matMeloVoices` defined Step 3, used Step 8. `~matCurrentName` set in `~matLoad` (Step 7), read in `~matNextPreset` (Step 7). `gset` persistence key matches in `saveMatState`/`loadMatState` (Task 3).
|
||||
@@ -0,0 +1,157 @@
|
||||
# Matrix global morceau actions — Phase 3 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:** Add a matrix tempo route (`/matrix/bpm`) and a tempo slider in the Morceau panel. (Per the user's decision the existing global controls — evolve/rate, full-loop, charger-suivant — STAY where they are; Phase 3 only ADDS the tempo control.)
|
||||
|
||||
**Architecture:** A new self-contained `~matSetBpm` sets the matrix clock tempo (`(~lp[\clock] ? TempoClock.default).tempo = bpm/60`) and echoes `/matrix/bpm`; the web Morceau panel gains a single tempo slider wired to it. No existing controls are moved; `main.js` is not touched.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), vanilla ES modules + `node --test`.
|
||||
|
||||
## 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`.
|
||||
- Web tests: `cd web_realart && node --test test/*.test.mjs` (GLOB).
|
||||
- Commit subject ≤ 50, no underscore in scope, no AI attribution, no `--no-verify`. No emojis.
|
||||
- BPM range clamp `[20, 300]`. The matrix clock is `~lp[\clock] ? TempoClock.default`. BPM is a LIVE global param (NOT persisted per-morceau in this phase) — synced via `/matrix/bpm` echo + `/matrix/bpm/get`.
|
||||
- ADD ONLY a tempo control. Do NOT move, delete, or re-id the existing matrix controls (`matrix-evolve`, `matrix-evolve-rate`, `matrix-loop-full`, `matrix-loadnext`). Do NOT touch `main.js`.
|
||||
- No `data_only_viz` changes. Deploy on user request only (live rig).
|
||||
|
||||
## File Structure
|
||||
|
||||
- `sound_algo/data_only/matrix.scd` — `~matBpm`, `~matSetBpm`, `~matBpmPush`, OSCdefs `/matrix/bpm` + `/matrix/bpm/get`.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.scd` — bpm harness check.
|
||||
- `web_realart/public/control/index.html` — a tempo control appended inside `#morceau-panel` (no other element moved).
|
||||
- `web_realart/public/control/js/morceau-panel.js` — wire `#matrix-bpm` → `/matrix/bpm` + inbound sync.
|
||||
- `web_realart/public/control/control.css` — tempo-control styles.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SC — `/matrix/bpm` tempo route
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` (global-actions block + OSCdefs near `\mat_globalsettings`). Modify `sound_algo/data_only/matrix_presets/test_global_actions.scd`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~lp[\clock]`, `~toscSend`/`~toscTouch`.
|
||||
- Produces: `~matBpm` (Float), `~matSetBpm.(bpm)`, `~matBpmPush.()`, OSC `/matrix/bpm` (set+echo) + `/matrix/bpm/get`.
|
||||
|
||||
- [ ] **Step 1: Append failing harness check** to `test_global_actions.scd`, before the final `pass.if(...)`:
|
||||
```supercollider
|
||||
// -- O: bpm sets the matrix clock tempo + tracks ~matBpm --
|
||||
~matSetBpm.(140);
|
||||
(~matBpm == 140).not.if({ pass = false; "FAIL: ~matBpm not 140".postln });
|
||||
(((~lp[\clock] ? TempoClock.default).tempo - (140 / 60)).abs < 1e-6).not.if({ pass = false; "FAIL: clock tempo not 140 bpm".postln });
|
||||
~matSetBpm.(500); // clamps to 300
|
||||
(~matBpm == 300).not.if({ pass = false; "FAIL: bpm not clamped to 300".postln });
|
||||
~matSetBpm.(120); // restore
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matSetBpm` undefined).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: error on `~matSetBpm` or `GLOBALACT FAIL`.
|
||||
|
||||
- [ ] **Step 3: Add the bpm setter + push.** In the global-actions block (e.g. after `~matGlobalSettingsPush`), add:
|
||||
```supercollider
|
||||
~matBpm = ~matBpm ? 120;
|
||||
~matSetBpm = { |bpm|
|
||||
var b = bpm.asFloat.clip(20, 300);
|
||||
~matBpm = b;
|
||||
(~lp[\clock] ? TempoClock.default).tempo = b / 60;
|
||||
~toscSend !? { ~toscSend.("/matrix/bpm", b) };
|
||||
};
|
||||
~matBpmPush = { ~toscSend !? { ~toscSend.("/matrix/bpm", ~matBpm) } };
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the OSC routes.** After the `\mat_globalsettings_get` OSCdef, add:
|
||||
```supercollider
|
||||
OSCdef(\mat_bpm, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matSetBpm.((msg[1] ? 120))
|
||||
}, '/matrix/bpm');
|
||||
OSCdef(\mat_bpm_get, { |msg, time, addr|
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matBpmPush.()
|
||||
}, '/matrix/bpm/get');
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Balance + harness.**
|
||||
`awk` balance → `P:0 B:0`; `sclang test_global_actions.scd` → `GLOBALACT PASS` (A–O).
|
||||
|
||||
- [ ] **Step 6: 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): matrix bpm tempo route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Web — tempo slider in the Morceau panel
|
||||
|
||||
**Files:** Modify `web_realart/public/control/index.html`, `web_realart/public/control/js/morceau-panel.js`, `web_realart/public/control/control.css`. Do NOT modify `main.js`. Do NOT move/delete any existing element.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `send`/`on`/`onOpen` from `osc.js`; the SC `/matrix/bpm` route from Task 1.
|
||||
- Produces: a `#matrix-bpm` tempo slider (with a live readout) inside `#morceau-panel`, wired to `/matrix/bpm`.
|
||||
|
||||
- [ ] **Step 1: Add the tempo control in `index.html`.** Inside `#morceau-panel`, as the FIRST child (before `<div class="morceau-title">Actions globales (morceau)</div>`), add:
|
||||
```html
|
||||
<div class="morceau-global">
|
||||
<label class="morceau-bpm">Tempo <input id="matrix-bpm" type="range" min="20" max="300" step="1" value="120"><output id="matrix-bpm-out">120</output></label>
|
||||
</div>
|
||||
```
|
||||
Do not touch any other element.
|
||||
|
||||
- [ ] **Step 2: Wire the tempo slider in `morceau-panel.js`.** Inside `init()`, after the Réglages block (before the function ends), add:
|
||||
```javascript
|
||||
// --- Global: matrix tempo slider (existing evolve/loop/suivant stay in place) ---
|
||||
const bpm = document.getElementById("matrix-bpm");
|
||||
const bpmOut = document.getElementById("matrix-bpm-out");
|
||||
if (bpm) {
|
||||
bpm.addEventListener("input", () => { if (bpmOut) bpmOut.textContent = bpm.value; });
|
||||
bpm.addEventListener("change", () => send("/matrix/bpm", +bpm.value));
|
||||
on("/matrix/bpm", (args) => {
|
||||
const v = Math.round(Number(args[0]));
|
||||
if (Number.isFinite(v)) { bpm.value = v; if (bpmOut) bpmOut.textContent = v; }
|
||||
});
|
||||
onOpen(() => send("/matrix/bpm/get"));
|
||||
send("/matrix/bpm/get");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Style the control.** Append to `control.css`:
|
||||
```css
|
||||
.morceau-global { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.morceau-global .morceau-bpm { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: #ccc; }
|
||||
.morceau-global .morceau-bpm output { min-width: 2.5em; text-align: right; color: #8c8; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Parse + suite + no-collision check.**
|
||||
Run: `cd web_realart && node --check public/control/js/morceau-panel.js` (no output) and `node --test test/*.test.mjs` (all green). Confirm `#matrix-bpm` is unique and the existing four controls are untouched:
|
||||
`grep -c 'id="matrix-bpm"' public/control/index.html` → `1`; `for id in matrix-evolve matrix-evolve-rate matrix-loop-full matrix-loadnext; do echo -n "$id: "; grep -c "id=\"$id\"" public/control/index.html; done` → each `1`.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/index.html web_realart/public/control/js/morceau-panel.js web_realart/public/control/control.css
|
||||
git commit -m "feat(control): matrix tempo slider in panel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 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'`; SC-only reboot (kill `sclang boot.scd` + `scsynth`; relaunch `cd ~/Documents/Projets/AV-Live/sound_algo/data_only && MATRIX_ONLY=1 nohup script -q /tmp/sc_boot.log /Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd &`; wait for `[matrix] ready`). Hard-reload the web page.
|
||||
- [ ] **Step 2:** Confirm the Morceau panel shows a Tempo slider; dragging it changes the matrix speed live and the value echoes back; the existing ÉVOLUE / BOUCLE / SUIVANT controls remain in their original place and still work; Réglages + gesture slots unaffected. Report to the user.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage (Phase 3, scoped down per user):** matrix tempo route `/matrix/bpm` → Task 1; tempo slider in the panel → Task 2; controls left in place (no consolidation, per user) → Constraints + Task 2 (add-only). Smoke → Task 3. ✓
|
||||
|
||||
**Placeholder scan:** all code present; BPM clamp `[20,300]`, ids exact, no TBD.
|
||||
|
||||
**Type consistency:** `~matSetBpm.(bpm)` sets `~matBpm` (Float) + clock tempo; `~matBpmPush`/`\mat_bpm`/`\mat_bpm_get` all use `/matrix/bpm` with a single Float arg; the web `#matrix-bpm` sends `+bpm.value` (Number) and `on("/matrix/bpm")` reads `args[0]` — same single-value contract. No existing id is moved or duplicated; `main.js` untouched.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Matrix `_live` continuous groove — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline). Checkbox (`- [ ]`) steps.
|
||||
|
||||
**Goal:** Make every morceau's `_live` section a full continuous loop (plateau tiled across 64 bars) instead of re-introducing with a sparse intro + breakdown.
|
||||
|
||||
**Architecture:** Add `make_live_continuous(g)` (tiles the P plateau over all 64 bars), apply it after `apply_colors` for the live section only via a new `to_sc(post=)` param. Mirror it in `test_sections.py` and assert the live is full-from-start with no breakdown dip.
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`), the SC validation harness.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python: uv only. No emojis in code/commits. Commit subject ≤ 50, body ≤ 72/line, no AI attribution, no underscore in scope.
|
||||
- `SECTIONS["P"] == (32, 16)`. Transform moves cells only; never touches mods/poses/instruments/colorDefs.
|
||||
- SC validate: `sclang sound_algo/data_only/matrix_presets/validate_presets.scd` → `84/84 PASS` + `DEFAULTS PASS`. Deterministic regen. Push/deploy on user request (rig is live).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: make_live_continuous + wiring + test
|
||||
|
||||
**Files:** Modify `generate_presets.py` (`make_live_continuous`, `to_sc`, `main`), `test_sections.py`, regenerated `*.matrix`.
|
||||
|
||||
- [ ] **Step 1: Update `test_sections.py` to the new expectation (failing).** In `grid_of`, apply the live transform; add the "full from start, no breakdown dip" assertion:
|
||||
|
||||
```python
|
||||
def grid_of(name, sec):
|
||||
m = G.MORCEAUX[name]
|
||||
g = G.blank(); m[sec](g)
|
||||
if m.get("extra"): m["extra"](g)
|
||||
G.apply_colors(g)
|
||||
if sec == "live":
|
||||
G.make_live_continuous(g) # mirror the generator
|
||||
return g
|
||||
```
|
||||
Add, after the existing per-morceau checks (intro builds / outro breaks / live densest):
|
||||
```python
|
||||
# live is a full continuous loop: every 16-bar block ~ the plateau (no intro
|
||||
# ramp, no breakdown dip).
|
||||
p022 = active_in(live, 0, 16); p3248 = active_in(live, 32, 48)
|
||||
p4864 = active_in(live, 48, 64)
|
||||
check(p3248 > 0 and abs(p022 - p3248) <= 0.15 * p3248,
|
||||
"%s live not full from start (%d vs plateau %d)" % (name, p022, p3248))
|
||||
check(p3248 > 0 and abs(p4864 - p3248) <= 0.15 * p3248,
|
||||
"%s live has a breakdown dip (%d vs plateau %d)" % (name, p4864, p3248))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (`make_live_continuous` undefined + the raw live is not full from start).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_sections.py`
|
||||
Expected: `TEST FAIL` (AttributeError on `make_live_continuous`, or the full-from-start check fails).
|
||||
|
||||
- [ ] **Step 3: Add `make_live_continuous`** to `generate_presets.py` (near `apply_colors`):
|
||||
|
||||
```python
|
||||
def make_live_continuous(g):
|
||||
"""Live = the plateau groove tiled across all 64 bars: full from bar 0, no
|
||||
intro ramp, no breakdown. The intro builds to it, the outro descends from it."""
|
||||
ps, pn = SECTIONS["P"] # (32, 16)
|
||||
for row in g:
|
||||
plateau = row[ps:ps + pn]
|
||||
for b in range(len(row)):
|
||||
row[b] = plateau[(b - ps) % pn]
|
||||
return g
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Thread `post` through `to_sc` + `main`.** In `to_sc`, add the param and call it after `apply_colors`:
|
||||
```python
|
||||
def to_sc(name, arc_fn, instkit, melodies, extra, post=None):
|
||||
...
|
||||
apply_colors(g)
|
||||
if post is not None:
|
||||
post(g)
|
||||
...
|
||||
```
|
||||
In `main()`, pass it for the live section only:
|
||||
```python
|
||||
for name, m in MORCEAUX.items():
|
||||
for sec in ("intro", "live", "outro"):
|
||||
post = make_live_continuous if sec == "live" else None
|
||||
sc = to_sc("%s_%s" % (name, sec), m[sec], m["instkit"], m["melodies"], m["extra"], post)
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run `test_sections.py` — expect PASS.**
|
||||
|
||||
Run: `uv run python test_sections.py`
|
||||
Expected: `TEST PASS` (intro builds, outro breaks, live densest + full-from-start + no breakdown dip).
|
||||
|
||||
- [ ] **Step 6: Regenerate + determinism + SC validate.**
|
||||
|
||||
Run: `uv run python generate_presets.py` (84 files); run twice → zero diff. Then
|
||||
`sclang validate_presets.scd` → `OK: 84/84` + `DEFAULTS PASS` + `VALIDATE PASS`.
|
||||
Also `uv run python test_families.py` → `TEST PASS` (unaffected).
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_sections.py sound_algo/data_only/matrix_presets/*.matrix
|
||||
git commit -m "feat(presets): live = continuous full loop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: deploy + live smoke
|
||||
|
||||
- [ ] **Step 1:** `git push gitea-https main`; macm1 `git pull` + `uv run python generate_presets.py`; reload a `_live` preset (`/matrix/load techno_drive_live` + `/matrix/play`).
|
||||
- [ ] **Step 2:** Confirm the live plays full from bar 0, loops cleanly, no intro/breakdown; `_intro`/`_outro` unchanged. Report to the user (their ear).
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** `make_live_continuous` (plateau tiled) → Task 1 Step 3. Applied post-apply_colors, live only, via `to_sc(post=)` → Steps 3-4. test_sections mirror + full-from-start + no-breakdown asserts → Steps 1-2,5. 84/84 + DEFAULTS + determinism → Step 6. Smoke → Task 2. ✓
|
||||
|
||||
**Placeholder scan:** all code present; no TBD. The 15% tolerance is explicit.
|
||||
|
||||
**Type consistency:** `make_live_continuous(g)` mutates+returns the grid (list of rows), called in `to_sc` (post) and `test_sections.grid_of`. `to_sc` signature gains `post=None` (default keeps intro/outro callers correct). `SECTIONS["P"]` = (32,16) used identically in transform + asserts.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Matrix `_live` per-style variation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline). Checkbox (`- [ ]`) steps.
|
||||
|
||||
**Goal:** Make each morceau's `_live` loop breathe in a style-appropriate way (per-genre colour variation + phrase-end fills) while staying full.
|
||||
|
||||
**Architecture:** `LIVE_STYLE` maps morceau->style; `LIVE_VAR` holds per-style profiles; `make_live_continuous(g, style)` tiles the plateau then `_live_variation(g, style)` recolours active cells per block + adds phrase-end fills. Wired via `to_sc(post=)` in `main`, mirrored in `test_sections`.
|
||||
|
||||
**Tech Stack:** Python 3 (`uv run python`) + SC validation harness.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python uv only; no emojis; commit subject ≤ 50, body ≤ 72/line, no AI attribution, no underscore in scope.
|
||||
- Variation recolours ACTIVE cells only (`> 0`) -> density unchanged, no voice dropped. `BARS == 64`, blocks of 16. Rhythmic busy -> colour 2/4, melodic -> 3, fills -> 6.
|
||||
- SC validate -> `84/84 PASS` + `DEFAULTS PASS`, deterministic. Push/deploy on request (rig live).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: per-style variation + wiring + test
|
||||
|
||||
**Files:** Modify `generate_presets.py` (`LIVE_STYLE`, `LIVE_VAR`, `make_live_continuous`, `_live_variation`, `main`), `test_sections.py`, regenerated `*.matrix`.
|
||||
|
||||
- [ ] **Step 1: Update `test_sections.py` (failing).** Pass the style + add a "variation present" check.
|
||||
In `grid_of`:
|
||||
```python
|
||||
if sec == "live":
|
||||
G.make_live_continuous(g, G.LIVE_STYLE.get(name, "techno"))
|
||||
```
|
||||
After the existing live density checks, add:
|
||||
```python
|
||||
# the live loop must vary: its four 16-bar blocks are not all identical
|
||||
blocks_differ = any(
|
||||
len({tuple(row[i*16:i*16+16]) for i in range(4)}) > 1 for row in live)
|
||||
check(blocks_differ, "%s live has no variation (4 identical blocks)" % name)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`make_live_continuous` takes 1 arg / `LIVE_STYLE` undefined).
|
||||
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_sections.py`
|
||||
Expected: `TEST FAIL` (TypeError / AttributeError).
|
||||
|
||||
- [ ] **Step 3: Add `LIVE_STYLE` + `LIVE_VAR` + `_live_variation`** to `generate_presets.py` (just after `make_live_continuous`):
|
||||
```python
|
||||
LIVE_STYLE = {}
|
||||
for _s, _names in {
|
||||
"techno": ("techno_drive", "detroit_soul", "peak_drop", "rave_stab", "acid_journey"),
|
||||
"psy": ("dub_techno", "psytrance_roll", "minimal_hypno", "hypnotic_arp"),
|
||||
"trance": ("trance_euphoria", "melodic_builder", "melodic_deep", "progressive_lift", "synthwave_drive"),
|
||||
"breaks": ("breakbeat", "bass_science", "jungle_amen", "dnb_roller", "footwork_chop", "neuro_reese", "tribal_perc"),
|
||||
"electro": ("electro_808", "ebm_body", "electro_funk"),
|
||||
"industrial": ("schranz_drive", "industrial_grind", "hardcore_punk"),
|
||||
"ambient": ("ambient_intro",),
|
||||
}.items():
|
||||
for _n in _names: LIVE_STYLE[_n] = _s
|
||||
|
||||
LIVE_VAR = {
|
||||
"techno": dict(busy=["hats", "perc"], busy_blocks=[1, 3], busy_color=4, fill=["snare", "clap", "perc"]),
|
||||
"psy": dict(busy=["acid", "arp", "reese"], busy_blocks=[1, 3], busy_color=3, fill=["perc"]),
|
||||
"trance": dict(busy=["arp", "lead"], busy_blocks=[2], busy_color=3, fill=["snare", "clap"]),
|
||||
"breaks": dict(busy=["snare", "shaker"], busy_blocks=[1, 3], busy_color=2, fill=["snare", "perc", "tom"]),
|
||||
"electro": dict(busy=["hats", "clap"], busy_blocks=[1, 3], busy_color=2, fill=["clap", "perc"]),
|
||||
"industrial": dict(busy=["perc", "tom"], busy_blocks=[1, 3], busy_color=2, fill=["kick", "perc", "tom"]),
|
||||
"ambient": dict(busy=["bells", "melody"], busy_blocks=[2], busy_color=3, fill=[]),
|
||||
}
|
||||
|
||||
def _live_variation(g, style):
|
||||
"""Per-style life in the full loop: recolour active cells in some blocks
|
||||
(busier/octave) + phrase-end fills. Active cells only -> density kept."""
|
||||
prof = LIVE_VAR.get(style, LIVE_VAR["techno"])
|
||||
for blk in prof["busy_blocks"]:
|
||||
for v in prof["busy"]:
|
||||
vi = VI[v]
|
||||
for b in range(blk * 16, blk * 16 + 16):
|
||||
if g[vi][b] > 0: g[vi][b] = prof["busy_color"]
|
||||
for blk in range(BARS // 16):
|
||||
for v in prof["fill"]:
|
||||
vi = VI[v]
|
||||
for b in (blk * 16 + 14, blk * 16 + 15):
|
||||
if g[vi][b] > 0: g[vi][b] = 6
|
||||
return g
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend `make_live_continuous` to take + apply the style.** Change its signature to `make_live_continuous(g, style="techno")` and, after the tiling loop (before `return g`), call `_live_variation(g, style)`.
|
||||
|
||||
- [ ] **Step 5: Wire the style in `main()`.** Replace the live post-processor:
|
||||
```python
|
||||
for sec in ("intro", "live", "outro"):
|
||||
post = ((lambda nm: (lambda gg: make_live_continuous(gg, LIVE_STYLE.get(nm, "techno"))))(name)
|
||||
if sec == "live" else None)
|
||||
sc = to_sc("%s_%s" % (name, sec), m[sec], m["instkit"], m["melodies"], m["extra"], post)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run `test_sections.py` — expect PASS** (density asserts hold + variation present).
|
||||
|
||||
- [ ] **Step 7: Regen + determinism + validate.**
|
||||
`uv run python generate_presets.py` (twice, zero diff); `sclang validate_presets.scd` -> `84/84` + `DEFAULTS PASS` + `VALIDATE PASS`; `uv run python test_families.py` -> PASS.
|
||||
|
||||
- [ ] **Step 8: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_sections.py sound_algo/data_only/matrix_presets/*.matrix
|
||||
git commit -m "feat(presets): per-style variation in live loop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: deploy + smoke
|
||||
|
||||
- [ ] `git push gitea-https main`; macm1 `git pull` + regen; reload a couple of `_live` of different genres (techno_drive_live, breakbeat_live, ambient_intro_live) + play; confirm each loops full but varies in a style-fitting way; report to the user (ear).
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** LIVE_STYLE 7 groups -> Step 3. LIVE_VAR profiles + colours -> Step 3. `_live_variation` recolour-active + phrase-fills -> Step 3. `make_live_continuous(g, style)` -> Step 4. main wiring -> Step 5. test mirror + variation-present -> Steps 1,6. 84/84 + DEFAULTS + determinism -> Step 7. smoke -> Task 2. ✓
|
||||
|
||||
**Placeholder scan:** all code present; no TBD.
|
||||
|
||||
**Type consistency:** `make_live_continuous(g, style="techno")` (default keeps any 1-arg caller working) called with the morceau style in main + test. `LIVE_STYLE` dict[str,str]; `LIVE_VAR` dict[str, dict]. `_live_variation(g, style)` mutates+returns. `VI`/`BARS` existing. Fills applied after busy so a phrase-end cell ends colour 6 (fill wins).
|
||||
@@ -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` (A–P).
|
||||
|
||||
- [ ] **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.
|
||||
@@ -0,0 +1,457 @@
|
||||
# Matrix global actions — pinch triggers 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:** Trigger the global matrix actions from finger pinches (8 slots = 4 per hand) instead of open/closed hand, removing the open/close detection + its threshold settings; also drop in the matrix tempo slider.
|
||||
|
||||
**Architecture:** Extend the existing `/pose/pinch` OSCdef to map `(hand, finger)` → slot `0..7` and fire `~matFireGlobal`; remove `~matHandFlip`/`~matHandState` and the `openThresh`/`closeThresh` settings; grow `~matGlobalActions` to 8. The generator, web state, panel (8 dropdowns, trimmed Réglages, tempo slider), and launcher (`FINGER_PIANO=1`) follow.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), Python 3 (`uv run python`), 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 via uv. Web tests: `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 model:** index = `hand*4 + (finger-1)`; `0..3` = MG (index/majeur/annulaire/auriculaire), `4..7` = MD. `/pose/pinch` msg: `hand = msg[2]` (0=L,1=R), `finger = msg[3]` (1-4).
|
||||
- **Default 8-slot mapping (EXACT):** `[\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next]`.
|
||||
- **`~matGlobalSettings` keys after trim:** `transientBars: 1`, `breakdownKeep: [\kick, \sub]` (NO thresholds). `/matrix/globalsettings` arg layout becomes `[transientBars, keepN, keep0..]`.
|
||||
- Action ids unchanged: `none, fill, drop, breakdown, evolve, muteDrums, muteMelo, doubleTime, halfTime, washReverb, next`.
|
||||
- Per-voice pinch bindings (`~matPoseFire`) and `lOpen`/`rOpen` mod sources stay. No `data_only_viz` code change. Deploy on user request only (live rig).
|
||||
|
||||
## File Structure
|
||||
|
||||
- `sound_algo/data_only/matrix.scd` — pinch→global in `\mat_ev_pinch`; remove `~matHandFlip`/`~matHandState` + the two `/pose/hands` calls; trim `~matGlobalSettings` + push + `\mat_globalsettings` OSCdef + load-restore; 8-slot default + push(8) + `slot<8`.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.scd` — remove groups G + J, trim K, add pinch group P.
|
||||
- `sound_algo/data_only/matrix_presets/generate_presets.py` — 8-slot `globalActions` + trimmed `globalSettings`.
|
||||
- `sound_algo/data_only/matrix_presets/test_global_actions.py` — new expected literals.
|
||||
- `web_realart/public/control/js/matrix-state.js` — 8 `GLOBAL_SLOTS`, 8-elem default, trimmed `matGlobalSettings` + `parseGlobalSettings`, `setGlobalAction` slot<8, load length 8.
|
||||
- `web_realart/public/control/js/morceau-panel.js` — slot<8 inbound; Réglages drop thresholds; new `sendSettings` layout; tempo slider.
|
||||
- `web_realart/public/control/index.html` + `control.css` — tempo control + styles.
|
||||
- `web_realart/test/matrix-state.test.mjs` — 8 slots + new settings layout.
|
||||
- `launcher/concert/launch_concert.sh` — `export FINGER_PIANO=1`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SC — pinch triggers + remove open/close + trim settings
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` + `sound_algo/data_only/matrix_presets/test_global_actions.scd`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~matFireGlobal`, `~matEventPinch`, `~matPoseFire`, `~matGlobalActions`, `~matGlobalSettings`, `~matSetTransient`, `~matSave`/`~matLoadFile`.
|
||||
- Produces: `/pose/pinch` fires global actions by slot `0..7`; `~matGlobalActions` is 8-element; `~matGlobalSettings` has only `transientBars`+`breakdownKeep`; `~matHandFlip`/`~matHandState` removed.
|
||||
|
||||
- [ ] **Step 1: Edit the harness (failing).** In `test_global_actions.scd`:
|
||||
(a) DELETE the entire `// -- G:` block (the `~matHandFlip` flip-detection checks, through to just before `// -- H:`).
|
||||
(b) DELETE the entire `// -- J:` block (the settings-threshold checks, through to just before `// -- K:`).
|
||||
(c) In the `// -- K:` block, change the saved settings line and drop the openThresh assertion. Replace:
|
||||
```supercollider
|
||||
~matGlobalSettings = (openThresh: 0.7, closeThresh: 0.25, transientBars: 2, breakdownKeep: [\kick, \hats]);
|
||||
~matSave.("zz_gset_test");
|
||||
~matGlobalSettings = (openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]);
|
||||
~matLoadFile.(~matDir +/+ "zz_gset_test.matrix");
|
||||
(~matGlobalSettings[\openThresh] == 0.7).not.if({ pass = false; "FAIL: openThresh not restored".postln });
|
||||
(~matGlobalSettings[\transientBars] == 2).not.if({ pass = false; "FAIL: transientBars not restored".postln });
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalSettings = (transientBars: 2, breakdownKeep: [\kick, \hats]);
|
||||
~matSave.("zz_gset_test");
|
||||
~matGlobalSettings = (transientBars: 1, breakdownKeep: [\kick, \sub]);
|
||||
~matLoadFile.(~matDir +/+ "zz_gset_test.matrix");
|
||||
(~matGlobalSettings[\transientBars] == 2).not.if({ pass = false; "FAIL: transientBars not restored".postln });
|
||||
```
|
||||
(d) ADD a pinch group before the final `pass.if(...)`:
|
||||
```supercollider
|
||||
// -- P: a pinch fires the mapped global action (slot = hand*4 + finger-1) --
|
||||
~matGlobalActions = [\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next];
|
||||
~matTransient = nil;
|
||||
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 0, 1]); // L index -> slot 0 -> fill
|
||||
(~matTransient.notNil and: { ~matTransient[\kind] == \fill }).not.if({ pass = false; "FAIL: L-index pinch != fill".postln });
|
||||
~matTransient = nil;
|
||||
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 1, 1]); // R index -> slot 4 -> muteDrums (kind \mute)
|
||||
(~matTransient.notNil and: { ~matTransient[\kind] == \mute }).not.if({ pass = false; "FAIL: R-index pinch != muteDrums".postln });
|
||||
~matTransient = nil;
|
||||
OSCdef(\mat_ev_pinch).func.value(['/pose/pinch', 0, 1, 3]); // R ring -> slot 6 -> washReverb
|
||||
(~matTransient.notNil and: { ~matTransient[\kind] == \washReverb }).not.if({ pass = false; "FAIL: R-ring pinch != washReverb".postln });
|
||||
~matTransient = nil;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matHandFlip` still referenced by removed groups is gone, but the pinch OSCdef does not yet fire global → group P fails; also K asserts the new layout not yet saved).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && /Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd`
|
||||
Expected: `GLOBALACT FAIL` (P assertions fail / K mismatch). If an error about `~matHandFlip` appears it means a G/J reference was missed — remove it.
|
||||
|
||||
- [ ] **Step 3: Extend `\mat_ev_pinch` to fire global actions.** In `matrix.scd`, replace:
|
||||
```supercollider
|
||||
OSCdef(\mat_ev_pinch, { |msg| ~matPoseFire.(~matEventPinch.(msg[2] ? 0, msg[3] ? 1)) }, '/pose/pinch');
|
||||
```
|
||||
with:
|
||||
```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');
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Grow the default mapping + push + slot range.**
|
||||
Replace `~matGlobalActions = ~matGlobalActions ? [\fill, \drop, \evolve, \breakdown]; // slots 0..3` with:
|
||||
```supercollider
|
||||
~matGlobalActions = ~matGlobalActions ? [\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next]; // 8 pinch slots 0..7
|
||||
```
|
||||
In `~matGlobalActionsPush`, change `4.do` to `8.do`.
|
||||
In the `\mat_globalaction` OSCdef, change `(slot >= 0 and: { slot < 4 })` to `(slot >= 0 and: { slot < 8 })`.
|
||||
In `~matLoadFile`'s globalActions restore, change the default + bound. Replace:
|
||||
```supercollider
|
||||
~matGlobalActions = [\fill, \drop, \evolve, \breakdown];
|
||||
((raw.isArray.not) and: { raw[\globalActions].notNil and: { raw[\globalActions].isArray } }).if({
|
||||
raw[\globalActions].do { |s, i| (i < 4 and: { s.notNil }).if({ ~matGlobalActions[i] = s.asSymbol }) }
|
||||
});
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalActions = [\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next];
|
||||
((raw.isArray.not) and: { raw[\globalActions].notNil and: { raw[\globalActions].isArray } }).if({
|
||||
raw[\globalActions].do { |s, i| (i < 8 and: { s.notNil }).if({ ~matGlobalActions[i] = s.asSymbol }) }
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove open/close detection.**
|
||||
Delete `~matHandState = ~matHandState ? IdentityDictionary[\L -> \unknown, \R -> \unknown];` (line near the settings).
|
||||
Delete the whole `~matHandFlip = { ... };` definition (its comment `// derive open/closed per hand ...` through the closing `};`).
|
||||
In the `/pose/hands` OSCdef (`\mat_mod_hands`), delete the two lines:
|
||||
```supercollider
|
||||
~matHandFlip.(\L, msg[2] ? 0, msg[4] ? 0, 0, 1);
|
||||
~matHandFlip.(\R, msg[6] ? 0, msg[8] ? 0, 2, 3);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Trim the settings (drop thresholds).**
|
||||
Replace the settings Event:
|
||||
```supercollider
|
||||
~matGlobalSettings = ~matGlobalSettings ? (
|
||||
openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]
|
||||
);
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalSettings = ~matGlobalSettings ? ( transientBars: 1, breakdownKeep: [\kick, \sub] );
|
||||
```
|
||||
Replace `~matGlobalSettingsPush`:
|
||||
```supercollider
|
||||
~matGlobalSettingsPush = {
|
||||
var s = ~matGlobalSettings; var keep = s[\breakdownKeep] ? [];
|
||||
~toscSend !? { ~toscSend.valueArray(["/matrix/globalsettings",
|
||||
s[\openThresh], s[\closeThresh], s[\transientBars], keep.size] ++ keep.collect({ |v| v.asString })) }
|
||||
};
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalSettingsPush = {
|
||||
var s = ~matGlobalSettings; var keep = s[\breakdownKeep] ? [];
|
||||
~toscSend !? { ~toscSend.valueArray(["/matrix/globalsettings",
|
||||
s[\transientBars], keep.size] ++ keep.collect({ |v| v.asString })) }
|
||||
};
|
||||
```
|
||||
Replace the `\mat_globalsettings` OSCdef:
|
||||
```supercollider
|
||||
OSCdef(\mat_globalsettings, { |msg, time, addr|
|
||||
var op = (msg[1] ? 0.65).asFloat.clip(0.5, 0.95);
|
||||
var cl = (msg[2] ? 0.30).asFloat.clip(0.05, 0.6);
|
||||
var tb = (msg[3] ? 1).asInteger.clip(1, 8);
|
||||
var keepN = (msg[4] ? 0).asInteger.clip(0, 22);
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
cl = cl.min(op - 0.05); // enforce close < open (deadband)
|
||||
~matGlobalSettings[\openThresh] = op;
|
||||
~matGlobalSettings[\closeThresh] = cl;
|
||||
~matGlobalSettings[\transientBars] = tb;
|
||||
(keepN > 0).if({
|
||||
~matGlobalSettings[\breakdownKeep] = (0..(keepN - 1)).collect({ |k| (msg[5 + k] ? \kick).asSymbol })
|
||||
});
|
||||
~matGlobalSettingsPush.()
|
||||
}, '/matrix/globalsettings');
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
OSCdef(\mat_globalsettings, { |msg, time, addr|
|
||||
var tb = (msg[1] ? 1).asInteger.clip(1, 8);
|
||||
var keepN = (msg[2] ? 0).asInteger.clip(0, 22);
|
||||
~toscTouch !? { ~toscTouch.(addr) };
|
||||
~matGlobalSettings[\transientBars] = tb;
|
||||
(keepN > 0).if({
|
||||
~matGlobalSettings[\breakdownKeep] = (0..(keepN - 1)).collect({ |k| (msg[3 + k] ? \kick).asSymbol })
|
||||
});
|
||||
~matGlobalSettingsPush.()
|
||||
}, '/matrix/globalsettings');
|
||||
```
|
||||
Replace the `~matLoadFile` settings restore:
|
||||
```supercollider
|
||||
~matGlobalSettings = (openThresh: 0.65, closeThresh: 0.30, transientBars: 1, breakdownKeep: [\kick, \sub]);
|
||||
((raw.isArray.not) and: { raw[\globalSettings].notNil and: { raw[\globalSettings].isKindOf(Event) } }).if({
|
||||
var g = raw[\globalSettings];
|
||||
g[\openThresh].notNil.if({ ~matGlobalSettings[\openThresh] = g[\openThresh].asFloat });
|
||||
g[\closeThresh].notNil.if({ ~matGlobalSettings[\closeThresh] = g[\closeThresh].asFloat });
|
||||
g[\transientBars].notNil.if({ ~matGlobalSettings[\transientBars] = g[\transientBars].asInteger });
|
||||
(g[\breakdownKeep].notNil and: { g[\breakdownKeep].isArray }).if({
|
||||
~matGlobalSettings[\breakdownKeep] = g[\breakdownKeep].collect({ |v| v.asSymbol }) });
|
||||
});
|
||||
```
|
||||
with:
|
||||
```supercollider
|
||||
~matGlobalSettings = ( transientBars: 1, breakdownKeep: [\kick, \sub] );
|
||||
((raw.isArray.not) and: { raw[\globalSettings].notNil and: { raw[\globalSettings].isKindOf(Event) } }).if({
|
||||
var g = raw[\globalSettings];
|
||||
g[\transientBars].notNil.if({ ~matGlobalSettings[\transientBars] = g[\transientBars].asInteger });
|
||||
(g[\breakdownKeep].notNil and: { g[\breakdownKeep].isArray }).if({
|
||||
~matGlobalSettings[\breakdownKeep] = g[\breakdownKeep].collect({ |v| v.asSymbol }) });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Balance + harness.**
|
||||
`awk` balance → `P:0 B:0`. Grep to confirm no surviving `~matHandFlip`/`~matHandState`/`openThresh`/`closeThresh` in `matrix.scd`:
|
||||
`grep -nE "matHandFlip|matHandState|openThresh|closeThresh" sound_algo/data_only/matrix.scd` → no matches.
|
||||
Then `sclang test_global_actions.scd` → `GLOBALACT PASS` (A–F, H, K, L, M, N, O, P).
|
||||
|
||||
- [ ] **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-triggered global actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generator — 8-slot actions + trimmed settings
|
||||
|
||||
**Files:** Modify `generate_presets.py` (`to_sc`), `test_global_actions.py`; regenerate `*.matrix`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `to_sc`'s `globalActions`/`globalSettings` emission.
|
||||
- Produces: every `.matrix` carries the 8-symbol `globalActions` + the trimmed `globalSettings`.
|
||||
|
||||
- [ ] **Step 1: Update the Python test (failing).** In `test_global_actions.py`, replace the `EXPECT` and `EXPECT_SET` literals:
|
||||
```python
|
||||
EXPECT = r"'globalActions': [ \fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next ]"
|
||||
EXPECT_SET = "'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \\kick, \\sub ] )"
|
||||
```
|
||||
(leave the `check(...)`/`missing`/`missing_set` logic that uses them unchanged.)
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (old literals emitted).
|
||||
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_global_actions.py`
|
||||
Expected: `FAIL: to_sc output missing globalActions default`.
|
||||
|
||||
- [ ] **Step 3: Update `to_sc` emission.** Replace:
|
||||
```python
|
||||
"'globalActions': [ \\fill, \\drop, \\evolve, \\breakdown ],\n"
|
||||
"'globalSettings': ( 'openThresh': 0.65, 'closeThresh': 0.3, "
|
||||
"'transientBars': 1, 'breakdownKeep': [ \\kick, \\sub ] )\n)\n"
|
||||
```
|
||||
with:
|
||||
```python
|
||||
"'globalActions': [ \\fill, \\drop, \\breakdown, \\evolve, "
|
||||
"\\muteDrums, \\muteMelo, \\washReverb, \\next ],\n"
|
||||
"'globalSettings': ( 'transientBars': 1, "
|
||||
"'breakdownKeep': [ \\kick, \\sub ] )\n)\n"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate (twice) + gates.**
|
||||
`uv run python generate_presets.py` (twice, deterministic). 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`), and `/Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd` (`GLOBALACT PASS` — a freshly generated default still loads).
|
||||
|
||||
- [ ] **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): 8-slot pinch actions + trim settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Web — 8 slots + trimmed settings state
|
||||
|
||||
**Files:** Modify `web_realart/public/control/js/matrix-state.js`, `web_realart/test/matrix-state.test.mjs`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `saveMatState`/`loadMatState`.
|
||||
- Produces: 8-entry `GLOBAL_SLOTS`, 8-element `matGlobalActions` default, `setGlobalAction` slot<8, trimmed `matGlobalSettings`, `parseGlobalSettings` new layout, `loadMatState` gact length 8.
|
||||
|
||||
- [ ] **Step 1: Update tests (failing).** In `matrix-state.test.mjs`, replace the existing "four gesture slots" + "default mapping" + "default global settings" + "parseGlobalSettings" tests with:
|
||||
```javascript
|
||||
test("eight pinch gesture slots in fixed order", () => {
|
||||
assert.equal(GLOBAL_SLOTS.length, 8);
|
||||
assert.deepEqual(GLOBAL_SLOTS.map((s) => s.key), [0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
});
|
||||
test("default mapping is the 8-finger spread", () => {
|
||||
assert.deepEqual(matGlobalActions,
|
||||
["fill", "drop", "breakdown", "evolve", "muteDrums", "muteMelo", "washReverb", "next"]);
|
||||
});
|
||||
test("default global settings (no thresholds)", () => {
|
||||
assert.equal(matGlobalSettings.transientBars, 1);
|
||||
assert.deepEqual(matGlobalSettings.breakdownKeep, ["kick", "sub"]);
|
||||
assert.equal(matGlobalSettings.openThresh, undefined);
|
||||
});
|
||||
test("parseGlobalSettings decodes [transientBars, keepN, keep0..]", () => {
|
||||
const s = parseGlobalSettings([3, 2, "kick", "snare"]);
|
||||
assert.equal(s.transientBars, 3);
|
||||
assert.deepEqual(s.breakdownKeep, ["kick", "snare"]);
|
||||
});
|
||||
```
|
||||
(Keep the `setGlobalSetting`/`setGlobalAction` mutation tests; update the `setGlobalAction` one to use slot `7`.)
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL.**
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
|
||||
Expected: failures (4 slots / 4-element default / thresholds present).
|
||||
|
||||
- [ ] **Step 3: Update the state.** In `matrix-state.js`:
|
||||
Replace `GLOBAL_SLOTS`:
|
||||
```javascript
|
||||
export const GLOBAL_SLOTS = [
|
||||
{ key: 0, label: "Pince MG index" }, { key: 1, label: "Pince MG majeur" },
|
||||
{ key: 2, label: "Pince MG annulaire" }, { key: 3, label: "Pince MG auriculaire" },
|
||||
{ key: 4, label: "Pince MD index" }, { key: 5, label: "Pince MD majeur" },
|
||||
{ key: 6, label: "Pince MD annulaire" }, { key: 7, label: "Pince MD auriculaire" },
|
||||
];
|
||||
```
|
||||
Replace the `matGlobalActions` default + `resetGlobalActions` body + `setGlobalAction` bound:
|
||||
```javascript
|
||||
export let matGlobalActions = ["fill", "drop", "breakdown", "evolve", "muteDrums", "muteMelo", "washReverb", "next"];
|
||||
export function setGlobalAction(slot, id) { if (slot >= 0 && slot < 8) matGlobalActions[slot] = id; }
|
||||
export function resetGlobalActions() {
|
||||
matGlobalActions = ["fill", "drop", "breakdown", "evolve", "muteDrums", "muteMelo", "washReverb", "next"];
|
||||
}
|
||||
```
|
||||
Replace `matGlobalSettings` default + `resetGlobalSettings` + `parseGlobalSettings`:
|
||||
```javascript
|
||||
export let matGlobalSettings = { transientBars: 1, breakdownKeep: ["kick", "sub"] };
|
||||
export function setGlobalSetting(key, value) { matGlobalSettings[key] = value; }
|
||||
export function resetGlobalSettings() { matGlobalSettings = { transientBars: 1, breakdownKeep: ["kick", "sub"] }; }
|
||||
// Decode /matrix/globalsettings args: [transientBars, keepN, keep0..]
|
||||
export function parseGlobalSettings(args) {
|
||||
const transientBars = Math.round(Number(args[0]));
|
||||
const keepN = Math.round(Number(args[1])) || 0;
|
||||
const breakdownKeep = [];
|
||||
for (let k = 0; k < keepN; k++) breakdownKeep.push(String(args[2 + k]));
|
||||
return { transientBars, breakdownKeep };
|
||||
}
|
||||
```
|
||||
In `loadMatState`, change the gact length guard from `=== 4` to `=== 8`.
|
||||
|
||||
- [ ] **Step 4: Run target + full suite — PASS.**
|
||||
Run: `cd web_realart && node --test test/matrix-state.test.mjs` then `node --test test/*.test.mjs` → all green.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/js/matrix-state.js web_realart/test/matrix-state.test.mjs
|
||||
git commit -m "feat(control): 8 pinch slots + trim settings state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Web — panel (8 dropdowns auto, trimmed Réglages, tempo)
|
||||
|
||||
**Files:** Modify `web_realart/public/control/js/morceau-panel.js`, `web_realart/public/control/index.html`, `web_realart/public/control/control.css`. Do NOT modify `main.js`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GLOBAL_SLOTS` (now 8), `matGlobalSettings`/`parseGlobalSettings` (trimmed), the SC `/matrix/bpm` route.
|
||||
- Produces: 8 gesture dropdowns (auto from `GLOBAL_SLOTS`), Réglages without threshold sliders, the `/matrix/globalsettings` send with the new layout, and a `#matrix-bpm` tempo slider.
|
||||
|
||||
- [ ] **Step 1: Fix the inbound slot bound + the settings send/sliders in `morceau-panel.js`.**
|
||||
In the `on("/matrix/globalaction", ...)` handler, change `slot < 4` to `slot < 8`.
|
||||
In the Réglages block: remove the two `slider("openThresh", ...)` and `slider("closeThresh", ...)` lines and their `ctl` entries (keep only `transientBars`). Update the inbound `on("/matrix/globalsettings", ...)` to no longer reference `ctl.openThresh`/`ctl.closeThresh`. Change `sendSettings` to the new arg layout:
|
||||
```javascript
|
||||
const sendSettings = () => {
|
||||
const s = matGlobalSettings;
|
||||
send("/matrix/globalsettings", s.transientBars, s.breakdownKeep.length, ...s.breakdownKeep);
|
||||
saveMatState();
|
||||
};
|
||||
```
|
||||
And the inbound handler body becomes:
|
||||
```javascript
|
||||
on("/matrix/globalsettings", (args) => {
|
||||
const s = parseGlobalSettings(args);
|
||||
setGlobalSetting("transientBars", s.transientBars);
|
||||
setGlobalSetting("breakdownKeep", s.breakdownKeep);
|
||||
ctl.transientBars.inp.value = s.transientBars; ctl.transientBars.out.textContent = s.transientBars;
|
||||
MATRIX_VOICES.forEach((v) => { keepBoxes[v].checked = s.breakdownKeep.includes(v); });
|
||||
saveMatState();
|
||||
});
|
||||
```
|
||||
(Keep the `ctl = { transientBars: slider("transientBars", "Durée (mesures)", 1, 8, 1) }` line — drop the two threshold sliders from that object.)
|
||||
|
||||
- [ ] **Step 2: Add the tempo slider.** In `index.html`, as the FIRST child of `#morceau-panel`, add:
|
||||
```html
|
||||
<div class="morceau-global">
|
||||
<label class="morceau-bpm">Tempo <input id="matrix-bpm" type="range" min="20" max="300" step="1" value="120"><output id="matrix-bpm-out">120</output></label>
|
||||
</div>
|
||||
```
|
||||
In `morceau-panel.js`, after the Réglages block (before `init()` ends), add:
|
||||
```javascript
|
||||
const bpm = document.getElementById("matrix-bpm");
|
||||
const bpmOut = document.getElementById("matrix-bpm-out");
|
||||
if (bpm) {
|
||||
bpm.addEventListener("input", () => { if (bpmOut) bpmOut.textContent = bpm.value; });
|
||||
bpm.addEventListener("change", () => send("/matrix/bpm", +bpm.value));
|
||||
on("/matrix/bpm", (args) => {
|
||||
const v = Math.round(Number(args[0]));
|
||||
if (Number.isFinite(v)) { bpm.value = v; if (bpmOut) bpmOut.textContent = v; }
|
||||
});
|
||||
onOpen(() => send("/matrix/bpm/get"));
|
||||
send("/matrix/bpm/get");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Style.** Append to `control.css`:
|
||||
```css
|
||||
.morceau-global { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.morceau-global .morceau-bpm { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: #ccc; }
|
||||
.morceau-global .morceau-bpm output { min-width: 2.5em; text-align: right; color: #8c8; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Parse + suite.**
|
||||
Run: `cd web_realart && node --check public/control/js/morceau-panel.js` (no output) and `node --test test/*.test.mjs` (all green). Confirm `grep -c 'id="matrix-bpm"' public/control/index.html` → `1`.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/js/morceau-panel.js web_realart/public/control/index.html web_realart/public/control/control.css
|
||||
git commit -m "feat(control): 8 pinch slots panel + tempo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Launcher — enable pinch detection
|
||||
|
||||
**Files:** Modify `launcher/concert/launch_concert.sh`.
|
||||
|
||||
- [ ] **Step 1: Enable FINGER_PIANO.** After the `export MEDIAPIPE_DELEGATE=...` line, add:
|
||||
```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}"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lint.** `zsh -n launcher/concert/launch_concert.sh` → no output (valid syntax).
|
||||
|
||||
- [ ] **Step 3: Commit.**
|
||||
```bash
|
||||
git add launcher/concert/launch_concert.sh
|
||||
git commit -m "feat(launcher): enable pinch detection for matrix"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 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'`. Because `FINGER_PIANO` is new, restart the FULL stack via the launcher (it relaunches the pose pipeline with `FINGER_PIANO=1`) OR restart the pose pipeline with the env set + SC-only reboot. Simplest: re-run `launch_concert.sh` on macm1 (kills + relaunches iPhone app, SC engine, pose pipeline). Confirm `[matrix] ready` + the pose pipeline logs pinch detection.
|
||||
- [ ] **Step 2:** Hard-reload the web page → the Morceau panel shows 8 pinch dropdowns + a Tempo slider + Réglages (duration + kept-voices, NO threshold sliders). Pinch each finger (thumb↔finger), L and R → the mapped global action fires (fill/drop/breakdown/evolve, muteDrums/muteMelo/washReverb/next). Open/close hand no longer triggers. Re-map a slot + save/reload. Tempo slider changes the matrix speed. Report to the user.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** pinch→global 8 slots → Task 1 Step 3-4; remove open/close + thresholds → Task 1 Step 5-6; generator 8 + trim → Task 2; web 8 slots + trim → Task 3; panel 8 + Réglages trim + tempo → Task 4; FINGER_PIANO → Task 5; smoke → Task 6. ✓
|
||||
|
||||
**Placeholder scan:** all code present; slot math, default mapping, arg layouts, FINGER_PIANO concrete; no TBD.
|
||||
|
||||
**Type consistency:** slot index `hand*4+(finger-1)` (Task 1 Step 3) ranges 0..7, matching `~matGlobalActions` 8-element (Step 4), `slot < 8` guard (Step 4), `GLOBAL_SLOTS` keys 0..7 (Task 3), `setGlobalAction` <8 (Task 3), panel inbound `slot < 8` (Task 4). Default mapping `[fill,drop,breakdown,evolve,muteDrums,muteMelo,washReverb,next]` identical across SC (Step 4), generator literal (Task 2), web (Task 3). `globalSettings` arg layout `[transientBars, keepN, keep0..]` identical in `~matGlobalSettingsPush`/`\mat_globalsettings` (Task 1 Step 6), `parseGlobalSettings`/`sendSettings` (Task 3-4). `globalSettings` keys (`transientBars`,`breakdownKeep`) consistent SC↔generator↔web. `/matrix/bpm` single-Float contract (Task 4 Step 2) matches the route shipped in the bpm commit.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Matrix save-persist + queued load — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Fix user-saved matrix presets vanishing from the CHARGER list after an engine restart, and add a "CHARGER SUIVANT" queued load that swaps the preset at the loop wrap.
|
||||
|
||||
**Architecture:** Two engine changes in `matrix.scd` (`~matNames` disk-scan for the user dir; `~matWrapStep` + `~matQueuedLoad` + `/matrix/loadnext` OSC) and a web change (SUIVANT button + `/matrix/queued` feedback). Loading from inside the playhead Routine is safe — `~matLoadFile` only swaps data, it never restarts the Routine.
|
||||
|
||||
**Tech Stack:** SuperCollider (sclang), vanilla JS (web control surface), node --test.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- SC env vars lowercase `~xxx`. Code comments in French OK; no emojis in code/commits. Commits: subject ≤ 50 chars, body ≤ 72/line, no AI attribution, no underscore in scope.
|
||||
- Web: vanilla JS modules under `web_realart/public/control/js/`; tests `node --test web_realart/public/control/test/*.test.mjs` (GLOB, never a bare dir).
|
||||
- OSC: SC listens on `:57121`; feedback to the web goes via `~toscSend`. Push only on user request; macm1 rig is live.
|
||||
- The matrix playhead loop wrap is detected in the `~matPlay` Routine at `~lp[\matBar] < ~matPrevBar` (matrix.scd ~421-422), where ÉVOLUE mutates.
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sound_algo/data_only/matrix.scd` | `~matNames` disk-scan; `~matQueuedLoad`; `~matWrapStep`; wrap call; `/matrix/loadnext` OSCdef |
|
||||
| `web_realart/public/control/index.html` | SUIVANT button in `.matrix-persist` |
|
||||
| `web_realart/public/control/js/main.js` | SUIVANT handler + `/matrix/queued` feedback |
|
||||
| `web_realart/public/control/control.css` | `.armed` style |
|
||||
| `web_realart/public/control/test/queued-load.test.mjs` | create — SUIVANT + feedback test |
|
||||
| `sound_algo/data_only/matrix_presets/test_matnames.scd` | create — `~matNames` + `~matWrapStep` SC checks |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ~matNames disk-scan for the user dir (Part A)
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` (`~matNames`, lines 494-517); create `sound_algo/data_only/matrix_presets/test_matnames.scd`.
|
||||
|
||||
**Interfaces:** Produces `~matNames.(dir)` → array of base-name Symbols scanned from disk for any dir, unioned with `~matSavedNames` when `dir == ~matDir`.
|
||||
|
||||
- [ ] **Step 1: Write the failing SC check** `test_matnames.scd` (headless, mirrors validate_presets.scd stub):
|
||||
|
||||
```supercollider
|
||||
// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang test_matnames.scd
|
||||
(
|
||||
var feat = PathName(thisProcess.nowExecutingPath).pathOnly ++ "../matrix.scd";
|
||||
var pass = true, dir, nm;
|
||||
~lp = (clock: TempoClock.default); ~toscSend = { |path ...a| }; ~toscTouch = { |a| };
|
||||
["kick","hats","clap","perc","sub","acid","arp","lead","stab","pad","ride","rim",
|
||||
"tom","reese","bells","sweep","melody","chord","fx","snare","crash","shaker"].do { |v|
|
||||
Pdef(("lp_" ++ v).asSymbol, Pbind(\instrument, v.asSymbol, \dur, 1)) };
|
||||
feat.load;
|
||||
// Part A: a .matrix written to ~matDir is listed even with an empty ~matSavedNames
|
||||
dir = ~matDir; ("mkdir -p '" ++ dir ++ "'").systemCmd;
|
||||
File.use(dir +/+ "zz_persist_test.matrix", "w", { |f| f.write("('grid':[])") });
|
||||
~matSavedNames = Set.new; // simulate fresh boot (no in-memory saves)
|
||||
nm = ~matNames.(dir);
|
||||
nm.includes(\zz_persist_test).not.if({ pass = false; "FAIL: ~matNames missed disk file".postln });
|
||||
("rm -f '" ++ (dir +/+ "zz_persist_test.matrix") ++ "'").systemCmd;
|
||||
pass.if({ "MATNAMES PASS".postln }, { "MATNAMES FAIL".postln });
|
||||
0.exit;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/test_matnames.scd`
|
||||
Expected: `MATNAMES FAIL` — current `~matNames` returns `~matSavedNames.asArray` (empty) for `~matDir`.
|
||||
|
||||
- [ ] **Step 3: Fix `~matNames`** (replace lines 494-517) so it always disk-scans, then unions the in-memory Set for the user dir:
|
||||
|
||||
```supercollider
|
||||
~matNames = { |dir|
|
||||
var ext = ".matrix";
|
||||
var result = [];
|
||||
File.exists(dir).if({
|
||||
var tmp = "/tmp/_av_live_matnames_.txt";
|
||||
("ls -1 '" ++ dir ++ "' > '" ++ tmp ++ "' 2>/dev/null").systemCmd;
|
||||
File.exists(tmp).if({
|
||||
var content = File.use(tmp, "r", { |f| f.readAllString });
|
||||
("rm -f '" ++ tmp ++ "'").systemCmd;
|
||||
content.split($\n).do({ |fn|
|
||||
(fn.size > ext.size and: {
|
||||
fn.copyRange(fn.size - ext.size, fn.size - 1) == ext
|
||||
}).if({
|
||||
result = result.add(fn.copyRange(0, fn.size - ext.size - 1).asSymbol)
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
(dir == ~matDir).if({ result = (result ++ ~matSavedNames.asArray).as(Set).asArray });
|
||||
result
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it — expect PASS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/test_matnames.scd`
|
||||
Expected: `MATNAMES PASS`.
|
||||
|
||||
- [ ] **Step 5: Regression — the preset validator still loads 84.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/validate_presets.scd`
|
||||
Expected: `VALIDATE PASS` (84/84).
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/matrix_presets/test_matnames.scd
|
||||
git commit -m "fix(matrix): persist user saves in load list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: queued load engine — ~matWrapStep + /matrix/loadnext (Part B)
|
||||
|
||||
**Files:** Modify `sound_algo/data_only/matrix.scd` (init `~matQueuedLoad`; add `~matWrapStep`; wrap call in `~matPlay`; add `/matrix/loadnext` OSCdef); extend `test_matnames.scd`.
|
||||
|
||||
**Interfaces:** Produces `~matQueuedLoad` (nil | name String), `~matWrapStep.()` (applies queued load else evolve), OSC `/matrix/loadnext <name>`, feedback `/matrix/queued <name|"">`.
|
||||
|
||||
- [ ] **Step 1: Add the failing wrap check** to `test_matnames.scd` (before `0.exit`):
|
||||
|
||||
```supercollider
|
||||
// Part B: ~matWrapStep applies a queued load and clears the queue
|
||||
~lp[\matrix] = Array.fill(~matVoices.size, { Array.fill(~matBars, 0) });
|
||||
~matQueuedLoad = "techno_drive_live";
|
||||
~matWrapStep.();
|
||||
(~matQueuedLoad.isNil).not.if({ pass = false; "FAIL: queue not cleared after wrap".postln });
|
||||
(~lp[\matrix].flatten.sum == 0).if({ pass = false; "FAIL: queued preset not loaded".postln });
|
||||
```
|
||||
(Re-run uses the same `pass`/exit lines; rename the final print to `QUEUED PASS`/`QUEUED FAIL` or keep `MATNAMES`.)
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL** (`~matWrapStep` undefined).
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/test_matnames.scd`
|
||||
Expected: FAIL (`~matWrapStep` is nil / not understood).
|
||||
|
||||
- [ ] **Step 3: Init `~matQueuedLoad`** next to `~matSavedNames` (matrix.scd ~66):
|
||||
|
||||
```supercollider
|
||||
~matQueuedLoad = ~matQueuedLoad ? nil; // armed preset name for /matrix/loadnext (nil = none)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `~matWrapStep`** (place just before `~matPlay`, ~407):
|
||||
|
||||
```supercollider
|
||||
// -- ~matWrapStep : runs once per loop wrap. A queued load wins over evolve so a
|
||||
// freshly loaded preset plays one clean cycle before ÉVOLUE mutates it. --
|
||||
~matWrapStep = {
|
||||
~matQueuedLoad.notNil.if({
|
||||
~matLoad.(~matQueuedLoad);
|
||||
~matQueuedLoad = nil;
|
||||
~toscSend !? { ~toscSend.("/matrix/queued", "") };
|
||||
}, {
|
||||
~matEvolve.if({ ~matEvolveStep.() });
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Call it from the wrap** — replace matrix.scd line 422
|
||||
`(~matEvolve and: { ~lp[\matBar] < ~matPrevBar }).if({ ~matEvolveStep.() });` with:
|
||||
|
||||
```supercollider
|
||||
(~lp[\matBar] < ~matPrevBar).if({ ~matWrapStep.() });
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the `/matrix/loadnext` OSCdef** next to `/matrix/load` (~952):
|
||||
|
||||
```supercollider
|
||||
OSCdef(\mat_loadnext, { |msg|
|
||||
var name = msg[1].asString;
|
||||
~lp[\matPlaying].if({
|
||||
~matQueuedLoad = name;
|
||||
~toscSend !? { ~toscSend.("/matrix/queued", name) };
|
||||
}, {
|
||||
~matLoad.(name);
|
||||
~toscSend !? { ~toscSend.("/matrix/queued", "") };
|
||||
});
|
||||
}, '/matrix/loadnext');
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the SC checks — expect PASS.**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/matrix_presets/test_matnames.scd`
|
||||
Expected: PASS (queue cleared + grid non-empty after `~matWrapStep`).
|
||||
|
||||
- [ ] **Step 8: Commit.**
|
||||
```bash
|
||||
git add sound_algo/data_only/matrix.scd sound_algo/data_only/matrix_presets/test_matnames.scd
|
||||
git commit -m "feat(matrix): queued load at loop wrap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: web SUIVANT button + feedback (Part B)
|
||||
|
||||
**Files:** Modify `index.html`, `js/main.js`, `control.css`; create `test/queued-load.test.mjs`.
|
||||
|
||||
**Interfaces:** Consumes `send(path, ...args)` and `on(path, handler)` from main.js. The SUIVANT button id is `matrix-loadnext`.
|
||||
|
||||
- [ ] **Step 1: Write the failing web test** `web_realart/public/control/test/queued-load.test.mjs`:
|
||||
|
||||
```javascript
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
// Minimal: the SUIVANT click sends /matrix/loadnext with the selected preset,
|
||||
// and /matrix/queued toggles the armed class on the button.
|
||||
test("SUIVANT sends /matrix/loadnext with the selected name", () => {
|
||||
const dom = new JSDOM(`<select id="matrix-list"><option value="acid_journey_live" selected>x</option></select>
|
||||
<button id="matrix-loadnext">SUIVANT</button>`);
|
||||
const { document } = dom.window;
|
||||
const sent = [];
|
||||
const send = (p, ...a) => sent.push([p, ...a]);
|
||||
document.getElementById("matrix-loadnext").addEventListener("click", () =>
|
||||
send("/matrix/loadnext", document.getElementById("matrix-list").value));
|
||||
document.getElementById("matrix-loadnext").click();
|
||||
assert.deepStrictEqual(sent, [["/matrix/loadnext", "acid_journey_live"]]);
|
||||
});
|
||||
|
||||
test("/matrix/queued toggles the armed class", () => {
|
||||
const dom = new JSDOM(`<button id="matrix-loadnext">SUIVANT</button>`);
|
||||
const btn = dom.window.document.getElementById("matrix-loadnext");
|
||||
const onQueued = (name) => btn.classList.toggle("armed", !!name);
|
||||
onQueued("acid_journey_live"); assert.ok(btn.classList.contains("armed"));
|
||||
onQueued(""); assert.ok(!btn.classList.contains("armed"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it — expect FAIL** (jsdom dep / no button yet, depending on harness).
|
||||
|
||||
Run: `node --test web_realart/public/control/test/queued-load.test.mjs`
|
||||
Expected: FAIL (the wiring under test does not yet exist in main.js — this test is a spec of the handler shape; if `jsdom` is unavailable, mirror the existing tests' DOM stub instead).
|
||||
|
||||
- [ ] **Step 3: Add the SUIVANT button** to `index.html` `.matrix-persist`, after the CHARGER button (`<button id="matrix-load" ...>CHARGER</button>`):
|
||||
|
||||
```html
|
||||
<button id="matrix-loadnext" class="mat-action-btn">SUIVANT</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire it in `main.js`** — after the `matLoad` handler block:
|
||||
|
||||
```javascript
|
||||
// Queued load: arm the selected preset to swap at the next loop wrap.
|
||||
const matLoadNext = document.getElementById("matrix-loadnext");
|
||||
if (matLoadNext) matLoadNext.addEventListener("click", () => {
|
||||
const n = document.getElementById("matrix-list").value;
|
||||
if (n) send("/matrix/loadnext", n);
|
||||
});
|
||||
on("/matrix/queued", (args) => {
|
||||
const name = (args && args[0] != null) ? String(args[0]) : "";
|
||||
if (matLoadNext) {
|
||||
matLoadNext.classList.toggle("armed", name.length > 0);
|
||||
matLoadNext.textContent = name.length > 0 ? "SUIVANT → " + name : "SUIVANT";
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the `.armed` style** to `control.css`:
|
||||
|
||||
```css
|
||||
.mat-action-btn.armed { background: #2a4d2a; color: #9f9; border-color: #4a4;
|
||||
animation: matArmedPulse 1s ease-in-out infinite; }
|
||||
@keyframes matArmedPulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the web test — expect PASS.**
|
||||
|
||||
Run: `node --test web_realart/public/control/test/queued-load.test.mjs`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add web_realart/public/control/index.html web_realart/public/control/js/main.js web_realart/public/control/control.css web_realart/public/control/test/queued-load.test.mjs
|
||||
git commit -m "feat(control): SUIVANT queued-load button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: live smoke on macm1
|
||||
|
||||
**Files:** none (deploy + verify).
|
||||
|
||||
- [ ] **Step 1: Push + deploy.** `git push gitea-https main`; on macm1 `git pull origin main`; reboot the matrix engine (`zsh launcher/concert/launch_concert.sh` or re-source matrix.scd).
|
||||
- [ ] **Step 2: Part A smoke.** Confirm a previously user-saved preset (e.g. `saisia`) now appears in `/matrix/list` after the fresh boot (Part A) and loads.
|
||||
- [ ] **Step 3: Part B smoke.** With the playhead playing, arm SUIVANT on another preset → it swaps at the next loop wrap (not immediately); the button shows armed then clears. Arm while stopped → loads immediately.
|
||||
- [ ] **Step 4: Report** the smoke result to the user (their ear/eyes confirm the wrap timing).
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:** Part A (`~matNames` disk-scan ∪ Set) → Task 1. Part B engine (`~matQueuedLoad`, `~matWrapStep` wrap hook skip-evolve, `/matrix/loadnext`, `/matrix/queued`, stopped-fallback) → Task 2. Part B web (SUIVANT button, handler, feedback, armed css) → Task 3. Re-entrancy caveat → resolved (verified `~matLoadFile` only `~matApplyBar`s, no `~matPlay`), noted in Architecture. Tests (SC ~matNames + wrap; web button + feedback; live smoke) → Tasks 1-4. ✓
|
||||
|
||||
**Placeholder scan:** no TBD/TODO; each code step carries full code. The web test notes a jsdom fallback (mirror existing DOM stub) — the existing `test/*.test.mjs` show the project's stub pattern; not a placeholder, an adaptation note.
|
||||
|
||||
**Type consistency:** `~matQueuedLoad` (nil | String) set in `/matrix/loadnext` + read/cleared in `~matWrapStep`; `~matWrapStep` defined Task 2 Step 4, called Task 2 Step 5. `/matrix/queued` emitted from both `~matWrapStep` and the OSCdef, consumed by main.js `on("/matrix/queued")`. Button id `matrix-loadnext` consistent across index.html, main.js, css, test. Wrap condition uses `~lp[\matBar] < ~matPrevBar` (matches matrix.scd line 421-422).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Hand Display Cleanup + Side Panels 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:** Remove ghost/erratic/mesh artifacts from the hand overlay and add left/right front-view hand panels, so the operator sees exactly what the pinch detector sees.
|
||||
|
||||
**Architecture:** Fix the decode layer to stop discarding the Vision per-landmark confidence and per-hand chirality already on the wire; add pure geometry helpers in a new `hand_display.py` (plausibility gate, segment filter, panel mapping) consumed by `renderer.py`. Spec: `docs/superpowers/specs/2026-07-02-hand-display-cleanup-design.md`.
|
||||
|
||||
**Tech Stack:** Python 3.11, uv, pytest, Metal renderer (pyobjc) — helpers stay Metal-free.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python via `uv`; test command: `cd data_only_viz && uv run pytest tests/<file> -v`
|
||||
- Commit subjects <= 50 chars, body <= 72, no AI attribution, no underscore in scope.
|
||||
- `PoseKp.z` of iPhone hands keeps the raw wire value (finger-strike OSC reads z); confidence goes to `.c` clamped [0,1].
|
||||
- Body and face rendering (skeleton + mesh) unchanged; only hands are touched.
|
||||
- Defaults: conf_min=0.3, size_min=0.02, size_max=0.5, max_bone_ratio=1.2, panel side=30% of height, edge margin 2%, inner bbox margin 10%, left panel pid 5, right panel pid 6.
|
||||
- MediaPipe hands (c==1.0 landmarks) must still draw exactly as before through the new gates (confidence filter neutral for them).
|
||||
|
||||
---
|
||||
|
||||
### Task A: Decode confidence + chirality
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/iphone_usb_source.py` (`_make_point` ~line 36, `_decode_hands` ~line 41, TAG_HANDS handler ~line 176)
|
||||
- Modify: `data_only_viz/state.py` (add field near `persons_hands_iphone` ~line 108)
|
||||
- Test: `data_only_viz/tests/test_iphone_usb_source.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_decode_hands(payload) -> tuple[list[list[PoseKp]], list[int]] | None` — returns `(hands, chirality)`; chirality[i] is 0=left, 1=right (wire byte 1=right, anything else left). Landmark `.c` = clamp(wire_z, 0, 1); `.z` = raw wire_z unchanged.
|
||||
- Produces: `State.persons_hands_chirality: list[int]` (default empty list), written in the TAG_HANDS handler under `state.lock()` together with `persons_hands_iphone` (and `persons_hands` when `_write_hands`).
|
||||
|
||||
**Steps (TDD):**
|
||||
- [ ] Write failing tests in `test_iphone_usb_source.py` (follow the file's existing payload-building style): (1) a 2-hand payload with chirality bytes 0 and 1 and distinct z values round-trips: `.c` clamped from z, `.z` raw, chirality `[0, 1]`; (2) z values outside [0,1] (e.g. -0.2, 1.7) clamp to 0.0/1.0 in `.c` but stay raw in `.z`; (3) count=0 payload returns `([], [])`; (4) empty payload returns `None`.
|
||||
- [ ] Run: `cd data_only_viz && uv run pytest tests/test_iphone_usb_source.py -v` — new tests FAIL (current return is a bare list).
|
||||
- [ ] Implement: `_decode_hands` reads the chirality byte instead of skipping it, builds the chirality list, returns the tuple; `_make_point` gains the conf argument or is inlined — landmarks get `c=clamp(z,0,1)`, `z=raw`. Update the TAG_HANDS handler for the new return shape and store `persons_hands_chirality` under the lock. Add the `State` field with a short comment (0=left, 1=right, aligned with persons_hands_iphone).
|
||||
- [ ] Run the whole file: all PASS. Also run `uv run pytest tests/test_iphone_usb_reconnect.py -v` (imports the same module).
|
||||
- [ ] Commit: `feat(pose): decode hand confidence + chirality`
|
||||
|
||||
---
|
||||
|
||||
### Task B: hand_display helpers + overlay cleanup
|
||||
|
||||
**Files:**
|
||||
- Create: `data_only_viz/hand_display.py`
|
||||
- Create: `data_only_viz/tests/test_hand_display.py`
|
||||
- Modify: `data_only_viz/renderer.py` (`_update_skeleton` hands loops ~lines 397-402 and 415-421, `_update_mesh` hands block ~lines 510-517)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces in `hand_display.py` (pure functions over `list[PoseKp]`-like points with .x/.y/.c; no Metal, no numpy required):
|
||||
- `hand_size(kp) -> float` — hypot(wrist(0) ↔ middle-MCP(9)).
|
||||
- `hand_plausible(kp, conf_min=0.3, size_min=0.02, size_max=0.5) -> bool` — False if len(kp) < 21, size outside [size_min, size_max], or mean(.c) < conf_min.
|
||||
- `segment_ok(A, B, size, conf_min=0.3, max_bone_ratio=1.2) -> bool` — False if min(A.c, B.c) < conf_min or hypot(A↔B) > max_bone_ratio * size.
|
||||
- Consumes: real `.c` values from Task A (MediaPipe hands keep c=1.0 → gates neutral).
|
||||
|
||||
**Steps (TDD):**
|
||||
- [ ] Write failing tests in `test_hand_display.py` with tiny synthetic hands (21 PoseKp): plausible open hand accepted; tiny hand (size 0.005) rejected; huge hand (size 0.8) rejected; low-conf hand (all c=0.1) rejected; spike segment (bone 2× hand size) rejected by `segment_ok` while a normal bone passes; low-conf endpoint rejected.
|
||||
- [ ] Run: FAIL (module missing). Implement `hand_display.py`. Run: PASS.
|
||||
- [ ] Renderer: in the two hands loops of `_update_skeleton` (multi-person ~397 and single-person fallback ~415), skip the hand when `not hand_plausible(...)`, skip segments when `not segment_ok(A, B, size, ...)` (compute size once per hand), and push with `min(A.c, B.c)` instead of 1.0. In `_update_mesh`, delete the hands HAND_TRIANGLES block (and drop the now-unused HAND_TRIANGLES import if nothing else uses it).
|
||||
- [ ] Run: `uv run pytest tests/test_hand_display.py tests/test_iphone_usb_source.py -v` — PASS. Renderer has no unit tests (Metal); it is validated live.
|
||||
- [ ] Commit: `feat(viz): filter hand overlay artifacts`
|
||||
|
||||
---
|
||||
|
||||
### Task C: side panels front view
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/hand_display.py` (add panel helpers)
|
||||
- Modify: `data_only_viz/tests/test_hand_display.py`
|
||||
- Modify: `data_only_viz/renderer.py` (`_update_skeleton`, after the existing overlay pushes)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces in `hand_display.py`:
|
||||
- `PANEL_SIDE = 0.30`, `PANEL_MARGIN = 0.02`, `PANEL_INNER = 0.10` (module constants).
|
||||
- `panel_rect(side: str, aspect: float) -> tuple[x0, y0, x1, y1]` — normalized [0,1] screen rect of the square panel ("left"/"right"); square in PIXELS: width = PANEL_SIDE / aspect where aspect = view width/height, height = PANEL_SIDE; vertically centered; anchored at PANEL_MARGIN from the edge.
|
||||
- `panel_segments(kp, side, bones, aspect, mirror=True) -> list[tuple[ax, ay, bx, by]]` — landmarks bbox-normalized (aspect preserved: uniform scale on the larger bbox dimension, centered), mapped into `panel_rect` shrunk by PANEL_INNER, X flipped when mirror; returns [] if `hand_plausible` fails.
|
||||
- `panel_frame(side, aspect) -> list[tuple[ax, ay, bx, by]]` — the rect's 4 border segments.
|
||||
- Consumes: `state.persons_hands_chirality` (Task A) to route hands to panels; `push_seg`-style coordinates (normalized [0,1], y down) — the renderer converts to clip space WITHOUT applying the video mirror (panel coords are final screen coords).
|
||||
|
||||
**Steps (TDD):**
|
||||
- [ ] Write failing tests: (1) `panel_rect("left", aspect=1.0)` anchors at x0=0.02 and is vertically centered; `"right"` at x1=0.98; (2) with aspect=16/9 the rect is square in pixels (width*aspect == height); (3) `panel_segments` output stays inside the inner rect and preserves aspect (a hand twice as tall as wide maps to segments twice as tall as wide, in pixel space); (4) mirror=True flips X within the panel; (5) implausible hand -> []; (6) `panel_frame` returns 4 segments tracing the rect.
|
||||
- [ ] Run: FAIL. Implement. Run: PASS.
|
||||
- [ ] Renderer: in `_update_skeleton`, after the overlay section, add a panels section: read `persons_hands` + `persons_hands_chirality` (fallback: if chirality missing/misaligned, order plausible hands by screen cx — mirror-aware — first→left panel, second→right); for each side draw `panel_frame` (conf 1.0, pid 7) then `panel_segments` (conf 1.0, pid 5 left / 6 right) via a local `push_panel(ax, ay, bx, by, conf, pid)` that converts [0,1]→clip like `push_seg` but NEVER applies `mirror`. Compute `aspect` from `s.width / s.height` (guard height 0 → 1.0).
|
||||
- [ ] Run: `uv run pytest tests/test_hand_display.py -v` — PASS.
|
||||
- [ ] Commit: `feat(viz): left/right hand front-view panels`
|
||||
|
||||
---
|
||||
|
||||
## After the plan (manual)
|
||||
|
||||
Deploy to macm1 (pull + restart stack with PINCH_ENABLE=1 FINGER_DEBUG=1) and validate visually: skeleton still on video, no ghost hands / spikes / hand mesh, panels track each hand and go empty when the hand leaves.
|
||||
@@ -0,0 +1,363 @@
|
||||
# Pinch Open-Hand Gate + Debounce 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 the finger pinch robust against relaxed-hand/fist false positives during full-body play so it can be re-enabled live.
|
||||
|
||||
**Architecture:** Two new gates inside `PinchDetector.step()` (`data_only_viz/finger_strike.py`): an open-hand extension gate (winner engages only if >=2 non-pinching fingers are extended) and an engage debounce (N consecutive qualifying frames). Release stays immediate. Strict defaults live in the env-var reads of `action_head_pub.py`; constructor defaults stay permissive so existing call sites and tests keep today's behavior.
|
||||
|
||||
**Tech Stack:** Python 3.11, uv, pytest. Spec: `docs/superpowers/specs/2026-07-02-pinch-open-hand-gate-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python via `uv` only; test command: `cd data_only_viz && uv run pytest tests/... -v`
|
||||
- Commit subjects <= 50 chars, body lines <= 72, no AI attribution, no underscore in scope.
|
||||
- `ratio_on`/`ratio_off`/`margin` logic unchanged; FINGER_PIANO strike detector untouched.
|
||||
- Env defaults (live behavior): `PINCH_EXT_RATIO=1.35`, `PINCH_EXT_MIN=2`, `PINCH_DEBOUNCE_FRAMES=3`.
|
||||
- Constructor defaults (backward compat): `ext_ratio=1.35`, `ext_min=0`, `debounce_frames=1` — spec's "existing tests stay valid" requirement wins over its defaults table; the strict values are applied at the env layer.
|
||||
- `PINCH_ENABLE` default stays `0` in `launcher/concert/launch_concert.sh` — flipping it back to `1` is gated on LIVE validation by the user (spec Rollout step 3), NOT part of this plan.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Open-hand extension gate in PinchDetector
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/finger_strike.py` (class `_PinchState` ~line 135, class `PinchDetector` ~lines 143-207)
|
||||
- Test: `data_only_viz/tests/test_finger_strike.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `PinchDetector`, `PinchEvent`, test helpers `_pinch_hand`, `_OPEN`, `_PINCH` (already in the test file).
|
||||
- Produces: `PinchDetector.__init__(..., ext_ratio: float = 1.35, ext_min: int = 0, debounce_frames: int = 1)` with public attrs `ext_ratio`, `ext_min`, `debounce_frames` (Task 3 asserts them). `debounce_frames` is accepted here but its logic lands in Task 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `data_only_viz/tests/test_finger_strike.py`:
|
||||
|
||||
```python
|
||||
def _relaxed_hand():
|
||||
"""Relaxed/fist hand: thumb touches the index tip, but middle/ring/
|
||||
pinky are curled near the wrist (NOT extended). The runner-up tip is
|
||||
far from the thumb so the closest-wins margin passes -- only the
|
||||
extension gate can reject this hand."""
|
||||
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
|
||||
lm[0] = [0.5, 0.8, 0.0] # WRIST
|
||||
lm[9] = [0.5, 0.5, 0.0] # MIDDLE_MCP -> hand size 0.3
|
||||
lm[4] = [0.45, 0.55, 0.0] # THUMB_TIP
|
||||
lm[8] = [0.47, 0.55, 0.0] # INDEX_TIP: ratio 0.067 (pinch-close)
|
||||
lm[12] = [0.60, 0.62, 0.0] # MIDDLE_TIP curled (ext ~0.69)
|
||||
lm[16] = [0.62, 0.65, 0.0] # RING_TIP curled (ext ~0.64)
|
||||
lm[20] = [0.64, 0.68, 0.0] # LITTLE_TIP curled (ext ~0.62)
|
||||
return lm
|
||||
|
||||
|
||||
def test_relaxed_hand_rejected_by_extension_gate():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=2)
|
||||
out = []
|
||||
for i in range(10):
|
||||
out += det.step([_relaxed_hand()], i * 0.033)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_open_hand_pinch_passes_extension_gate():
|
||||
# _pinch_hand parks middle/ring/little far from the wrist
|
||||
# (ext 1.67 / 1.49 / 1.37, all >= 1.35) -> 3 extended >= ext_min 2.
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=2)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
ev = det.step([_pinch_hand(*_PINCH)], 0.1)
|
||||
assert len(ev) == 1 and ev[0].state == 1 and ev[0].finger == 1
|
||||
|
||||
|
||||
def test_extension_gate_defeat_reproduces_old_behavior():
|
||||
# ext_min=0 disables the gate: the relaxed hand fires like today.
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
ext_min=0)
|
||||
ev = det.step([_relaxed_hand()], 0.1)
|
||||
assert len(ev) == 1 and ev[0].state == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the new ones fail**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
|
||||
Expected: `test_relaxed_hand_rejected_by_extension_gate` and `test_open_hand_pinch_passes_extension_gate` FAIL with `TypeError: PinchDetector.__init__() got an unexpected keyword argument 'ext_min'`; all pre-existing tests PASS.
|
||||
|
||||
- [ ] **Step 3: Implement the extension gate**
|
||||
|
||||
In `data_only_viz/finger_strike.py`, replace `PinchDetector.__init__` with:
|
||||
|
||||
```python
|
||||
def __init__(self, ratio_on: float = 0.45, ratio_off: float = 0.65,
|
||||
refractory_ms: float = 250.0, history_slots: int = 2,
|
||||
margin: float = 0.20, ext_ratio: float = 1.35,
|
||||
ext_min: int = 0, debounce_frames: int = 1) -> None:
|
||||
self.ratio_on = ratio_on
|
||||
self.ratio_off = ratio_off
|
||||
self.refractory_s = refractory_ms / 1000.0
|
||||
# Only the single nearest fingertip may engage, and only if it is at
|
||||
# least `margin` (in size-normalized units) nearer than the runner-up.
|
||||
# Rejects the adjacent-finger ambiguity when fingers curl together.
|
||||
self.margin = margin
|
||||
# Open-hand gate: the winner may engage only when at least ext_min
|
||||
# of the 3 non-pinching fingers are extended (tip-to-wrist distance
|
||||
# above ext_ratio hand-sizes). Rejects relaxed-hand/fist false
|
||||
# pinches during full-body play. ext_min=0 disables the gate; the
|
||||
# live defaults come from the PINCH_EXT_* env vars (action_head_pub),
|
||||
# constructor defaults preserve legacy behavior.
|
||||
self.ext_ratio = ext_ratio
|
||||
self.ext_min = int(ext_min)
|
||||
# Engage fires only after debounce_frames consecutive qualifying
|
||||
# frames; release stays immediate. 1 = no debounce. (Enforced in
|
||||
# step(); wired fully by the debounce task.)
|
||||
self.debounce_frames = max(1, int(debounce_frames))
|
||||
self._state = [[_PinchState() for _ in range(4)]
|
||||
for _ in range(history_slots)]
|
||||
```
|
||||
|
||||
Then in `step()`, compute per-tip wrist extension next to the existing ratio loop and add the gate to the winner condition — replace the block from `ratios = []` down to the `winner = ...` assignment with:
|
||||
|
||||
```python
|
||||
ratios = []
|
||||
exts = []
|
||||
for tip_idx in PINCH_TIPS:
|
||||
fx = _finite(_coord(lm[tip_idx], "x", 0), 0.5)
|
||||
fy = _finite(_coord(lm[tip_idx], "y", 1), 0.5)
|
||||
ratios.append(math.hypot(fx - tx, fy - ty) / size)
|
||||
exts.append(math.hypot(fx - wx, fy - wy) / size)
|
||||
# closest-wins + margin: pick the single nearest fingertip, and treat
|
||||
# it as a pinch only if it is clearly nearer than the runner-up.
|
||||
order = sorted(range(4), key=lambda j: ratios[j])
|
||||
nearest, runner = order[0], order[1]
|
||||
# open-hand gate: a deliberate pinch keeps the other fingers
|
||||
# extended; a fist/relaxed hand has them curled at the wrist.
|
||||
open_ok = self.ext_min <= 0 or sum(
|
||||
1 for j in range(4)
|
||||
if j != nearest and exts[j] >= self.ext_ratio
|
||||
) >= self.ext_min
|
||||
winner = nearest if (
|
||||
ratios[nearest] < self.ratio_on
|
||||
and (ratios[runner] - ratios[nearest]) >= self.margin
|
||||
and open_ok
|
||||
) else -1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full test file to verify everything passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
|
||||
Expected: ALL PASS (new gate tests + every pre-existing strike/pinch test).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/finger_strike.py data_only_viz/tests/test_finger_strike.py
|
||||
git commit -m "feat(pose): pinch open-hand extension gate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Engage debounce in PinchDetector
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/finger_strike.py` (class `_PinchState`, `PinchDetector.step()` engage branch + absent-slot loop)
|
||||
- Test: `data_only_viz/tests/test_finger_strike.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PinchDetector(..., debounce_frames=N)` from Task 1 (constructor already accepts and stores it); test helpers `_pinch_hand`, `_OPEN`, `_PINCH`.
|
||||
- Produces: engage emitted only on the `debounce_frames`-th consecutive qualifying frame; release edges unchanged (immediate).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `data_only_viz/tests/test_finger_strike.py`:
|
||||
|
||||
```python
|
||||
def test_debounce_delays_engage_to_nth_frame():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.00)
|
||||
a = det.step([_pinch_hand(*_PINCH)], 0.10) # qualifying 1
|
||||
b = det.step([_pinch_hand(*_PINCH)], 0.13) # qualifying 2
|
||||
c = det.step([_pinch_hand(*_PINCH)], 0.16) # qualifying 3 -> engage
|
||||
assert a == [] and b == []
|
||||
assert len(c) == 1 and c[0].state == 1 and c[0].finger == 1
|
||||
|
||||
|
||||
def test_debounce_jitter_resets_counter():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_PINCH)], 0.00) # qualifying 1
|
||||
det.step([_pinch_hand(*_PINCH)], 0.03) # qualifying 2
|
||||
det.step([_pinch_hand(*_OPEN)], 0.06) # jitter -> counter resets
|
||||
a = det.step([_pinch_hand(*_PINCH)], 0.09) # qualifying 1 again
|
||||
b = det.step([_pinch_hand(*_PINCH)], 0.12) # qualifying 2
|
||||
assert a == [] and b == []
|
||||
|
||||
|
||||
def test_release_immediate_after_debounced_engage():
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
|
||||
debounce_frames=3)
|
||||
det.step([_pinch_hand(*_PINCH)], 0.00)
|
||||
det.step([_pinch_hand(*_PINCH)], 0.03)
|
||||
eng = det.step([_pinch_hand(*_PINCH)], 0.06) # 3rd frame -> engage
|
||||
rel = det.step([_pinch_hand(*_OPEN)], 0.09) # very next frame
|
||||
assert len(eng) == 1 and eng[0].state == 1
|
||||
assert len(rel) == 1 and rel[0].state == 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the new ones fail**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
|
||||
Expected: the 3 new debounce tests FAIL (engage fires on the first qualifying frame, so `a`/`eng` assertions break); everything else PASSES.
|
||||
|
||||
- [ ] **Step 3: Implement the debounce**
|
||||
|
||||
In `data_only_viz/finger_strike.py`, add the counter to `_PinchState`:
|
||||
|
||||
```python
|
||||
class _PinchState:
|
||||
__slots__ = ("engaged", "last_t", "qual")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.engaged: bool = False
|
||||
self.last_t: float = -1e9
|
||||
self.qual: int = 0 # consecutive qualifying frames (debounce)
|
||||
```
|
||||
|
||||
Replace the per-finger edge loop in `step()` (the `for i in range(4):` block) with:
|
||||
|
||||
```python
|
||||
for i in range(4):
|
||||
st = self._state[slot][i]
|
||||
if st.engaged:
|
||||
# release when this finger opens, or another finger took over.
|
||||
if i != winner or ratios[i] > self.ratio_off:
|
||||
st.engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
elif i == winner and (t_now - st.last_t) >= self.refractory_s:
|
||||
# engage only after debounce_frames consecutive qualifying
|
||||
# frames; release below stays edge-immediate.
|
||||
st.qual += 1
|
||||
if st.qual >= self.debounce_frames:
|
||||
st.engaged = True
|
||||
st.last_t = t_now
|
||||
st.qual = 0
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=1))
|
||||
else:
|
||||
st.qual = 0
|
||||
```
|
||||
|
||||
And reset the counter for absent slots — replace the trailing absent-slot loop with:
|
||||
|
||||
```python
|
||||
for slot in range(len(self._state)):
|
||||
if slot not in present:
|
||||
for i in range(4):
|
||||
st = self._state[slot][i]
|
||||
st.qual = 0
|
||||
if st.engaged:
|
||||
st.engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
return events
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full test file to verify everything passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_finger_strike.py -v`
|
||||
Expected: ALL PASS — new debounce tests AND the pre-existing pinch tests (they use the default `debounce_frames=1`, i.e. no debounce).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/finger_strike.py data_only_viz/tests/test_finger_strike.py
|
||||
git commit -m "feat(pose): pinch engage debounce"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Env plumbing + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/action_head_pub.py:185-190` (`_init_finger_piano`)
|
||||
- Modify: `CLAUDE.md` (repo root, env table after the `PINCH_REFRACTORY_MS` row)
|
||||
- Test: `data_only_viz/tests/test_action_head_finger_emit.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PinchDetector` attrs `ext_ratio`, `ext_min`, `debounce_frames` (Task 1); existing test helper `_pub_envs(monkeypatch, finger_piano, pinch_enable)`.
|
||||
- Produces: env vars `PINCH_EXT_RATIO` (default 1.35), `PINCH_EXT_MIN` (default 2), `PINCH_DEBOUNCE_FRAMES` (default 3) wired to the live detector.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `data_only_viz/tests/test_action_head_finger_emit.py`:
|
||||
|
||||
```python
|
||||
def test_pinch_gate_env_plumbing(monkeypatch):
|
||||
monkeypatch.setenv("PINCH_EXT_RATIO", "1.5")
|
||||
monkeypatch.setenv("PINCH_EXT_MIN", "3")
|
||||
monkeypatch.setenv("PINCH_DEBOUNCE_FRAMES", "5")
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
assert pub._pinch_det.ext_ratio == 1.5
|
||||
assert pub._pinch_det.ext_min == 3
|
||||
assert pub._pinch_det.debounce_frames == 5
|
||||
|
||||
|
||||
def test_pinch_gate_env_defaults_are_strict(monkeypatch):
|
||||
# Live defaults: gate ON (2 extended fingers) + 3-frame debounce.
|
||||
for k in ("PINCH_EXT_RATIO", "PINCH_EXT_MIN", "PINCH_DEBOUNCE_FRAMES"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
pub = _pub_envs(monkeypatch, finger_piano="0", pinch_enable="1")
|
||||
assert pub._pinch_det.ext_ratio == 1.35
|
||||
assert pub._pinch_det.ext_min == 2
|
||||
assert pub._pinch_det.debounce_frames == 3
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_action_head_finger_emit.py -v`
|
||||
Expected: both new tests FAIL (`ext_min == 0`, `debounce_frames == 1` — constructor defaults); pre-existing tests PASS.
|
||||
|
||||
- [ ] **Step 3: Wire the env vars**
|
||||
|
||||
In `data_only_viz/action_head_pub.py`, replace the `self._pinch_det = PinchDetector(...)` call with:
|
||||
|
||||
```python
|
||||
self._pinch_det = PinchDetector(
|
||||
ratio_on=float(os.environ.get("PINCH_RATIO_ON", "0.45")),
|
||||
ratio_off=float(os.environ.get("PINCH_RATIO_OFF", "0.65")),
|
||||
refractory_ms=float(os.environ.get("PINCH_REFRACTORY_MS", "250")),
|
||||
margin=float(os.environ.get("PINCH_MARGIN", "0.20")),
|
||||
ext_ratio=float(os.environ.get("PINCH_EXT_RATIO", "1.35")),
|
||||
ext_min=int(os.environ.get("PINCH_EXT_MIN", "2")),
|
||||
debounce_frames=int(os.environ.get("PINCH_DEBOUNCE_FRAMES", "3")),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run both test files to verify everything passes**
|
||||
|
||||
Run: `cd data_only_viz && uv run pytest tests/test_action_head_finger_emit.py tests/test_finger_strike.py -v`
|
||||
Expected: ALL PASS.
|
||||
|
||||
- [ ] **Step 5: Document the env vars**
|
||||
|
||||
In the repo-root `CLAUDE.md` env table, insert after the `PINCH_REFRACTORY_MS` row:
|
||||
|
||||
```markdown
|
||||
| `PINCH_EXT_RATIO` | `1.35` | tip-to-wrist distance / hand size above which a finger counts as extended (open-hand pinch gate) |
|
||||
| `PINCH_EXT_MIN` | `2` | min extended non-pinching fingers required for a pinch to engage; `0` disables the open-hand gate |
|
||||
| `PINCH_DEBOUNCE_FRAMES` | `3` | consecutive qualifying frames before a pinch engage fires (release stays immediate); `1` disables |
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/action_head_pub.py data_only_viz/tests/test_action_head_finger_emit.py CLAUDE.md
|
||||
git commit -m "feat(pose): pinch gate env vars + docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After the plan (manual, NOT tasks)
|
||||
|
||||
1. Deploy to macm1 (pull) and run a live session with `PINCH_ENABLE=1`:
|
||||
full-body play must produce zero false engages; deliberate open-hand
|
||||
pinches must still fire.
|
||||
2. Only after the user confirms live: flip `PINCH_ENABLE` default to `1`
|
||||
in `launcher/concert/launch_concert.sh` (separate commit).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Matrix 6-colour contrast derivation (refactor SP-C1)
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: pattern bank only (`sound_algo/data_only/matrix_presets/patterns.py`) + regeneration/validation. NO engine/web changes. Sub-project C1 of the matrix refactor (cycle 2); the hand-authored families/motifs are a separate follow-up (SP-C2).
|
||||
|
||||
## Context
|
||||
|
||||
`patterns.py` holds the matrix's per-voice patterns: `DRUMS` (12 drum voices, rhythm-DSL strings `. o - x X` = velocities 0/0.30/0.50/0.80/1.0) and `MEL` (9 melodic voices, `(degree, vel)` motifs), each as a 6-element list (colours 1-6) per groove family, parsed by `_rhythm`/`_mel`. `patterns_for(voice, preset)` picks a family, falling back to `default`.
|
||||
|
||||
The 6 colours follow a loose convention (1=base, 4=busy, 5=sparse, 6=fill) but it is NOT codified: many voices' `default` colours are low-contrast (e.g. kick default colours 1-4 are all 4-8 hits), and 14/22 voices have ONLY the `default` family. So the matrix's per-bar variation reads as flat for many voices.
|
||||
|
||||
This sub-project codifies a deterministic **6-colour contrast convention** and derives a well-contrasted 6-colour spectrum for **every voice's `default` family**, keeping each voice's characteristic colour 1 as the base. Hand-authored STYLE families (the existing per-style variants) are left untouched — their character is preserved.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Contrast rule applied uniformly** to the `default` family of ALL 22 voices (not just the 14 thin ones) — "base saine partout". Each voice's `default` becomes `[base] + derive(base)` where `base` = that voice's current `default` colour-1.
|
||||
- **Authored style families untouched** (kick/hats `techno`/`breaks`/`dnb`/…, sub `dub`, acid `psy`, etc.) — the rule only normalizes the `default` family.
|
||||
- **Deterministic** transformations (no RNG) so regeneration is byte-for-byte reproducible.
|
||||
- Engineering only (density/validity invariants); musical character of new families is SP-C2 (hand-authored, user-guided).
|
||||
|
||||
## The 6-colour convention
|
||||
|
||||
| Colour | Role | Density vs base |
|
||||
|--------|------|-----------------|
|
||||
| 1 | base (characteristic) | — |
|
||||
| 2 | busier | ≥ base |
|
||||
| 3 | broken (syncopated / octave-displaced) | ≈ base, hits at different positions |
|
||||
| 4 | double (denser, double-time / arpeggiated) | ≥ base, ≥ colour 2 |
|
||||
| 5 | sparse (minimal) | < base |
|
||||
| 6 | fill (base + end roll / closing run) | ≥ base, last steps active |
|
||||
|
||||
## Architecture (pure content, deterministic)
|
||||
|
||||
### `derive_drum_colors(base)` — base is a 16-step velocity list
|
||||
- **2 busier**: add ghost hits (`0.30`) on a fixed set of currently-rest off-beat steps.
|
||||
- **3 broken**: displace off-beat hits by +1 step (fixed syncopation), preserving hit count.
|
||||
- **4 double**: fill currently-rest 8th-note steps (`i % 2 == 0`) with `0.80`, keeping existing accents.
|
||||
- **5 sparse**: keep only accents (`1.0`) and down-beats (`i % 4 == 0`); other steps → rest.
|
||||
- **6 fill**: copy base, then overwrite the last 4 steps (12-15) with an accelerating roll `[0.8, 0.8, 1.0, 1.0]`.
|
||||
|
||||
### `derive_mel_colors(base)` — base is a 16-step list of `(degree, vel)` or `None`
|
||||
- **2 busier**: fill a fixed set of rest steps with a passing note (previous active degree, lower vel).
|
||||
- **3 broken**: octave-displace notes at fixed positions (`+7` or `+12` to degree).
|
||||
- **4 double**: fill rest 8th-note steps (`i % 2 == 0`) by repeating the nearest active degree.
|
||||
- **5 sparse**: keep only notes at strong steps (`i % 4 == 0`).
|
||||
- **6 fill**: replace the last 4 steps with a stepwise run from the base's last active degree.
|
||||
|
||||
Both derivations are pure functions of the base (deterministic). They operate on the **parsed step lists** (the 16-element output of `_rhythm`/`_mel`: drums = velocity floats with 0 for rest; mel = `(degree, vel)` tuples or `None`), returning the same shape — so the result plugs straight into the existing emit path. No DSL-string manipulation.
|
||||
|
||||
### Application
|
||||
- For EACH of the 22 voices, the `default` family becomes `[base, *derive(base)]` (6 colours) where `base` = the voice's current `default[0]`. Drum voices use `derive_drum_colors`; melodic voices use `derive_mel_colors`.
|
||||
- All non-`default` families (the hand-authored style variants) are emitted unchanged.
|
||||
- `patterns_for` is unaffected structurally (it still returns 6 colours per voice/family); only the `default` family's colours 2-6 change.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python** (standalone assertion script, `uv run python`, no pytest dep — like `test_default_mods.py`):
|
||||
- density invariants on representative bases: `density(5) < density(1)`, `density(2) >= density(1)`, `density(4) >= density(1)`, colour 6 has its last 4 steps active; colour 3 has the same hit count as base but ≥1 hit at a different position (drums).
|
||||
- validity: every derived colour is 16 steps; drum velocities ∈ {0, .30, .50, .80, 1.0}; mel degrees within the legal range used by `_mel`; rests preserved where required.
|
||||
- determinism: `derive_drum_colors(base) == derive_drum_colors(base)` (and mel) — identical on repeat.
|
||||
- application: for every voice, the `default` family has 6 colours and colours 2-6 are NOT all equal to colour 1 (real contrast); style families are byte-identical to before.
|
||||
- **SC validation** (`validate_presets.scd`): regenerate the 28 presets, load all 28 (`28/28 PASS`) — the derived `default` colours parse and play.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Hand-authored new families / characterful motifs (SP-C2 — user-guided).
|
||||
- New presets/styles (SP-D).
|
||||
- Any engine/web change.
|
||||
- Changing colour 1 (the characteristic base) of any voice, or touching any non-`default` family.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- Apply to the `default` family of all 22 voices; keep colour 1; derive 2-6.
|
||||
- Deterministic transforms (no RNG).
|
||||
- Style families untouched.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Default pose→instrument mappings in presets (refactor SP-A)
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: preset generator only (`sound_algo/data_only/matrix_presets/generate_presets.py`) + its validation. NO engine changes (the Cycle-1 `pitch`/`res` targets + the per-voice mod system are already shipped). Sub-project A of the matrix refactor (cycle 2).
|
||||
|
||||
## Context
|
||||
|
||||
The matrix engine supports per-voice modulation bindings `~matVoiceMods[voice]` =
|
||||
list of `(source, target, depth)`, targets `amp/cutoff/pan/rev/res/pitch`, 19
|
||||
pose sources, applied live. Presets carry these via `voiceMods`/`voicePoses`
|
||||
keys, but only `techno_drive` has demo bindings (`VOICE_MODS_DEMO`/
|
||||
`VOICE_POSES_DEMO`, generate_presets.py:518-529); the other 27 presets ship
|
||||
empty. This sub-project bakes a **fixed, logical, role-adapted default mapping
|
||||
scheme** into every preset so the body/hands control instruments expressively
|
||||
out of the box.
|
||||
|
||||
### Constraints carried in
|
||||
|
||||
- **No runtime switching.** Mappings are FIXED per instrument, identical across
|
||||
all presets (one global consistent scheme). No close/far mode, no distance
|
||||
watcher — explicitly out of scope (the engine stays untouched).
|
||||
- **Mixed sources by role** (`project_concert_mode`: hand tracking dies at
|
||||
full-body distance). Soloist voices use HAND sources (expressive, close-up);
|
||||
energy/structure voices use BODY sources (robust at any distance).
|
||||
- **Cycle-1 target eligibility** (which voices accept which target) must be
|
||||
respected — emit only valid bindings:
|
||||
- `cutoff`: perc, acid, arp, lead, stab, pad, reese, melody, chord
|
||||
- `res`: sub, acid, arp, lead, stab, pad, reese, bells, melody, chord
|
||||
- `pitch`: sub, acid, arp, lead, stab, pad, reese, bells, perc, tom
|
||||
- `amp`, `pan`, `rev`: all voices
|
||||
- **Cycle-1 minors honored**: pitch is bipolar (neutral at source 0.5 → the
|
||||
resting `rHandY`/`bodyY` give 0 transpose) and applies only on the step path
|
||||
(active melodic voices have steps → fine); `res` is additive-from-0 (a source
|
||||
resting low gives low resonance) so res bindings use modest depth and sources
|
||||
whose rest reads musically (limbSpan low when arms down opens up as they
|
||||
spread; lHandX similar).
|
||||
|
||||
## The fixed mapping scheme
|
||||
|
||||
Applied to each voice ACTIVE in a preset (skip muted voices), filtered by
|
||||
eligibility:
|
||||
|
||||
| Role | Voices | Bindings (source → target, depth) |
|
||||
|------|--------|-----------------------------------|
|
||||
| Lead (hands) | acid, arp, lead, stab | rHandX→amp 0.4 · rHandY→pitch 0.4 · lHandY→cutoff 0.5 · lHandX→res 0.4 |
|
||||
| High-mel (hands) | bells | rHandX→amp 0.4 · rHandY→pitch 0.4 · lHandX→res 0.4 |
|
||||
| High-mel (hands) | melody | rHandX→amp 0.4 · lHandY→cutoff 0.5 · lHandX→res 0.4 |
|
||||
| Bass (body) | reese | bodyVitesse→cutoff 0.5 · bodyY→pitch 0.3 · limbSpan→res 0.4 · bodyX→pan 0.3 |
|
||||
| Bass (body) | sub | bodyVitesse→res 0.4 · bodyY→pitch 0.3 · bodyX→pan 0.3 |
|
||||
| Pad/harm (body) | pad, chord | bodyVitesse→cutoff 0.4 · limbSpan→rev 0.4 · bodyX→pan 0.3 |
|
||||
| Pitched perc (body) | perc | bodyVitesse→cutoff 0.4 · bodyY→pitch 0.3 · bodyX→pan 0.3 |
|
||||
| Pitched perc (body) | tom | bodyY→pitch 0.3 · bodyVitesse→amp 0.3 · bodyX→pan 0.3 |
|
||||
| Drums (body) | kick, hats, clap, ride, rim, snare, crash, shaker | bodyVitesse→rev 0.3 · bodyX→pan 0.3 |
|
||||
| FX (body) | sweep, fx | danse→rev 0.5 · bodyVitesse→amp 0.4 |
|
||||
|
||||
Every binding above is eligibility-valid for its voice (verified against the
|
||||
Cycle-1 table: e.g. sub uses `res` not `cutoff`; bells omits `cutoff`; melody
|
||||
omits `pitch`; perc/tom omit `res`).
|
||||
|
||||
**Default poses** (`voicePoses`): minimal — `danse → gate` on `fx` and `sweep`
|
||||
(the dance gesture swells the effects). All other voices: empty. Fine-grained
|
||||
pose triggers stay a per-performance live choice.
|
||||
|
||||
## Architecture (pure content)
|
||||
|
||||
- New helper `default_mods(voice)` in `generate_presets.py`: returns the role's
|
||||
binding list for the voice, each binding a `(source, target, depth)` tuple,
|
||||
**filtered through an eligibility map** (a Python mirror of the Cycle-1
|
||||
per-voice target sets) so an invalid binding can never be emitted. A voice
|
||||
with no role match returns `[]`.
|
||||
- New helper `default_poses(voice)`: returns `[("danse","gate")]` for `fx`/
|
||||
`sweep`, else `[]`.
|
||||
- The existing `emit_voicemods(name)` / `emit_voiceposes(name)` (generate_
|
||||
presets.py:542-567) source their per-voice lists from these helpers. The
|
||||
`VOICE_MODS_DEMO`/`VOICE_POSES_DEMO` literals (incl. the techno_drive demo)
|
||||
are REMOVED — the helpers are the single source of default bindings (YAGNI:
|
||||
no per-preset override layer for now; the scheme is uniform across presets).
|
||||
- **Active-voice filter**: emit default mappings only for voices the preset
|
||||
actually plays (has a non-empty step pattern / is in the kit). If determining
|
||||
"active" per voice is impractical in `to_sc`, emitting for all 22 is an
|
||||
acceptable fallback (mappings on silent voices are inert — no notes to
|
||||
modulate); prefer active-only for tidiness, document whichever is chosen.
|
||||
- No SC, no web changes. The web target dropdown + SC gating already handle
|
||||
these bindings (Cycle 1).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python** (the generator's own test surface; use `uv`):
|
||||
- `default_mods("lead")` returns the 4 lead-hand bindings; `default_mods("sub")`
|
||||
returns res (not cutoff) + pitch + pan; `default_mods("bells")` omits cutoff;
|
||||
`default_mods("melody")` omits pitch; `default_mods("perc")`/`("tom")` omit
|
||||
res; `default_mods("kick")` returns only body amp/pan/rev-eligible bindings
|
||||
(bodyVitesse→rev, bodyX→pan).
|
||||
- Eligibility filter: every binding `default_mods(v)` returns has a target in
|
||||
that voice's eligibility set (assert across all 22 voices — no invalid
|
||||
emission).
|
||||
- `default_poses("fx")` == `[("danse","gate")]`; `default_poses("kick")` == `[]`.
|
||||
- **SC validation** (extend the existing `matrix_presets/validate_presets.scd`):
|
||||
regenerate the 28 presets, load all 28 via `~matLoadFile` (28/28 PASS), and
|
||||
assert a representative preset has a lead voice with its 4 hand bindings in
|
||||
`~matVoiceMods` and a drum voice with its body bindings — proving the emitted
|
||||
defaults parse and survive load.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any engine change (no mode switch, no new sources/targets — Cycle 1 done).
|
||||
- Preset variety / auto-color arrangement / new presets (sub-projects B/C/D).
|
||||
- Per-preset bespoke mappings (the scheme is uniform across presets; per-preset
|
||||
tuning / an override layer can come later if needed).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- One uniform fixed scheme across all presets; soloists→hands, structure→body.
|
||||
- Depths: 0.3–0.5 (modest, musical). Pitch depth 0.3–0.4 (≈ ±2-3 scale degrees
|
||||
at `~matPitchRange` 7).
|
||||
- `danse→gate` on fx/sweep is the only default pose.
|
||||
- Emit for active voices (all-22 fallback acceptable, documented).
|
||||
@@ -0,0 +1,188 @@
|
||||
# Matrix Instrument + Colour Editor Refactor — Design
|
||||
|
||||
Date: 2026-06-29
|
||||
Scope: `web_realart/public/control/` (front) + a small `sound_algo/data_only/matrix.scd` engine extension.
|
||||
|
||||
## Context & Goals
|
||||
|
||||
The matrix control surface (`control.js`, 1151 lines, vanilla JS, no build step) drives the
|
||||
SuperCollider data-only engine over a WebSocket-wrapped OSC bridge. The per-voice
|
||||
instrument + per-(voice,colour) variation editor has grown tangled. Three goals,
|
||||
validated with the user:
|
||||
|
||||
1. **Unify instrument + colour editing** into one coherent per-voice editor.
|
||||
2. **Better live UX** on **desktop** (mouse/keyboard, large screen) — keep the grid visible.
|
||||
3. **Maintainability** — split the monolith into ES modules, single source of truth.
|
||||
|
||||
Two feature additions requested during design:
|
||||
|
||||
4. **Adaptive step sequencer** — note pitch (piano-roll) for melodic voices, drum-machine
|
||||
(on/off + accent) for percussion, with a per-voice toggle.
|
||||
5. **A SYNTH/FX tab** to edit the instrument's parameters and effects (full scope:
|
||||
cutoff, resonance, reverb send, pan, amp, octave, stretch — requires a small engine
|
||||
extension since `res`/`rev` are not yet driven statically).
|
||||
|
||||
## Current State (frictions, from code audit)
|
||||
|
||||
- **Instrument chosen in two places**: the `.minst` dropdown in each grid row (per-voice,
|
||||
`matInst`) AND the per-colour `inst` field inside the modal — no shared widget.
|
||||
- **`colordef-modal` is a full-screen overlay** (`position:fixed; inset:0`) → it hides the
|
||||
grid while editing.
|
||||
- **Six-colour palette triplicated**: CSS `.mcell.m1..m6`, CSS `.cd-row/.seq-row`, and JS
|
||||
`COLORS` array — must diverge eventually.
|
||||
- **Monolithic OSC inbound handler** (~250 lines, l.34-286) mutates every state global and
|
||||
calls every renderer — the central coupling hub.
|
||||
- **Two voice-addressing schemes**: grid/colordef use index `vi`, mixer uses voice-name.
|
||||
- **Engine params**: SynthDefs accept `cutoff, res, pan, rev` (reverb send); the matrix only
|
||||
pushes `cutoff`/`pan` statically (gated by `~matModTargets`), `rev` only via modulation,
|
||||
`res` not at all.
|
||||
|
||||
## Design
|
||||
|
||||
### A. Voice editor — inline accordion with tabs
|
||||
|
||||
Each grid voice row becomes:
|
||||
|
||||
```
|
||||
[label] [instrument ▾] [▷] ■·■·■·■·… (64 colour cells)
|
||||
```
|
||||
|
||||
- **One instrument dropdown per voice** (removes the grid/modal duplication — the unification).
|
||||
- Clicking **▷** (or the label) **expands an editor row directly under the voice**, in the grid.
|
||||
**Only one voice expanded at a time** (opening another collapses the previous) so height stays
|
||||
bounded with 22 voices. The grid stays visible — no full-screen modal.
|
||||
- The `colordef-modal` overlay is **removed**.
|
||||
- The expanded editor is **tabbed**:
|
||||
- **STEPS** — the adaptive step sequencer (section B), for the currently selected colour.
|
||||
- **SYNTH/FX** — instrument params + effects (section C), for the currently selected colour.
|
||||
- **MOD / POSE** — capture-modulation (source→target→depth) and pose bindings
|
||||
(poseId→action), as today, grouped in one tab.
|
||||
- A **colour selector ①–⑥** sits above the tabs; it sets the active colour all tabs edit.
|
||||
- **AUDITION** button moves from the modal head into the editor row.
|
||||
- **Advanced (collapsed by default): per-colour instrument override.** The engine supports
|
||||
`spec.inst`; expose it as an optional "advanced" disclosure, default "= voice instrument".
|
||||
|
||||
### B. Step sequencer — adaptive Pitch / Drum
|
||||
|
||||
The step model is unchanged: `steps[s] = {degree, vel}` or `null`, 16 steps, sent via
|
||||
`/matrix/step vi colour step degree vel`. This is purely a better UI over the same data.
|
||||
|
||||
- **Pitch mode (piano-roll)** for melodic voices: vertical axis = scale degrees (default ~2
|
||||
octaves, vertically scrollable for very low `sub` / high `bells`), horizontal = 16 steps.
|
||||
Click places/moves a note at (step, degree); velocity via wheel or a per-step level. Shows
|
||||
the actual melody contour.
|
||||
- **Drum mode (beat box)** for percussion: 16 on/off pads + accent/velocity (no pitch;
|
||||
`degree` written as 0).
|
||||
- **Per-voice toggle Pitch/Drum** with a smart default by voice class (section below).
|
||||
`perc`/`tom` default to Drum but can switch to Pitch (the engine treats them as pitched).
|
||||
|
||||
### C. SYNTH/FX tab (+ small engine extension)
|
||||
|
||||
Per **(voice, colour)** the tab edits: **cutoff, resonance (res), reverb send (rev), pan,
|
||||
amp, octave, stretch**. `cutoff/res/rev/pan` are shown only for voices whose SynthDef accepts
|
||||
them (gated, see classification).
|
||||
|
||||
Engine extension (`matrix.scd`) — minimal, the SynthDefs already accept `res`/`rev`:
|
||||
|
||||
1. `~matDefaultColorDefs`: add `res: nil, rev: nil` to each colour def.
|
||||
2. `~matVariationOverlay` and `~matColorStepPattern`: push `\res` and `\rev` to the Pbind when
|
||||
non-nil and the voice supports them (same pattern as the existing `\cutoff`/`\pan` push).
|
||||
3. `~matLoadFile`: add `\res, \rev` to the per-colour field copy list (currently
|
||||
`[\stretch,\octave,\amp,\inst,\cutoff,\pan]`).
|
||||
4. OSC: `/matrix/colordefs` push and `/matrix/colordef vi colour field value` set already carry
|
||||
arbitrary fields — extend the bulk-push arg layout to include res/rev; `setCD` works as-is.
|
||||
|
||||
No new FX buses (delay/distortion were considered and deferred — see Non-goals).
|
||||
|
||||
### D. Code architecture — ES modules
|
||||
|
||||
`index.html` switches `control.js` to `<script type="module">`. Split into:
|
||||
|
||||
- **`osc.js`** — `ws`, `send`, and a dispatch registry `on(address, fn)` replacing the 250-line
|
||||
switch. Each concern registers its own handlers.
|
||||
- **`matrix-state.js`** — `MATRIX_VOICES`, `MATRIX_BARS`, `INST_CHOICES`, mod sources/targets,
|
||||
the **single colour palette**, voice classification, `matGrid/matInst/matColorDefs`, `mkColor`,
|
||||
`saveMatState/loadMatState`. Pure data + persistence, no DOM.
|
||||
- **`matrix-grid.js`** — grid render (accordion rows), `applyMatCellColor`, `cellRefs`, glow,
|
||||
timeline.
|
||||
- **`voice-editor.js`** — the tabbed accordion editor (colour selector, STEPS, SYNTH/FX,
|
||||
MOD/POSE) + its own OSC handlers (`/matrix/colordefs|steps|colormod|colorpose`).
|
||||
- **`mixer.js`** — fader/mute strips.
|
||||
- **`sequencer.js`**, **`launchpad.js`**, **`scenes.js`**, **`transport.js`** — orthogonal concerns.
|
||||
- **`main.js`** — bootstrap, tab switching, top-level wiring.
|
||||
|
||||
### E. Single palette + OSC registry
|
||||
|
||||
- **One colour palette** in `matrix-state.js` (`['#c22','#1a8','#25b','#990','#c60','#82a']`),
|
||||
exposed to CSS as custom properties `--m1..--m6`; CSS `.mcell`/cell classes consume the vars.
|
||||
Removes the CSS/CSS/JS triplication.
|
||||
- **OSC dispatch registry** in `osc.js`: `on('/matrix/grid', fn)` etc. Eliminates the monolithic
|
||||
handler and the modal-DOM coupling (`!document.getElementById('colordef-modal').hidden` checks).
|
||||
|
||||
## Voice classification (melodic vs drum)
|
||||
|
||||
A single map in `matrix-state.js` drives both the step-mode default and which SYNTH/FX controls
|
||||
show:
|
||||
|
||||
- **Melodic (Pitch default; cutoff+res+rev+pan)**: `sub, acid, arp, lead, stab, pad, reese,
|
||||
bells, melody, chord`.
|
||||
- **Percussion (Drum default; pan+rev only, no cutoff/res)**: `kick, hats, clap, snare, shaker,
|
||||
ride, rim, crash, fx, sweep`.
|
||||
- **Semi-tonal (Drum default, Pitch allowed)**: `perc, tom`.
|
||||
|
||||
(Aligned with the engine `pitched` set; `res` shown only where the SynthDef has a filter.)
|
||||
|
||||
## Compatibility & persistence
|
||||
|
||||
- **OSC protocol**: same addresses; only the `colordef` field set gains `res`/`rev` values
|
||||
(backward-compatible — older presets simply omit them, defaulting nil).
|
||||
- **Persistence** `localStorage avlive.matrix` keeps shape `{grid, inst, cdef}`; colour defs
|
||||
gain optional `res`/`rev` (back-filled to nil on load, like the existing steps/pose/mod).
|
||||
- **`.matrix` preset files**: unchanged; `res`/`rev` are optional new colour-def fields.
|
||||
- The v3 presets just shipped (`8ebd028`) keep working unchanged.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new FX buses (delay/distortion sends) — would require SC bus architecture; deferred.
|
||||
- No change to the matrix grid arrangement model, the 22×64 dimensions, or the SC scene/boot.
|
||||
- No framework/build-step introduction — stay vanilla ES modules, no bundler.
|
||||
- No mixer redesign (fader/mute strips stay as-is).
|
||||
|
||||
## Testing & verification
|
||||
|
||||
- Keep `web_realart/test/matrix_glow.test.mjs` green; add light unit tests for `matrix-state`
|
||||
(palette source-of-truth, save/load round-trip, voice classification).
|
||||
- Re-run `sound_algo/data_only/test/test_matrix.scd` after the engine extension (res/rev).
|
||||
- Re-run the headless preset validator (`validate_presets.scd`) — 28/28 must still load+stream.
|
||||
- Browser smoke test (Playwright/Chrome) on `supra-m1.local:4400/control/`: expand a voice,
|
||||
switch tabs, edit a step in Pitch and Drum mode, set cutoff/res/reverb, AUDITION, confirm OSC
|
||||
egress and no regressions in grid/mixer/transport.
|
||||
- Fix the stale "16×32" comments → "22×64".
|
||||
|
||||
## Risks
|
||||
|
||||
- Large front rewrite → UI regression risk. Mitigated by unchanged OSC protocol + persistence,
|
||||
and the browser smoke test.
|
||||
- Engine `res`/`rev` push must be gated to voices whose SynthDef accepts them (drums lack a
|
||||
filter `res`) — wrong gating would send dead params (harmless) or miss controls.
|
||||
- The user iterates in parallel on these files — coordinate before large edits.
|
||||
|
||||
## Module / file plan (deliverables)
|
||||
|
||||
```
|
||||
web_realart/public/control/
|
||||
index.html (script type=module; remove colordef-modal; accordion markup)
|
||||
control.css (palette via --m1..--m6; accordion + tab styles; drop .cd-modal)
|
||||
js/
|
||||
osc.js
|
||||
matrix-state.js
|
||||
matrix-grid.js
|
||||
voice-editor.js
|
||||
mixer.js
|
||||
sequencer.js
|
||||
launchpad.js
|
||||
scenes.js
|
||||
transport.js
|
||||
main.js
|
||||
sound_algo/data_only/matrix.scd (res/rev colour-def fields + push + load + OSC)
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Matrix generative per-cycle evolution (refactor SP-B)
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: SuperCollider matrix engine (`sound_algo/data_only/matrix.scd`) + small OSC/web. Sub-project B of the matrix refactor (cycle 2).
|
||||
|
||||
## Context
|
||||
|
||||
The matrix is a 22-voice × 64-bar grid; each cell holds a colour 0-6 (0 = off,
|
||||
1-6 = a per-voice variation). The play routine (`~matPlay`, matrix.scd ~362-375)
|
||||
advances the playhead bar-by-bar; `~matApplyBar.(bar)` reads `~lp[\matrix][vi]
|
||||
[bar]` and re-sources each voice to that colour's variation. The loop region
|
||||
defaults to the full 64 bars (`~lp[\matLoopStart]=0`, `~lp[\matLoopEnd]=63`).
|
||||
|
||||
So the arrangement already evolves across 64 bars — but the generated grids use
|
||||
long homogeneous sections (a voice holds one colour for 4-24 bars), and the
|
||||
64-bar cycle then **repeats identically forever**, which reads as hypnotic/
|
||||
static. There is no auto-variation. This sub-project adds **bounded generative
|
||||
mutation**: at each loop wrap, the engine recolours a few active cells so no two
|
||||
cycles are identical, while staying close to the preset's intent.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Recolour active cells only.** The on/off density (rhythm) is preserved; only
|
||||
the COLOUR (variation) of already-active cells changes (1-6 → a different
|
||||
1-6). The 6 colours of a voice are all musically coherent variations, so a
|
||||
recolour is always in-place.
|
||||
- **Bounded drift.** Each cycle re-derives the working grid from an immutable
|
||||
base snapshot (the preset arrangement) — mutations do NOT accumulate, so it
|
||||
never drifts into mush; every cycle is "base + fresh light noise".
|
||||
- **Default OFF**, performer-enabled. Rate default 0.15 (≈15% of active cells
|
||||
recolour per cycle), env-tunable. Mutation runs once per 64-bar cycle (at the
|
||||
loop wrap).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data model
|
||||
- `~matBaseGrid` — immutable snapshot of the clean preset grid (a 2-level copy:
|
||||
`~lp[\matrix].collect({ |row| row.copy })`). Taken when evolution is ENABLED.
|
||||
- `~lp[\matrix]` — the working/playing grid (unchanged role: `~matApplyBar`
|
||||
reads it, the web shows it). Evolution OFF ⇒ identical to base.
|
||||
- `~matEvolve` (Boolean, default `false`), `~matEvolveRate` (0..1, default 0.15,
|
||||
`~matEvolveRate ? 0.15`, env-tunable).
|
||||
|
||||
### Mutation step (`~matEvolveStep`)
|
||||
Derives the working grid from the base, once per cycle:
|
||||
```
|
||||
for each voice vi, each bar b:
|
||||
base = ~matBaseGrid[vi][b]
|
||||
if base != 0 and rand(0..1) < ~matEvolveRate:
|
||||
~lp[\matrix][vi][b] = a random colour in 1..6 that is != base # recolour
|
||||
else:
|
||||
~lp[\matrix][vi][b] = base # keep
|
||||
then ~matGridPush.() # web reflects the evolution
|
||||
invalidate ~matLastColor (set all -1) # force re-source on the next bar
|
||||
```
|
||||
Bounded because every cell is re-derived from `~matBaseGrid` (no accumulation).
|
||||
0-cells always stay 0 (density preserved). A mutated cell is guaranteed to
|
||||
differ from its base colour (so the rate is the true change fraction).
|
||||
|
||||
### Trigger point
|
||||
In the `~matPlay` routine, when the playhead WRAPS back to `~lp[\matLoopStart]`
|
||||
after completing a cycle (detect via the previous bar being `~lp[\matLoopEnd]`,
|
||||
or `newBar < prevBar`), and `~matEvolve` is true, call `~matEvolveStep` before
|
||||
applying that bar. Runs once per full loop, not per bar.
|
||||
|
||||
### Enable / disable (OSC)
|
||||
- `/matrix/evolve [0/1]`: ON → `~matEvolve = true; ~matBaseGrid = <2-level copy
|
||||
of ~lp[\matrix]>`. OFF → `~matEvolve = false; ~lp[\matrix] = <2-level copy of
|
||||
~matBaseGrid>` (restore the clean grid), `~matGridPush.()`, re-source.
|
||||
- `/matrix/evolverate [0..1]`: `~matEvolveRate = value.clip(0,1)`.
|
||||
- Outbound echo `/matrix/evolve [stateInt, rate]` so the web reflects state.
|
||||
|
||||
### Live edits while evolving
|
||||
- `~matSetCell.(vi, bar, color)`: writes `~lp[\matrix][vi][bar]` as today AND,
|
||||
when `~matEvolve` is true, also writes `~matBaseGrid[vi][bar]` — so a live
|
||||
edit persists into the base and is not overwritten on the next cycle.
|
||||
- `~matSave`: serializes the CLEAN arrangement — `(~matEvolve.if({ ~matBaseGrid
|
||||
}, { ~lp[\matrix] }))` — never the mutated working grid.
|
||||
- `~matLoad`/`~matLoadFile`: unchanged (sets `~lp[\matrix]`). If evolution is on
|
||||
at load time, re-snapshot `~matBaseGrid` from the freshly loaded grid.
|
||||
|
||||
### Web (minor)
|
||||
- A toggle "ÉVOLUE" + a rate slider in the matrix transport area, sending
|
||||
`/matrix/evolve` / `/matrix/evolverate` and reflecting the echoed state. The
|
||||
grid display already refreshes from `~matGridPush` each cycle.
|
||||
|
||||
## Testing
|
||||
|
||||
- **SC** (`test/test_matrix.scd`, existing harness; no server; `pass`/`try`;
|
||||
`TEST PASS`):
|
||||
- density preserved: build a `~matBaseGrid` with known 0 and non-0 cells, run
|
||||
`~matEvolveStep` with rate 1.0, assert every 0-cell stays 0 and every active
|
||||
cell stays active (1..6).
|
||||
- recolour differs: with rate 1.0, every active cell's new colour ≠ its base
|
||||
colour and ∈ 1..6.
|
||||
- bounded/no-accumulation: enabling snapshots the base; after two
|
||||
`~matEvolveStep` calls, disabling restores `~lp[\matrix]` to `~matBaseGrid`
|
||||
exactly (the base is untouched by mutation).
|
||||
- rate 0.0: `~matEvolveStep` leaves the working grid identical to the base.
|
||||
- live edit during evolve: `~matSetCell` with `~matEvolve` true updates
|
||||
`~matBaseGrid` too (so the edit survives a subsequent `~matEvolveStep`).
|
||||
- save picks base: with evolve on and a mutated working grid, the `~matSave`
|
||||
payload grid equals `~matBaseGrid`, not the mutated `~lp[\matrix]`.
|
||||
- **Web** (`node --test test/*.test.mjs`): `/matrix/evolve` / `/matrix/evolverate`
|
||||
send payload shapes; the echoed-state handler updates the toggle/slider.
|
||||
|
||||
## Out of scope (other sub-projects)
|
||||
|
||||
- Denser deterministic grids / richer patterns (SP-C) and new presets (SP-D).
|
||||
- Density mutation (0↔colour) — explicitly excluded; recolour-only.
|
||||
- Accumulating random-walk drift — explicitly excluded; bounded only.
|
||||
- Per-section or tempo-synced evolution curves — v1 is uniform per-cycle.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- `~matEvolveRate` = 0.15, env-tunable. Evolution default OFF (performer enables).
|
||||
- Recolour active cells only; bounded from an immutable base snapshot.
|
||||
- Mutate once per 64-bar loop wrap; mutated colours differ from base and ∈ 1..6.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Matrix hand-authored pattern families (refactor SP-C2)
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: pattern bank only (`sound_algo/data_only/matrix_presets/patterns.py`) + `PRESET_FAMILY` wiring + regeneration/validation. NO engine/web changes. Sub-project C2 of the matrix refactor (cycle 2). The timing-swing engine feature is a separate follow-up (SP-E).
|
||||
|
||||
## Context
|
||||
|
||||
`patterns.py` is thin on authored variety: `MEL` has only 3 families (`default`
|
||||
for all mel voices, `dub` on `sub`, `psy` on `acid`) — 8/10 mel voices are
|
||||
`default`-only. `DRUMS` has 12 families but skewed: `crash, fx, ride, rim,
|
||||
shaker, sweep` have NO custom family (fallback only), and `techno/breaks/dnb`
|
||||
cover only a few voices. SP-C1 gave every voice's `default` family a contrasted
|
||||
6-colour spectrum via `derive_drum_colors`/`derive_mel_colors`; this sub-project
|
||||
adds **hand-authored characterful style families** so presets of different
|
||||
genres feel distinct, plus velocity humanization.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Hand-author all 6 colours per (voice, family).** Each colour is written by
|
||||
hand (NOT derived from a base) — maximum musical character. The 6 colours
|
||||
still follow the colour-role convention for contrast (1 base · 2 busier · 3
|
||||
broken/syncopated · 4 double-time · 5 sparse · 6 fill), but each is an explicit
|
||||
authored pattern, not a transformation. No `fam()`/derive shortcut here (that
|
||||
was SP-C1's job for the `default` family).
|
||||
- **Existing families untouched.** `default` (SP-C1-derived) and the existing
|
||||
authored families (`dub` on sub, `psy` on acid, the drum style families) keep
|
||||
their current content; SP-C2 only ADDS new (voice, family) entries.
|
||||
- **Velocity humanization is baked into the authored colours** (varied accents
|
||||
`X` / ghosts `o` / soft `-`, not robotic all-`x`).
|
||||
- **Batch workflow.** Author the whole set, then the user reviews the final
|
||||
sound on the live rig and we iterate on what sounds wrong. Timing swing is
|
||||
out of scope (SP-E, engine).
|
||||
|
||||
## Architecture (pure content)
|
||||
|
||||
- Each new family is **6 explicit hand-authored colours** added to the existing
|
||||
dicts, exactly like the current authored families:
|
||||
```python
|
||||
DRUMS["ride"]["techno"] = ["x.x.x.x.x.x.x.x.", # 1 base: rolling 8ths
|
||||
"xXx.xXx.xXx.xXx.", # 2 busier: accented
|
||||
"x..xx..xx..xx..x", # 3 broken: syncopated
|
||||
"xxxxxxxxxxxxxxxx", # 4 double: 16ths
|
||||
"x...x...x...x...", # 5 sparse: quarters
|
||||
"x.x.x.x.x.x.xXXX"] # 6 fill: accent roll tail
|
||||
MEL["lead"]["dub"] = [_mel([...]), _mel([...]), _mel([...]),
|
||||
_mel([...]), _mel([...]), _mel([...])] # 6 motifs
|
||||
```
|
||||
Colours follow the role convention (base/busier/broken/double/sparse/fill) so
|
||||
they stay contrasted, but every cell is hand-written for character. DRUMS
|
||||
store 16-char strings (parsed by `_rhythm` in `patterns_for`); MEL store
|
||||
`_mel([...])` motif lists.
|
||||
- New entries are added to the existing `DRUMS`/`MEL` dicts; `patterns_for`
|
||||
already selects `dfam`/`mfam` then falls back to `default` — no change needed
|
||||
there (a voice that now HAS the requested style family stops falling back).
|
||||
- `PRESET_FAMILY` is extended/retargeted so presets actually reference the new
|
||||
families (e.g. `dub_techno -> ("techno", "dub")`, a psy preset -> mel `psy`,
|
||||
a house preset -> mel `house`, ambient_intro -> mel `ambient`).
|
||||
|
||||
## Content to author (the characteristic bases)
|
||||
|
||||
### New MEL families (base motif per voice where the style is musical)
|
||||
| Family | Mel voices it covers | Feel |
|
||||
|--------|----------------------|------|
|
||||
| `dub` | sub*(exists), reese, pad, chord, bells, melody | deep, sparse, spacey, off-beat stabs |
|
||||
| `psy` | acid*(exists), sub, reese, arp, lead, stab | rolling 16th basslines, fast arps |
|
||||
| `house` | pad, chord, stab, bells, lead, melody | chord stabs, syncopated, vocal-ish |
|
||||
| `ambient` | pad, bells, melody, sub, reese | long, sparse, evolving, few notes |
|
||||
(* already authored — leave as-is, only add the missing voices.)
|
||||
|
||||
### Percussion voices (give the 6 fallback-only drum voices authored bases + fills)
|
||||
`crash, ride, rim, shaker, fx, sweep` — author a characteristic base per
|
||||
relevant family (at minimum `default` stays SP-C1-derived; add `techno`/`breaks`/
|
||||
`dnb` bases where the voice plays a role: e.g. `ride` techno = rolling 8ths,
|
||||
`shaker` = 16th groove, `crash` = downbeat hits + a fill, `rim` = syncopated
|
||||
clicks).
|
||||
|
||||
### Complete skewed drum styles
|
||||
For `techno`, `breaks`, `dnb`: add base patterns for the voices these families
|
||||
currently omit (so a `breaks` preset has a coherent breaks feel across kick,
|
||||
snare, hats, perc, ride, shaker — not just 3 voices).
|
||||
|
||||
### Velocity humanization
|
||||
Authored bases use varied dynamics (`X` accents on strong beats, `o` ghosts,
|
||||
`-` soft) so the groove breathes; the existing `_VEL` map (`. o - x X` =
|
||||
0/.30/.50/.80/1.0) is the vocabulary. No all-`x` rows.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python** (standalone, `uv run python`, new `test_families.py`):
|
||||
- Validity: EVERY family in `DRUMS`/`MEL` (existing + new) is a list of exactly
|
||||
6 patterns; each drum string is 16 chars over `. o - x X`; each mel motif is
|
||||
16 steps of `None`/`(int, float)` (i.e. `_rhythm`/`_mel` accept them).
|
||||
- Contrast: each new family's 6 colours are not all identical (≥4 distinct),
|
||||
colour 5 (sparse) has ≤ hits of colour 1, colour 6 (fill) has its last steps
|
||||
active — a light sanity check that the hand-authored convention holds.
|
||||
- Untouched: existing families (`MEL["sub"]["dub"]`, `MEL["acid"]["psy"]`, the
|
||||
drum style families) are byte-identical to before.
|
||||
- Wiring: every `(dfam, mfam)` named in `PRESET_FAMILY` exists for the relevant
|
||||
voices (or falls back to `default` cleanly — no missing-key crash).
|
||||
- **SC validation** (`validate_presets.scd`): regenerate the 28 presets, all 28
|
||||
load (`28/28 PASS`).
|
||||
- **Musical review (user, batch):** reload the genre presets on the live rig and
|
||||
listen; iterate on bases that sound wrong.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Timing swing / micro-timing (SP-E, engine).
|
||||
- Engine/web changes; new presets (SP-D).
|
||||
- Changing `default` families or the SP-C1 derivation; changing existing
|
||||
authored families.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- Full hand-authored 6-colour sets per new (voice, family) — NOT derived.
|
||||
- Styles covered: dub, psy, house, ambient (mel) + complete techno/breaks/dnb
|
||||
(drums) + percu voice families. Velocity-humanized authored colours.
|
||||
- Existing families and `default` untouched; only additions + `PRESET_FAMILY`
|
||||
retargeting.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Matrix pitch + res modulation targets (refactor cycle 1)
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: SuperCollider matrix engine (`sound_algo/data_only/matrix.scd`) + web
|
||||
display layer (`web_realart/public/control/js/matrix-state.js`). Cycle 1 of a
|
||||
two-cycle matrix refactor.
|
||||
|
||||
## Context
|
||||
|
||||
The per-voice additive modulation system (shipped 2026-06-29, commits
|
||||
`6964831..8daad40`) lets each matrix voice bind modulation sources (19 pose-
|
||||
derived scalars, 0..1) to targets via `~matVoiceMods[voice]` — a free list of
|
||||
`(source, target, depth)` applied in `~matModPairs`. Current targets:
|
||||
`amp`, `cutoff`, `pan`, `rev`.
|
||||
|
||||
The user wants logical pose→instrument mappings such as "right-hand y → pitch,
|
||||
left-hand x → resonance". Two of those targets don't exist yet. This cycle adds
|
||||
them. The richer default-mapping work + preset variety is **cycle 2** (separate
|
||||
spec); this cycle is the engine prerequisite only.
|
||||
|
||||
### How frequency is computed today (the constraint that shapes pitch)
|
||||
|
||||
Melodic voices (`sub, acid, arp, lead, stab, pad, reese, bells, perc, tom`)
|
||||
emit a custom `\freq` Pfunc (`matrix.scd:~225`):
|
||||
|
||||
```supercollider
|
||||
[\freq, Pfunc { |e| (~lpNote.notNil).if({ ~lpNote.(e[\degree], 0).midicps * freqRatio }, { 440 }) }]
|
||||
```
|
||||
|
||||
`~lpNote(degree, octave)` maps a scale degree to a note (stays in the current
|
||||
scale); `freqRatio = 2.pow(octave)`. There is no `\degree→\note→\freq` standard
|
||||
chain and no `\ctranspose`/`\detune` key, so the SynthDefs receive `freq`
|
||||
directly. Consequence: **pitch modulation cannot be an independent `[\key,
|
||||
Pfunc]` pair** — it must fold into this freq computation. `res` has no such
|
||||
constraint: it is already an independent `\res` Pbind key, gated by
|
||||
`~matResVoices`, and the SynthDefs accept `res` (0..1).
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Two cycles.** This is cycle 1 (engine targets only). Cycle 2 = logical
|
||||
default mappings + preset variety.
|
||||
- **Pitch semantics: scale-quantized, bipolar transpose.** A pitch binding
|
||||
shifts the played note by an integer number of **scale degrees** (stays in
|
||||
key), **centered** on the note: source `0.5` = no shift, `<0.5` = down,
|
||||
`>0.5` = up. Per-note (computed at onset) → no glissando.
|
||||
- **Res semantics: like cutoff/rev**, an independent `\res` Pbind key.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. `res` target (independent Pbind key, like cutoff)
|
||||
|
||||
- `~matModPairs` gains a `\res` branch: additive `Σ (src · depth)`, clipped
|
||||
`0..1`, emitted as `[\res, Pfunc {...}]`. (Res is naturally 0..1, so a direct
|
||||
scaled sum is the right analogue — simpler than cutoff's log curve; no neutral
|
||||
table needed.)
|
||||
- A `\res` mod binding overrides the colorDef's static `res` for that voice
|
||||
while present (same precedence as cutoff: the mod pair is spliced after the
|
||||
colorDef `res` pair, so it wins).
|
||||
- `~matModTargets[voice]` gains `\res` **only for `~matResVoices`** (`sub,
|
||||
acid, arp, lead, stab, pad, reese, bells, melody, chord`).
|
||||
|
||||
### 2. `pitch` target (scale-degree offset folded into the freq Pfunc)
|
||||
|
||||
- New helper:
|
||||
```supercollider
|
||||
~matPitchOffset = { |name|
|
||||
var off = 0;
|
||||
((~matVoiceMods[name] ? []).select { |b| b[\target] == \pitch }).do { |b|
|
||||
off = off + ((~matModSourceVal.(b[\source]) * 2 - 1) * b[\depth] * ~matPitchRange).round.asInteger
|
||||
};
|
||||
off
|
||||
};
|
||||
~matPitchRange = ~matPitchRange ? 7; // ± scale degrees at full deflection (env-tunable; 7 ≈ 1 octave)
|
||||
```
|
||||
Bipolar (source 0.5 → 0), additive across pitch bindings, rounded to an
|
||||
integer scale-degree offset.
|
||||
- The melodic freq Pfunc (`matrix.scd:~225`) changes to add the offset to the
|
||||
degree **at note onset**:
|
||||
```supercollider
|
||||
[\freq, Pfunc { |e| (~lpNote.notNil).if(
|
||||
{ ~lpNote.(e[\degree] + ~matPitchOffset.(name), 0).midicps * freqRatio }, { 440 }) }]
|
||||
```
|
||||
Because `~lpNote` maps degree→scale note, an integer offset stays in key.
|
||||
- `pitch` is **NOT** produced by `~matModPairs` (freq is already handled);
|
||||
`~matModPairs` simply never emits it (it is filtered as a non-`amp/cutoff/
|
||||
pan/rev/res` target). The freq Pfunc is the sole consumer, via the helper.
|
||||
- **Apply the offset to every freq Pfunc for pitch-capable voices.** A melodic
|
||||
voice may render via the step-pattern path (`~matColorStepPattern`) when it
|
||||
has steps, or via the overlay path (`~matVariationOverlay`) when it does not.
|
||||
The implementation must add `+ ~matPitchOffset.(name)` to the degree in
|
||||
**each** place a melodic `\freq` Pfunc is built (grep `\freq, Pfunc` in
|
||||
matrix.scd), so pitch modulation works regardless of which path a voice
|
||||
takes. If only the step-pattern path computes freq from degree and the
|
||||
overlay path inherits the base pattern's freq, document that and patch the
|
||||
one true site.
|
||||
- `~matModTargets[voice]` gains `\pitch` **only for the freq-Pfunc voices**
|
||||
(`sub, acid, arp, lead, stab, pad, reese, bells, perc, tom`).
|
||||
|
||||
### 3. Target gating (already enforced — no new gate)
|
||||
|
||||
`~matSetVoiceMod` already drops bindings whose target ∉ `~matModTargets[voice]`.
|
||||
Extending `~matModTargets` is therefore sufficient: `pitch` bindings on a
|
||||
non-melodic voice (e.g. `kick`) and `res` bindings on a non-res voice
|
||||
(e.g. `hats`) are rejected at set time, by both the OSC route and the web UI.
|
||||
|
||||
### 4. Web display layer
|
||||
|
||||
- `matrix-state.js`: `MOD_TARGET_LABELS` gains `pitch: "pitch"`, `res:
|
||||
"résonance"`. `MATRIX_MOD_TARGETS` (the per-voice target lists, mirrors SC)
|
||||
gains `pitch` on the 10 melodic voices and `res` on the 10 res-voices.
|
||||
- No other UI change: the target dropdown is already filtered by
|
||||
`MATRIX_MOD_TARGETS[voice]` and labelled via `MOD_TARGET_LABELS`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **SC** (`test/test_matrix.scd`, existing harness — no server, `pass`/`try`,
|
||||
`TEST PASS`):
|
||||
- `res` additive: two res bindings sum and clip 0..1; `~matModPairs` emits a
|
||||
`\res` pair for a res-voice with the summed value.
|
||||
- `~matPitchOffset`: bipolar (source 0.5 → 0), additive, rounded; e.g.
|
||||
source 1.0, depth 1.0, range 7 → +7; source 0.0 → −7; two bindings sum.
|
||||
- freq integration: with a pitch binding active, `~matVariation` of a melodic
|
||||
voice produces a `\freq` that equals `~lpNote(degree + offset, 0).midicps *
|
||||
freqRatio` (assert the transposed freq differs from the un-modulated one in
|
||||
the expected direction).
|
||||
- gating: `~matSetVoiceMod` drops a `pitch` binding on `\kick` and a `\res`
|
||||
binding on `\hats` (target not in `~matModTargets`).
|
||||
- **Web** (`test/matrix-state.test.mjs`, `node --test test/*.test.mjs`):
|
||||
- `MATRIX_MOD_TARGETS` includes `pitch` for melodic voices and `res` for
|
||||
res-voices, and does NOT include them for ineligible voices (e.g. kick has
|
||||
no pitch, hats has no res).
|
||||
- `MOD_TARGET_LABELS` has entries for `pitch` and `res`.
|
||||
|
||||
## Out of scope (→ cycle 2)
|
||||
|
||||
- Default per-instrument pose→target mappings baked into presets.
|
||||
- Preset variety / richer arrangements.
|
||||
- Any new modulation SOURCE (the 19 are unchanged).
|
||||
- New SynthDef args (pitch uses the existing degree→freq path; res uses the
|
||||
existing `res` arg).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- `~matPitchRange` = 7 (± scale degrees at full deflection), env-tunable.
|
||||
- Pitch is bipolar (neutral at source 0.5); res is direct (source 0 → res 0).
|
||||
- Pitch-capable voices = the 10 freq-Pfunc voices; res-capable = the 10
|
||||
`~matResVoices`.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Matrix presets v4 — intro/live/outro triplets + hand-authored families
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design pending approval
|
||||
**Scope**: preset generator (`sound_algo/data_only/matrix_presets/generate_presets.py` + `patterns.py`) + regeneration/validation. NO engine/web changes. Supersedes the SP-C2 families-only spec/plan (folds it in). Sub-projects C2+D merged.
|
||||
|
||||
## Context
|
||||
|
||||
The 28 matrix presets are each a single 64-bar grid. The user wants each
|
||||
*morceau* decomposed into **three sections — intro / live / outro — as three
|
||||
separate `.matrix` files**, hand-arranged per morceau (28 → 84 presets). And the
|
||||
pattern bank is thin (mel: only `dub` on sub, `psy` on acid; 6 drum voices
|
||||
fallback-only) — so this effort ALSO hand-authors new pattern families. Both are
|
||||
pure content/generation; the engine (SP-C1 derivation, per-voice mods, evolve)
|
||||
is untouched.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Three separate presets per morceau.** Each of the 28 morceaux emits
|
||||
`<name>_intro.matrix`, `<name>_live.matrix`, `<name>_outro.matrix` (= 84). No
|
||||
engine feature, no in-grid section zones — three independent grids loaded
|
||||
separately (`/matrix/load <name>_live` etc.).
|
||||
- **Hand-arranged per morceau.** Each section's arrangement (which voices play,
|
||||
density, which colours) is authored per morceau — not auto-derived from live.
|
||||
- **Hand-authored pattern families** (the merged SP-C2): full 6-colour sets for
|
||||
`dub/psy/house/ambient` (mel) + percu-voice and style-completion drum families
|
||||
— written by hand, NOT derived (SP-C1 derivation stays for the `default`
|
||||
family only).
|
||||
- **Full batch.** Author all 84 + all families in one batch; the user reviews by
|
||||
ear on the live rig and we iterate on what sounds wrong.
|
||||
|
||||
## Section semantics
|
||||
|
||||
| Section | Feel | Arrangement |
|
||||
|---------|------|-------------|
|
||||
| **intro** | build-up / entry | starts sparse (kick + 1-2 elements), voices ENTER progressively across the 64 bars, ends near-full → leads into live |
|
||||
| **live** | the main groove | full arrangement (≈ the current per-style arc), all the morceau's voices active, the richest section |
|
||||
| **outro** | breakdown / exit | starts ~full, voices DROP OUT progressively, thins to a sparse tail/near-empty end → a clean stop or loop-out |
|
||||
|
||||
The three are coherent (same instkit + key + families per morceau); they differ
|
||||
in voice activation + density over the 64 bars.
|
||||
|
||||
## Architecture (pure content)
|
||||
|
||||
### Generator restructure (`generate_presets.py`)
|
||||
- Replace the flat `PRESETS = { name: (arc, instkit, melodies, extra) }` with a
|
||||
per-morceau structure carrying three section arrangements:
|
||||
```python
|
||||
MORCEAUX = { "techno_drive": dict(
|
||||
instkit=K_909, melodies={...}, fam=("techno","default"),
|
||||
intro=arc_intro_fourfloor, live=arc_four_floor, outro=arc_outro_fourfloor,
|
||||
extra=None) , ... }
|
||||
```
|
||||
- `main()` loops morceaux × {intro, live, outro}, calling `to_sc(f"{name}_{sec}",
|
||||
section_arc, instkit, melodies, extra)` → writes 84 `.matrix` files.
|
||||
- **Each intro and outro is its own fully-independent hand-written arc
|
||||
function** — `arc_intro_<morceau>(g)` / `arc_outro_<morceau>(g)` — 56 distinct
|
||||
bodies (28 intro + 28 outro), no shared helper. Maximum per-morceau control.
|
||||
`live` = the morceau's existing arc (reused unchanged). So per morceau: 1
|
||||
existing live arc + 2 new hand-written arcs.
|
||||
- The intro/outro arcs follow the section semantics (intro builds, outro breaks
|
||||
down) but each is composed by hand for its morceau (its own voice timing,
|
||||
density curve, and section pivots over the 64 bars).
|
||||
|
||||
### Pattern families (`patterns.py`) — merged SP-C2
|
||||
- Hand-authored 6-colour entries added to `DRUMS`/`MEL` (dub/psy/house/ambient
|
||||
mel families on the matrix voices; ride/shaker/crash/rim/fx/sweep drum
|
||||
families; complete techno/breaks/dnb for uncovered voices). Existing families
|
||||
+ every `default` untouched.
|
||||
- `PRESET_FAMILY` keyed per the 84 preset names (or resolved from the morceau +
|
||||
section), mapping each to `(drum_family, mel_family)`.
|
||||
|
||||
### Wiring / consumers
|
||||
- `launch_concert.sh` `MATRIX_PRESET` default → `techno_drive_live`.
|
||||
- The web `/matrix/list` and preset selectors now show 84 names (no code change;
|
||||
they list whatever SC reports). Naming `<morceau>_<section>` sorts the three
|
||||
together.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python** (`test_families.py` extended + a `test_sections.py`):
|
||||
- family validity/contrast (existing harness).
|
||||
- section coverage: each of the 28 morceaux yields intro/live/outro; each
|
||||
section grid is valid (22×64, colours 0-6).
|
||||
- section shape sanity: intro's first bars are sparser than its last (voices
|
||||
enter); outro's last bars are sparser than its first (voices exit); live is
|
||||
the densest of the three on average.
|
||||
- **SC validation** (`validate_presets.scd`): regenerate, all **84** load
|
||||
(`84/84 PASS`).
|
||||
- **Musical review (user, batch):** load the `_intro`/`_live`/`_outro` of several
|
||||
morceaux on the live rig and listen; iterate on weak sections/families.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Engine/web changes (no in-grid section zones, no named-loop navigation — those
|
||||
were the rejected options).
|
||||
- Timing swing (SP-E, still later).
|
||||
- Changing the SP-C1 `default` derivation, per-voice mods, or evolve.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- 84 presets = 28 morceaux × {intro, live, outro}; `live` = the existing arc,
|
||||
`intro`/`outro` = 56 fully-independent hand-written arc functions (no shared
|
||||
helper), one each per morceau.
|
||||
- Hand-authored 6-colour families (not derived); existing families/default kept.
|
||||
- Full batch; user ear-reviews and we iterate.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Per-voice modulation/pose + expanded iPhone sources + FR names
|
||||
|
||||
**Date**: 2026-06-29
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: matrix sequencer modulation/pose binding system, iPhone-derived
|
||||
value routing, web voice editor MOD/POSE tabs, FR display-label layer, live
|
||||
value visualization.
|
||||
|
||||
## Context
|
||||
|
||||
The matrix sequencer (`sound_algo/data_only/matrix.scd`, 22 voices × 64 bars,
|
||||
6 colours/voice) already supports:
|
||||
|
||||
- **Per-colour modulation** binding: one `(source, target, depth)` per
|
||||
colorDef, applied live via `~matModPairs` / `~matVariation`.
|
||||
- **Per-colour pose** bindings: list of `(poseId, action)` where action is
|
||||
`trigger` (re-source the colour and play) or `gate` (toggle the Pdef).
|
||||
- **11 modulation sources** in `~matModCache`, fed by only 3 OSC routes:
|
||||
`/pose/hands` → `lHandX/Y, lOpen, handSpeed, rHandX/Y, rOpen, handDist`;
|
||||
`/pose/center` → `bodyX, bodyY`; `/pose/sho_span` → `depth`.
|
||||
|
||||
`data_only_viz/pose_bridge.py` already emits many more iPhone-derived signals
|
||||
to SC (`127.0.0.1:57121`) that the matrix ignores: `/pose/kin [speed, accel,
|
||||
symmetry]`, `/pose/head [x, y, c]`, `/pose/limb_span [span]`, `/pose/action
|
||||
[idx, p0, p1, p2]` (debout/assise/danse), `/pose/finger [hand, finger,
|
||||
strike_speed, z, tipx, tipy]`, `/pose/pinch [hand, finger]`. `mouth_open` is
|
||||
computed (`action_head`) but not yet emitted.
|
||||
|
||||
The web voice editor (`web_realart/public/control/js/voice-editor.js`) has a
|
||||
MOD/POSE tab that mirrors the per-colour model: source × target × depth (one
|
||||
binding) + free-text `poseId` list.
|
||||
|
||||
### Problems addressed
|
||||
|
||||
1. **Most iPhone values are unusable as instrument modulation** — kinetics,
|
||||
action, head, limb span, mouth are produced but never reach the mod cache.
|
||||
2. **Association is per-colour and single-binding** — awkward to drive live
|
||||
(132 slots, one mod each), no additive layering.
|
||||
3. **Names are cryptic** (`lHandY`, `depth`) and poses are free-text, with no
|
||||
live feedback of incoming values to guide mapping.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Granularity**: modulation and pose bindings move from **per-colour** to
|
||||
**per-voice (instrument)**. A voice's bindings apply to all 6 of its colours.
|
||||
STEPS and SYNTH-FX tabs stay per-colour.
|
||||
- **Binding model**: **free additive list** per voice (add/remove rows). Two
|
||||
bindings may target the same param; contributions sum then clip.
|
||||
- **Discrete vs continuous**: continuous iPhone values become new modulation
|
||||
sources; discrete events (finger strike, pinch, action label) stay in the
|
||||
POSE trigger/gate system.
|
||||
- **Names**: **display-label layer** — technical IDs stay on the wire and in
|
||||
presets; the web UI shows FR labels. No rename in SC or presets.
|
||||
- **Poses**: **curated dropdown + custom** — standardized event names
|
||||
(finger strikes, pinch, danse/debout/assise) plus a free-text fallback.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. SC data model (`matrix.scd`)
|
||||
|
||||
Replace per-colour mod/pose with per-voice state:
|
||||
|
||||
```supercollider
|
||||
~matVoiceMods = (); // voice(symbol) -> Array of (source:, target:, depth:)
|
||||
~matVoicePoses = (); // voice(symbol) -> Array of (poseId:, action:)
|
||||
```
|
||||
|
||||
- `colorDef[\mod]` and `colorDef[\pose]` are **deprecated**. On preset/save
|
||||
load, legacy per-colour bindings are migrated: for each voice, collect the
|
||||
bindings present across its 6 colours and dedup into the voice-level list
|
||||
(mod: dedup by `target`, keeping the first non-nil; pose: dedup by
|
||||
`(poseId, action)`).
|
||||
- `~matModPairs` / `~matVariation` change to read `~matVoiceMods[voice]`
|
||||
instead of the active colorDef's `\mod`.
|
||||
|
||||
**Additive application** (per voice, per target param):
|
||||
- Group the voice's bindings by `target`.
|
||||
- Each binding contributes a delta in the target's modulation space:
|
||||
- `pan`: Σ `(src*2 - 1) * depth`, clipped to `-1..1`.
|
||||
- `rev`: Σ `src * depth`, clipped to `0..1`.
|
||||
- `amp`: product of `(1 + depth*(src*2-1)).max(0)` over bindings.
|
||||
- `cutoff`: log-space — `c0 * Π (linexp(src,0,1,200,6000)/c0).pow(depth)`,
|
||||
i.e. the existing single-source formula multiplied per binding.
|
||||
- Targets with no binding keep their colorDef value unchanged.
|
||||
- Only targets allowed by `~matModTargets[voice]` are honored; the web UI
|
||||
filters the param dropdown accordingly, and SC ignores out-of-range targets.
|
||||
|
||||
### 2. New continuous sources (SC OSCdefs → `~matModCache`)
|
||||
|
||||
All from routes `pose_bridge.py` already emits (zero data_only change):
|
||||
|
||||
| OSC route | New source(s) | 0..1 mapping |
|
||||
|-----------|---------------|--------------|
|
||||
| `/pose/kin [pid, speed, accel, sym]` | `bodyVitesse`, `bodyAccel`, `bodySym` | `speed/KIN_SPEED_MAX`, `accel/KIN_ACCEL_MAX` (clip 0..1); `sym → (sym+1)/2` |
|
||||
| `/pose/head [pid, x, y, c]` | `headX`, `headY` | `headX = x`; `headY = 1 - y` |
|
||||
| `/pose/limb_span [pid, span]` | `limbSpan` | `span * LIMB_SPAN_GAIN` (clip 0..1) |
|
||||
| `/pose/action [pid, idx, p0, p1, p2]` | `danse` | `danse = p2` (probability, continuous) |
|
||||
| `/pose/mouth [pid, open]` *(new emit)* | `mouthOpen` | `open` (clip 0..1) |
|
||||
|
||||
Normalization constants (`KIN_SPEED_MAX`, `KIN_ACCEL_MAX`, `LIMB_SPAN_GAIN`)
|
||||
are defined as env-vars in `matrix.scd` with sensible defaults so they can be
|
||||
tuned without code edits. Stale handling: `~matModCache` entries decay/clamp;
|
||||
if a person leaves (`/pose/leave`), continuous body sources relax toward
|
||||
neutral (no hard reset mid-performance).
|
||||
|
||||
**`mouthOpen`** requires one new emit `/pose/mouth [pid, open]` in
|
||||
`pose_bridge.py`, exposing the already-computed `mouth_open` scalar. This is
|
||||
the only `data_only_viz` change — routing an existing value, not detection.
|
||||
|
||||
Total sources after this change: 11 existing + `bodyVitesse, bodyAccel,
|
||||
bodySym, headX, headY, limbSpan, danse, mouthOpen` = **19**.
|
||||
|
||||
### 3. Pose/event system (per-voice trigger/gate)
|
||||
|
||||
New SC OSCdefs map discrete events to canonical `poseId` symbols, then call
|
||||
`~matPoseFire` (which now iterates `~matVoicePoses` and fires matching voices):
|
||||
|
||||
| Route | Canonical poseId |
|
||||
|-------|------------------|
|
||||
| `/pose/finger [0, hand, finger, ...]` | `fingerL1..fingerL5`, `fingerR1..fingerR5` (finger 0..4 → 1..5) |
|
||||
| `/pose/pinch [0, hand, finger]` | `pinchL1..pinchL4`, `pinchR1..pinchR4` |
|
||||
| `/pose/action [pid, idx, ...]` | `debout` / `assise` / `danse` — fired on label change |
|
||||
|
||||
`~matPoseFire(poseId)` iterates all voices; for each voice whose
|
||||
`~matVoicePoses` contains a matching `poseId`, applies the bound action at the
|
||||
voice's current colour. The canonical event list is shared with the web UI.
|
||||
|
||||
### 4. Display-label layer (web)
|
||||
|
||||
New entries in `matrix-state.js`:
|
||||
|
||||
```js
|
||||
export const MOD_SOURCE_LABELS = {
|
||||
none: "—",
|
||||
lHandY: "Main G ↕", rHandY: "Main D ↕",
|
||||
lHandX: "Main G ↔", rHandX: "Main D ↔",
|
||||
lOpen: "Main G ouvert", rOpen: "Main D ouvert",
|
||||
handSpeed: "Vitesse mains", handDist: "Écart mains",
|
||||
bodyX: "Corps ↔", bodyY: "Corps ↕", depth: "Profondeur",
|
||||
bodyVitesse: "Vitesse corps", bodyAccel: "Accél. corps",
|
||||
bodySym: "Symétrie", headX: "Tête ↔", headY: "Tête ↕",
|
||||
limbSpan: "Envergure", danse: "Danse (proba)",
|
||||
mouthOpen: "Bouche",
|
||||
};
|
||||
export const POSE_EVENT_CHOICES = [
|
||||
{ id: "danse", label: "Danse" }, { id: "debout", label: "Debout" },
|
||||
{ id: "assise", label: "Assise" }, { id: "pinchR1", label: "Pincement MD index" },
|
||||
// … finger strikes / pinches enumerated, + a "custom…" entry
|
||||
];
|
||||
```
|
||||
|
||||
IDs are unchanged on the OSC wire and in presets. SC stores and matches
|
||||
symbols only.
|
||||
|
||||
### 5. Live value visualization
|
||||
|
||||
- SC pushes `/matrix/modvalues [v0, v1, … vN]` — the current `~matModCache`
|
||||
serialized in a **fixed source order** — at ~12 Hz via a `Routine` to the
|
||||
feedback port (→ web bridge → all WS clients).
|
||||
- The fixed source order is a single source-of-truth array in `matrix.scd`,
|
||||
mirrored in `matrix-state.js`.
|
||||
- Web: in the MOD tab, a small live meter next to each source value; bindings
|
||||
whose source is currently active are highlighted.
|
||||
|
||||
### 6. OSC routes (new / changed)
|
||||
|
||||
| Route | Dir | Payload | Purpose |
|
||||
|-------|-----|---------|---------|
|
||||
| `/matrix/voicemod` | in | `[vi, n, src1, tgt1, depth1, …]` | set full mod list for voice |
|
||||
| `/matrix/voicemod/get` | in | `[vi]` | request push of voice's mod list |
|
||||
| `/matrix/voicepose` | in | `[vi, n, pose1, act1, …]` | set full pose list for voice |
|
||||
| `/matrix/voicepose/get` | in | `[vi]` | request push of voice's pose list |
|
||||
| `/matrix/modvalues` | out | `[v0 … vN]` | live mod cache (~12 Hz) |
|
||||
|
||||
`/matrix/colormod` and `/matrix/colorpose` are removed as inbound editors;
|
||||
legacy values are read only during preset/save migration.
|
||||
|
||||
### 7. Web UI (`voice-editor.js`)
|
||||
|
||||
- **MOD tab** → per-voice free list: rows `[source ▾][param ▾][depth slider]
|
||||
[✕]` plus `[+ ajouter]`. FR labels via `MOD_SOURCE_LABELS`. Param dropdown
|
||||
filtered by `MATRIX_MOD_TARGETS[voice]`. Live meter per row.
|
||||
- **POSE tab** → per-voice list: rows `[event ▾ (curated + custom)][action ▾
|
||||
trigger|gate][✕]` plus `[+ ajouter]`.
|
||||
- Both tabs now read/write **voice-level** state, independent of the selected
|
||||
colour. STEPS and SYNTH-FX remain per-colour.
|
||||
|
||||
### 8. Persistence + migration
|
||||
|
||||
- `/matrix/save` and `/matrix/load` serialize `~matVoiceMods` and
|
||||
`~matVoicePoses` per voice.
|
||||
- Loading an old preset (per-colour `\mod`/`\pose`) triggers the migration in
|
||||
§1. Migration is idempotent and logged.
|
||||
- `matrix_presets/generate_presets.py` + `patterns.py`: emit per-voice mod/
|
||||
pose blocks (empty by default, with a few demonstrative mappings, e.g.
|
||||
`acid: bodyVitesse → cutoff`). Regenerate and re-validate the 28 presets.
|
||||
|
||||
### 9. Testing
|
||||
|
||||
- **SC** (`test/test_matrix.scd` extended): cache updates for each new OSCdef;
|
||||
additive same-target summation/clipping; pose mapping for finger/pinch/
|
||||
action; legacy per-colour → per-voice migration; `/matrix/modvalues` shape.
|
||||
- **Web** (`node --test`): `MOD_SOURCE_LABELS` coverage of all source IDs;
|
||||
per-voice state read/write; OSC payload shapes for `/matrix/voicemod` and
|
||||
`/matrix/voicepose`; `parseModValues` round-trip.
|
||||
- **Manual smoke (macm1)**: boot engine + control UI, move/dance in front of
|
||||
the iPhone → live meters move, audio responds; save/load a preset and
|
||||
confirm bindings persist; load a legacy preset and confirm migration.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New detection algorithms or new computed scalars (the values already exist;
|
||||
`mouthOpen` just routes an already-computed `mouth_open` scalar).
|
||||
- Changes to STEPS / SYNTH-FX tabs, the grid, transport, or the launchpad.
|
||||
- TouchOSC surface parity for the new MOD/POSE layout (can follow later).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- `mouthOpen`: **kept** — first-class source via a new `/pose/mouth` emit
|
||||
(one line in `pose_bridge.py`).
|
||||
- `/matrix/colormod`/`colorpose`: **removed as editors**, kept only for load
|
||||
migration.
|
||||
- Normalization constants for kin/limb: **env-var defaults**, tunable live.
|
||||
@@ -0,0 +1,107 @@
|
||||
# ARKit-only body position (cut Mac MediaPipe pose/face)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved (decisions made), pending spec review
|
||||
**Scope**: `shared/AVLiveWire` (tag), `iphone-arbody` (send 2D skeleton + rebuild),
|
||||
`data_only_viz/{iphone_usb_source.py, scripts/iphone_usb_bridge.py, multi.py, arkit_joint_map.py}`,
|
||||
`launcher/concert/launch_concert.sh` (POSE_FILTER). Hands already cut (iPhone Vision).
|
||||
|
||||
## Context
|
||||
|
||||
Under `--iphone-usb` the matrix's body modulation (`/pose/center`, `/pose/kin`,
|
||||
`/pose/sho_span`, `/pose/limb_span`, `/pose/head`) is computed from the **Mac
|
||||
MediaPipe pose** (2D image landmarks), with ARKit only *fused* into the 3D
|
||||
world body (`arkit_fuse`, 14 joints). The 2D body position the matrix uses is
|
||||
therefore MediaPipe (CPU, jittery) — the user wants it 100% ARKit.
|
||||
|
||||
**The wall:** the iPhone sends the ARKit skeleton in **3D world** coords
|
||||
(`root * jointModelTransforms`, tag `.skeleton`). The matrix `/pose/center`
|
||||
needs **2D image-normalized** coords. The iPhone app already computes a 2D
|
||||
projection (`ARCamera.projectPoint` → `skeleton2D`) for its on-device overlay
|
||||
but does NOT send it. So ARKit-only 2D position requires the iPhone to send the
|
||||
2D-projected skeleton.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Full ARKit body**: under `--iphone-usb`, build the body (2D + 3D) from the
|
||||
ARKit skeleton; **cut Mac MediaPipe pose AND face** (hands already cut). Lose
|
||||
`/pose/mouth` + the face mesh (accepted — max CPU, position 100% ARKit).
|
||||
- The iPhone sends the **2D-projected skeleton** (viewport-normalized) over USB,
|
||||
in addition to the existing 3D world skeleton.
|
||||
- `arkit_fuse` is dropped from `POSE_FILTER` (ARKit becomes the source, not a fuse).
|
||||
|
||||
## Design
|
||||
|
||||
### Wire format (`shared/AVLiveWire`)
|
||||
- Add a `skeleton2D` tag (e.g. value 5). Payload: 91 × (x, y) Float32
|
||||
(viewport-normalized 0..1) + 91 × valid (UInt8). Mirrors `SkeletonPayload`
|
||||
but 2 floats/joint instead of 3.
|
||||
|
||||
### iPhone app (`iphone-arbody`)
|
||||
- In `publishUSB` (or alongside `updateSkeleton2D`), build a `Skeleton2DPayload`
|
||||
from the already-computed `skeleton2D` points (`projectPoint` viewport coords)
|
||||
divided by `viewportSize` → normalized 0..1, with the tracked flags; send via
|
||||
`usb.send(tag: .skeleton2D, ...)`. Keep the existing 3D `.skeleton` send (the
|
||||
matrix's `/pose/kin` 3D body still uses it).
|
||||
- **Rebuild + redeploy** on the iPhone (`xcodegen generate` → Xcode → ⌘R). USER
|
||||
STEP — cannot be done remotely.
|
||||
|
||||
### Mac decode (`data_only_viz`)
|
||||
- `iphone_usb_source` + `scripts/iphone_usb_bridge.decode_skeleton2D`: decode
|
||||
`TAG_SKELETON2D` → `state.persons_arkit_2d[pid]` (91 × (x,y) normalized) +
|
||||
`persons_arkit_2d_t`.
|
||||
|
||||
### Mac pose loop (`multi.py`, under `--iphone-usb`)
|
||||
- **Cut MediaPipe pose + face** inference (like hands): `pose_res`/`face_res`
|
||||
skipped; `faces=[]`.
|
||||
- **Build the 2D body** (`bodies`, MP33 image-normalized) from `persons_arkit_2d`
|
||||
via the `ARKIT91_TO_MP33` map (extended with head/neck → MP33 slot 0). Missing
|
||||
MP33 slots (face/fingers) → low-confidence/clamped defaults.
|
||||
- **Build `bodies3d`** (MP33 world) from `persons_arkit_joints` (3D world) via the
|
||||
same map (this replaces the `arkit_fuse` splice — ARKit is now the source).
|
||||
- Emit `/pose/*` from the ARKit body. The id/tracking can use a single pid (the
|
||||
ARKit body) — the matrix is single-person.
|
||||
|
||||
### Mapping (`arkit_joint_map.py`)
|
||||
- Extend `ARKIT91_TO_MP33` with head/neck → MP33 0 (nose) so `/pose/center` +
|
||||
`/pose/head` have a head anchor. (ARKit `head_joint` index — confirm against the
|
||||
Apple enum during implementation.)
|
||||
|
||||
### Launcher
|
||||
- Drop `arkit_fuse` from `POSE_FILTER` (no longer fusing): set
|
||||
`POSE_FILTER=median+kalman+lookahead+ik` for the body (ARKit is already smooth;
|
||||
keep the temporal filters). Verify the matrix mod still feels right.
|
||||
|
||||
## Testing
|
||||
|
||||
- **AVLiveWire**: `swift test` (the wire round-trip for the new tag, in the
|
||||
shared package's loopback tests).
|
||||
- **Mac decode + body-build**: unit-test `decode_skeleton2D` + the ARKit→MP33
|
||||
body builder in isolation (no torch / no mediapipe needed for those pure
|
||||
functions) — feed a synthetic 91-joint 2D array, assert the MP33 body + a
|
||||
sane `/pose/center` (mean of mapped joints).
|
||||
- **Live (on the rig, after the iPhone rebuild)**: body position tracks from
|
||||
ARKit (stable, no MediaPipe jitter); `/pose/center`/`kin`/`sho_span` respond;
|
||||
CPU drops (no MediaPipe inference); the matrix's pose-driven modulation works;
|
||||
the face mesh + `/pose/mouth` are gone (accepted).
|
||||
|
||||
## Risks / dependencies
|
||||
|
||||
- **iPhone rebuild is a manual user step** (Swift, on-device) — each iteration of
|
||||
the Swift change needs a rebuild+redeploy. Get the wire format right first time.
|
||||
- Untestable end-to-end locally (needs the device + ARKit). Mac-side pure
|
||||
functions are unit-tested; the integration is verified live.
|
||||
- Coordinate orientation: `projectPoint` viewport coords depend on the interface
|
||||
orientation — normalize consistently (the app already handles orientation for
|
||||
its overlay; reuse that).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Face/mouth modulation (cut with MediaPipe face).
|
||||
- Multi-person (ARKit body tracking is single-body; the matrix is single-person).
|
||||
- Hand position (already iPhone Vision).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- Full ARKit body, cut MediaPipe pose+face, send 2D skeleton from the iPhone,
|
||||
drop `arkit_fuse`, head/neck added to the mapping.
|
||||
@@ -0,0 +1,325 @@
|
||||
# iPhone ↔ AV-Live — refonte technique SOTA 2026 (design)
|
||||
|
||||
Date: 2026-06-30
|
||||
Statut: design validé, en attente de relecture utilisateur avant plan d'implémentation
|
||||
Approche retenue: **C — fidélité maximale** (corps 3D + mains liftées en 3D)
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Le système capture un squelette corps + mains sur iPhone 16 Pro (iOS 26.5) et
|
||||
le streame vers un Mac pour piloter une performance audio-visuelle (matrix
|
||||
SuperCollider). L'utilisateur veut « vraiment la vision du squelette de pose
|
||||
sur data_only_viz et dans le lien de données », et une refonte technique
|
||||
« SOTA 2026 » sur quatre axes : **fidélité du squelette, latence/perf,
|
||||
modernisation de la stack Apple, robustesse/maintenabilité**.
|
||||
|
||||
Signaux retenus pour la performance : **corps 3D + mains/doigts**.
|
||||
Hors scope : visage/bouche, profondeur/mesh LiDAR.
|
||||
|
||||
### Diagnostic de l'existant (factuel)
|
||||
|
||||
Le **lien de données est déjà bon**. L'iPhone envoie sur TCP :7000 (usbmuxd,
|
||||
filaire, pas de réseau) en frames AVLiveWire (header 19 o big-endian) :
|
||||
|
||||
- `skeleton` (tag 1) : 91 joints 3D world-space + validité, ~30 fps
|
||||
- `skeleton2D` (tag 6) : 91 joints 2D projetés normalisés, ~30 fps
|
||||
- `hands` (tag 4) : 21 pts × 2 mains (Vision), ~15 fps
|
||||
- `video` (tag 2) : HEVC, keyframe / 30 frames
|
||||
|
||||
La dégradation est **côté consommation/rendu data_only_viz** :
|
||||
|
||||
- Les 91 joints sont décodés intacts dans `persons_arkit_joints`, **puis 76
|
||||
sont jetés** : `arkit_joint_map.py` (`ARKIT91_TO_MP33`) ne mappe que **15
|
||||
joints** dans le conteneur MediaPipe MP33.
|
||||
- `renderer.py` ne connaît que MP33 (35 os MediaPipe) → seuls **14 segments**
|
||||
sont dessinés (le « 14 segs, 17 tris » observé). Aucun chemin vers
|
||||
`persons_arkit_joints`.
|
||||
- Les index du mapping sont **non vérifiés** (`ARKIT_HEAD_IDX = 51` annoté
|
||||
« VERIFY LIVE »).
|
||||
- Le log `body3d: n=1 (pose_world_landmarks)` est trompeur (la source est
|
||||
ARKit sous `--iphone-usb`, pas MediaPipe).
|
||||
- Code mort : chemin OSC/UDP supprimé côté iOS mais doc périmée
|
||||
(`iphone-arbody/CLAUDE.md` montre encore un fanout OSC) ; tag `face` (5)
|
||||
défini mais jamais émis ; tag `meta` (3) réservé inutilisé.
|
||||
|
||||
À noter : l'armature 91 joints complète **est** rendue correctement ailleurs,
|
||||
par `AVLiveBody` (Swift, `Skeleton3DRenderer.swift`), consommateur distinct du
|
||||
même flux. Les deux consommateurs divergent.
|
||||
|
||||
### État de l'art Apple iOS 26 (recherche, sources datées)
|
||||
|
||||
- **Corps 3D** : ARKit `ARBodyTrackingConfiguration` / `ARSkeleton3D` (~91
|
||||
joints hiérarchiques world-space, A12+, 60 fps) reste le plus riche sur
|
||||
iPhone. Vision `VNDetectHumanBodyPose3DRequest` (iOS 17+) ne donne que 17
|
||||
joints → trop pauvre. Rien de matériellement nouveau en iOS 26 pour le body
|
||||
3D iPhone (surtout une API Vision Swift async).
|
||||
- **Mains** : pas de finger tracking ARKit natif sur iPhone (visionOS only).
|
||||
Voie native = Vision `VNDetectHumanHandPoseRequest` (21 joints). iOS 26
|
||||
apporte un **nouveau modèle hand pose** plus petit/précis/basse latence —
|
||||
**mais les positions de joints ont bougé** (re-tuning des classifieurs
|
||||
requis). Holistic `detectsHands` (iOS 18) existe mais est **2D**.
|
||||
- **Fusion corps+mains 3D** : pas d'unifié natif sur iPhone → **fusion
|
||||
manuelle obligatoire** (état de l'art).
|
||||
- **Transport** : usbmuxd + TCP sur câble reste recommandé pour le live
|
||||
filaire ; `NWConnection` (Network framework) est l'API moderne ;
|
||||
peer-to-peer Wi-Fi à éviter (latence).
|
||||
- **Concurrence** : `CVPixelBuffer` / `ARFrame` non `Sendable` → pipeline en
|
||||
`actor`, copie en structs `Sendable`, `AsyncStream` + `sending`, cible Swift
|
||||
6.2 « approachable concurrency ».
|
||||
|
||||
Conclusion : la stack de base (ARKit body + Vision hands + AVLiveWire/usbmux)
|
||||
est la bonne ; la refonte **exploite le riche existant** et **modernise les
|
||||
fondations**, elle ne remplace pas ARKit.
|
||||
|
||||
## Contrainte structurante
|
||||
|
||||
**Le lift 3D des mains par LiDAR n'est pas faisable.**
|
||||
`ARBodyTrackingConfiguration` rejette `.sceneDepth` (crashe la session) ;
|
||||
`.personSegmentationWithDepth` spamme « Portrait image not supported ». Le
|
||||
TrueDepth est sur la caméra avant, l'ARBodyTracking sur l'arrière → pas
|
||||
simultanés. La profondeur capteur n'est donc pas disponible pendant le body
|
||||
tracking.
|
||||
|
||||
→ Le 3D des mains est obtenu par **lift géométrique / IK ancré sur le poignet
|
||||
ARKit 3D** (cf. `HandLiftSolver`), sans LiDAR.
|
||||
|
||||
## Principe directeur
|
||||
|
||||
> Le téléphone est la **source de vérité 3D auto-descriptive** : il envoie
|
||||
> l'armature complète (corps + mains liftées) + sa topologie, horodatée, dans
|
||||
> un frame unifié. Les consommateurs (data_only_viz Python, AVLiveBody Swift)
|
||||
> **dessinent ce qu'on leur décrit** — zéro index hardcodé, zéro
|
||||
> reconstruction dégradée.
|
||||
|
||||
## Architecture & composants
|
||||
|
||||
### iPhone (`iphone-arbody/`, Swift 6.2)
|
||||
|
||||
```
|
||||
ARSession (ARBodyTrackingConfiguration, caméra arrière)
|
||||
│ ARFrame @60fps (MainActor : extrait UNIQUEMENT des valeurs Sendable)
|
||||
▼
|
||||
CaptureActor (actor, hors main thread)
|
||||
├─ extrait 91 joints 3D + validité
|
||||
├─ HandPoseService (nouveau modèle Vision iOS 26)
|
||||
├─ HandLiftSolver (2D → 3D ancré poignet)
|
||||
└─ copie en structs Sendable
|
||||
│ PoseSnapshot (Sendable, 1 timestamp)
|
||||
▼
|
||||
PoseEncoder → AsyncStream<Frame> TopologyProvider (1× au connect)
|
||||
▼
|
||||
WireServer (NWConnection, TCP :7000, usbmux) [+ VideoEncoder optionnel]
|
||||
```
|
||||
|
||||
Nouveaux composants : `CaptureActor` (isolation concurrence),
|
||||
`HandPoseService`, `HandLiftSolver`, `TopologyProvider`, `PoseEncoder`.
|
||||
`VideoEncoder` devient optionnel (`SEND_VIDEO`).
|
||||
|
||||
### Protocole partagé (`shared/AVLiveWire/`, v2)
|
||||
|
||||
- `FrameHeader` v2 : magic `AVL2`, octet **version** négocié.
|
||||
- `PosePayload` (tag `pose`) : corps 91 + N mains, **un seul frame horodaté**.
|
||||
- `TopologyPayload` (tag `topology`) : noms de joints + parentIndices corps &
|
||||
mains, émis 1× au connect (+ resend à chaque reconnexion).
|
||||
- Tags `skeleton` / `skeleton2D` / `hands` / `face` dépréciés puis retirés.
|
||||
|
||||
### Mac
|
||||
|
||||
- `data_only_viz` : `IphonePoseSource` (décode v2 → état unifié) +
|
||||
`SkeletonRenderer` refondu (corps + mains 3D, topologie-driven, plus de
|
||||
MP33). Détection gestes (`hand_features.py`) consomme les joints **main 3D**.
|
||||
- `AVLiveBody` (Swift) : aligné sur v2 (même `pose` + topologie).
|
||||
- Suppressions : `arkit_joint_map.py`, chemin OSC mort, log trompeur.
|
||||
|
||||
## Flux de données & schéma v2
|
||||
|
||||
### Header v2 (20 octets, big-endian)
|
||||
|
||||
| Champ | Taille | Note |
|
||||
|-------|--------|------|
|
||||
| magic | 4 o | `AVL2` |
|
||||
| version | 1 o | `2` (négociation explicite) |
|
||||
| tag | 1 o | `pose=1`, `topology=2`, `video=3` |
|
||||
| pid | 2 o (i16) | id corps, `-1` global |
|
||||
| timestamp | 8 o (f64) | horloge ARFrame, partagé corps+mains |
|
||||
| length | 4 o (u32) | longueur payload |
|
||||
|
||||
### `PosePayload` (tag `pose`), ~30 fps
|
||||
|
||||
```
|
||||
u8 bodyJointCount (= 91)
|
||||
[91] float32 x,y,z joints corps 3D world-space (m)
|
||||
[91] u8 valid
|
||||
u8 handCount (0..2)
|
||||
└─ par main:
|
||||
u8 chirality (0=left, 1=right)
|
||||
u8 lifted (1 = 3D résolu, 0 = fallback planaire)
|
||||
[21] float32 x,y,z joints main, MÊME repère monde que le corps
|
||||
[21] u8 valid
|
||||
```
|
||||
|
||||
### `TopologyPayload` (tag `topology`), 1× au connect
|
||||
|
||||
```
|
||||
u16 bodyJointCount, puis bodyJointCount × [u8 nameLen + utf8 name]
|
||||
[bodyJointCount] i16 parentIndex (-1 = racine)
|
||||
u16 handJointCount (=21), idem noms + parents (modèle Vision iOS 26)
|
||||
```
|
||||
|
||||
Le renderer construit ses os depuis `parentIndex` : zéro index hardcodé. Si
|
||||
l'ordre des joints change (nouveau modèle mains iOS 26), le rendu suit.
|
||||
|
||||
### Synchro & cadence
|
||||
|
||||
- Body @30 fps (throttle conservé) ; le dernier hand snapshot dispo est
|
||||
agrégé dans le frame body courant (timestamp partagé) → fin du désync
|
||||
15/30 fps.
|
||||
- Vidéo : tag séparé, optionnelle (`SEND_VIDEO=0` par défaut pour la visu).
|
||||
|
||||
## HandLiftSolver (cœur de l'approche C)
|
||||
|
||||
Sur l'iPhone. Entrées : 21 joints 2D Vision + intrinsèques caméra `ARFrame` +
|
||||
poignet ARKit 3D (déjà dans les 91 joints). Sortie : 21 joints main
|
||||
world-space, même repère que le corps.
|
||||
|
||||
1. **Ancrage** : poignet de la main Vision forcé sur le poignet ARKit 3D
|
||||
(profondeur `d₀` connue, non ambiguë).
|
||||
2. **Orientation initiale** : axe avant-bras ARKit (coude→poignet) + up caméra
|
||||
→ repère de paume (lève l'ambiguïté dos/paume + roll).
|
||||
3. **Back-projection** : chaque joint 2D → un rayon caméra (via intrinsèques).
|
||||
4. **Résolution par contrainte de longueur d'os** : propagation de la chaîne
|
||||
cinématique, profondeur par joint choisie pour respecter les longueurs d'os
|
||||
anthropométriques (a priori main ~18 cm, proportions fixes). Ambiguïté à 2
|
||||
solutions → branche cohérente avec la frame précédente.
|
||||
5. **Lissage** : One Euro sur les 21 joints 3D, anti-flip de branche.
|
||||
|
||||
### Précision (honnêteté)
|
||||
|
||||
Le 3D monoculaire d'une main est intrinsèquement ambigu en profondeur. Le lift
|
||||
contraint par longueurs d'os donne un 3D **plausible et stable pour la
|
||||
visualisation et les effets spatiaux**, pas de la métrologie. C'est le but de
|
||||
l'approche C (fidélité visuelle).
|
||||
|
||||
### Gestes en 3D (décision utilisateur)
|
||||
|
||||
Les 8 slots pinch + fist sont calculés sur le **3D lifté** (pas le 2D) :
|
||||
|
||||
- Pinch = distance 3D métrique pouce↔doigt, **normalisée par la taille de main
|
||||
3D** → invariant à la rotation de la main. Fist = courbure 3D de la chaîne.
|
||||
- **Re-tuning obligatoire** : `PINCH_RATIO_ON=0.45` / `OFF=0.65` deviennent des
|
||||
ratios 3D, à re-dériver sur device (re-tuning de toute façon imposé par le
|
||||
nouveau modèle mains iOS 26).
|
||||
- **Robustesse** : seuils normalisés par taille de main (annule l'erreur
|
||||
d'échelle), hystérésis ON/OFF conservée, réfractaire conservé, lissage One
|
||||
Euro avant calcul du geste.
|
||||
- **Fallback `lifted=0`** : 3D planaire à la profondeur du poignet → le pinch
|
||||
reste l'équivalent du 2D actuel ; les gestes continuent de marcher.
|
||||
- **Calcul côté Mac** (`hand_features.py` + routing OSC matrix) sur les joints
|
||||
main 3D du frame. iPhone = capture+lift pur, Mac = sens+routing.
|
||||
|
||||
## Concurrence, latence & perf
|
||||
|
||||
### Pipeline iPhone en isolation stricte (Swift 6.2)
|
||||
|
||||
- `ARSessionDelegate` (MainActor) extrait uniquement des valeurs `Sendable`
|
||||
(transforms, intrinsèques) ; aucun `CVPixelBuffer`/`ARFrame` ne franchit une
|
||||
frontière d'isolation (supprime le `nonisolated(unsafe)` patch).
|
||||
- `CaptureActor` sérialise l'état mutable (fps, compteurs, dernier hand) → fin
|
||||
des data races sans lock manuel.
|
||||
- Structs `Sendable` (`BodySnapshot`, `HandSnapshot`, `PoseFrame`) copiées
|
||||
avant toute frontière. `CVPixelBuffer` → `VideoEncoder` uniquement, via
|
||||
`sending`.
|
||||
- `AsyncStream` pour pousser au réseau (back-pressure naturelle).
|
||||
- Build en strict concurrency (Swift 6.2 approachable concurrency).
|
||||
|
||||
### Budget latence (geste → son), chemin filaire
|
||||
|
||||
| Étage | ~ms |
|
||||
|-------|-----|
|
||||
| ARKit body + Vision hand (A18 Pro) | 16–20 |
|
||||
| HandLiftSolver | < 2 |
|
||||
| Encode frame v2 + usbmux TCP (câble) | 2–5 |
|
||||
| Décode Mac + filtre pose + calcul geste | 5–8 |
|
||||
| OSC → SuperCollider | ~2 |
|
||||
| **Total estimé** | **~30–40 ms** |
|
||||
|
||||
### Leviers perf
|
||||
|
||||
- Vidéo off par défaut (`SEND_VIDEO=0`) : supprime encode HEVC + décode PyAV +
|
||||
bande passante.
|
||||
- Frame `pose` unifié : 1 message / 30 fps au lieu de 3 flux désynchronisés.
|
||||
- Throttle body 30 fps conservé (60 fps dispo si besoin futur).
|
||||
|
||||
## Gestion d'erreurs & dégradation
|
||||
|
||||
Principe : dégrader gracieusement, ne jamais crasher, toujours signaler
|
||||
l'état.
|
||||
|
||||
- **Reconnexion** : back-off auto (iPhone `WireServer`, Mac `IphonePoseSource`).
|
||||
La topologie est renvoyée à chaque (re)connexion ; le Mac bufferise les
|
||||
`pose` jusqu'à réception de la topologie. Flux coupé → dernier squelette
|
||||
gelé avec indicateur « stale », pas de disparition brutale.
|
||||
- **Perte de tracking** : aucun corps → « no body », modulation gelée sur
|
||||
dernière valeur ; joints partiels → flag `valid` par joint, os non dessiné,
|
||||
IK comble les trous courts ; main absente → gestes désarmés proprement ;
|
||||
main non liftée → `lifted=0`, 3D planaire, geste calculé, affichage distinct.
|
||||
- **Mismatch version** : `AVL2` + octet version → un consommateur d'une autre
|
||||
version rejette explicitement avec log clair (pas de décodage silencieux
|
||||
faux). `StreamDemuxer` : resync magic, rejet `length > maxPayload`, compteur
|
||||
de frames corrompues exposé.
|
||||
- **Garde-fous gestes** : hystérésis + réfractaire ; confiance < seuil sur N
|
||||
frames → geste forcé OFF ; armement sur front uniquement.
|
||||
- **Observabilité** : frame de métriques léger (fps réel, % frames liftées,
|
||||
drops Vision, frames corrompues, latence aller) affichable dans data_only_viz
|
||||
/ web control.
|
||||
|
||||
## Tests & validation
|
||||
|
||||
- **N1 — Unitaires déterministes** (`shared/AVLiveWire`) : round-trip
|
||||
encode/decode `PosePayload` + `TopologyPayload` ; robustesse `StreamDemuxer`
|
||||
(tronqué, magic partiel, frame géante, resync, rejet de version) ;
|
||||
`HandLiftSolver` (poignet synthétique + rayons + longueurs d'os → coïncidence
|
||||
poignet 3D, reprojection < ε px, stabilité de branche).
|
||||
- **N2 — Index ARKit sur device** : mode debug logue les noms
|
||||
`ARSkeletonDefinition`, le renderer étiquette chaque joint → confirmation
|
||||
visuelle une fois pour toutes (tue le « VERIFY LIVE » ; la topologie
|
||||
auto-descriptive empêche la re-dérive).
|
||||
- **N3 — Harnais comparatif gestes 2D→3D** : double détection 2D‖3D en
|
||||
parallèle sur le même flux device, log des divergences ; bascule quand 3D ≥
|
||||
2D en fiabilité et 0 faux positif ajouté (pinch lent/rapide, main
|
||||
tournée/de profil, fist) ; re-dérivation des seuils 3D ici. 2D reste source
|
||||
live tant que le critère n'est pas atteint (flag réversible).
|
||||
- **N4 — Visuel bout-en-bout** : data_only_viz affiche l'armature 91 joints +
|
||||
mains 3D (vs 14 segments actuels). Capture d'écran : `screencapture` est
|
||||
bloqué (permission Enregistrement de l'écran absente) → soit accorder la
|
||||
permission, soit valider via métriques + logs structurés (à décider en
|
||||
implémentation).
|
||||
- **Latence** : mesurée en continu via le timestamp partagé du frame.
|
||||
|
||||
## Phasage & migration
|
||||
|
||||
Le live garde un chemin fonctionnel à chaque étape ; chaque phase est livrable,
|
||||
validée, réversible par flag. Valeur visible d'abord, risque ensuite.
|
||||
|
||||
| Phase | Contenu | Driver | Risque | Réversible |
|
||||
|-------|---------|--------|--------|------------|
|
||||
| 0. Protocole | AVLiveWire v2 (header versionné, `PosePayload`, `TopologyPayload`) + tests | robustesse | faible | n/a |
|
||||
| 1. Squelette fidèle | iPhone émet la topologie ; data_only_viz nouveau renderer sur les 91 joints déjà décodés + mains, topologie-driven. **Gain visible : vrai squelette.** | fidélité | faible | flag (rendu MP33 en repli) |
|
||||
| 2. iPhone modernisé | Refacto capture `actor` Swift 6.2 + Sendable, frame `pose` unifié horodaté, vidéo optionnelle | latence + Apple | moyen | dual-emit v1/v2 |
|
||||
| 3. Mains 3D | `HandLiftSolver` → mains world-space dans le frame ; validation solver | fidélité | moyen | `lifted=0` repli planaire |
|
||||
| 4. Gestes 3D | Harnais double-détection, re-tuning seuils, bascule quand 3D ≥ 2D | fidélité | moyen | flag (2D source tant que non validé) |
|
||||
| 5. Hand model iOS 26 | Swap nouveau modèle Vision + re-tuning | Apple | faible | flag |
|
||||
| 6. Nettoyage | Retrait tags v1/face, OSC mort, `arkit_joint_map` ; alignement AVLiveBody sur v2 ; mise à jour des CLAUDE.md périmés | robustesse | faible | — |
|
||||
|
||||
Point de bascule sûr : dès la fin de Phase 1, le besoin n°1 (vraie vision du
|
||||
squelette dans data_only_viz) est livré, sans toucher l'iPhone en profondeur
|
||||
ni risquer les gestes.
|
||||
|
||||
## Décisions ouvertes pour l'implémentation
|
||||
|
||||
- Permission « Enregistrement de l'écran » pour les captures de validation
|
||||
visuelle (N4) — à accorder ou contourner par métriques.
|
||||
- Indices anatomiques ARKit exacts (poignet, avant-bras) à confirmer sur device
|
||||
en Phase N2 avant de câbler le `HandLiftSolver`.
|
||||
- Re-vérifier en Phase 5 l'impact du nouveau modèle mains iOS 26 sur les noms /
|
||||
ordre de joints de la topologie.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Matrix fist-held gestures — pointG (reload current) / pointD (next)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending spec review
|
||||
**Scope**: `sound_algo/data_only/matrix.scd`, `sound_algo/data_only/matrix_presets/generate_presets.py`,
|
||||
`web_realart/public/control/{js/matrix-state.js,js/morceau-panel.js,control.css}`. No `data_only_viz`
|
||||
change (openness `lOpen`/`rOpen` already arrives via `/pose/hands`). No launcher change.
|
||||
|
||||
## Context
|
||||
|
||||
After the pinch refinements, the user wants two more gestures driven by a HELD
|
||||
CLOSED FIST (one per hand): left fist held → **charger** (reload the current
|
||||
matrix), right fist held → **suivant** (next preset). A fist = low hand
|
||||
openness; the openness signal already flows into the engine, so detection +
|
||||
timing live entirely in SC, reusing the pinch hold mechanic.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Gesture** = closed fist (low openness) HELD ≥ the hold duration.
|
||||
- **pointG** (left fist held) → reload the current matrix from disk.
|
||||
- **pointD** (right fist held) → `~matNextPreset` (next preset, sorted order).
|
||||
- **Detection** = SC-side from `lOpen`/`rOpen` (in `~matModCache`, written by the
|
||||
`/pose/hands` OSCdef). Fist engage when openness ≤ `~matFistThresh`, release
|
||||
when ≥ `~matFistThresh + 0.15` (hysteresis). Presence-guarded (`x > 0.001`),
|
||||
so an absent hand (openness 0) does NOT read as a fist.
|
||||
- **Hold** reuses `~matPinchHoldMs` (the existing "Maintien" slider) — one hold
|
||||
duration for pinch + fist.
|
||||
- **Fist sensitivity** = `~matFistThresh`, a LIVE menu slider (default 0.15),
|
||||
saved per morceau.
|
||||
- **Progress** = `/matrix/fistprogress [hand, 0..1]` → two menu indicators
|
||||
("Poing G → charger", "Poing D → suivant") fill like the pinch cells.
|
||||
|
||||
## Design
|
||||
|
||||
### SC engine (matrix.scd)
|
||||
- State: `~matFistThresh` (default 0.15), `~matFistState` (Array[2] of
|
||||
`\unknown`/`\fist`/`\open`), `~matFistHeld` (Array[2] engage-time or nil),
|
||||
`~matFistFired` (Array[2] bool).
|
||||
- `~matFistEdge.(hand, x, openness)` (called from the `/pose/hands` OSCdef for
|
||||
L (hand 0: `x=msg[2]`, `openness=msg[4]`) and R (hand 1: `x=msg[6]`,
|
||||
`openness=msg[8]`)):
|
||||
- `present = x > 0.001`. If absent → state `\unknown`, clear `~matFistHeld[hand]`.
|
||||
- fist if `openness <= ~matFistThresh`; open if `openness >= ~matFistThresh + 0.15`;
|
||||
else keep previous.
|
||||
- on `\open`→`\fist` edge: `~matFistHeld[hand] = Main.elapsedTime`,
|
||||
`~matFistFired[hand] = false`.
|
||||
- on `\fist`→`\open` (or disappear): `~matFistHeld[hand] = nil`,
|
||||
`~matFistFired[hand] = false`, emit `/matrix/fistprogress [hand, 0]`.
|
||||
- `~matFistTick` (called by the existing pinch poller alongside `~matPinchTick`,
|
||||
30 Hz): for each held hand, `prog = ((now - held)*1000 / ~matPinchHoldMs).clip(0,1)`,
|
||||
emit `/matrix/fistprogress [hand, prog]`; when `prog>=1` and not fired, set
|
||||
fired + fire: hand 0 → `~matFistReloadCurrent` (`~matCurrentName.notNil` →
|
||||
`~matLoad(~matCurrentName)` + `/matrix/loaded`); hand 1 → `~matNextPreset`.
|
||||
- Routes: `/matrix/fisthresh [v]` set (clip 0.05..0.5) + echo, `/matrix/fisthresh/get`
|
||||
push. `fistThresh` saved per `.matrix` (top-level, default 0.15 on legacy).
|
||||
|
||||
### Generator
|
||||
- `to_sc` emits `'fistThresh': 0.15` (top-level, after `pinchHoldMs`).
|
||||
|
||||
### Web
|
||||
- `matrix-state.js`: `matFistThresh` (default 0.15) + setter + persistence (`fth`).
|
||||
- `morceau-panel.js`: a "Sensibilité poing" slider (0.05..0.5 → `/matrix/fisthresh`,
|
||||
synced); two fist indicator rows ("Poing G → charger", "Poing D → suivant")
|
||||
with a progress fill driven by `on("/matrix/fistprogress")`.
|
||||
- `control.css`: fist-indicator + fill styles.
|
||||
|
||||
## Testing
|
||||
|
||||
- **SC** (`test_global_actions.scd`, new group Q): fist engage records hold
|
||||
(no immediate fire); `~matFistTick` past hold fires the right action (hand 0
|
||||
reload-current via a stubbed `~matLoad` capturing the name = `~matCurrentName`;
|
||||
hand 1 → `~matNextPreset`); release before hold cancels; openness above the
|
||||
release threshold releases; `/matrix/fisthresh` clamps + sets; `fistThresh`
|
||||
save/load roundtrips; an ABSENT hand (x=0, openness 0) does NOT engage a fist.
|
||||
- **Python** (`test_global_actions.py`): every `.matrix` carries `fistThresh: 0.15`.
|
||||
- **Web** (`matrix-state.test.mjs`): `matFistThresh` default + persistence.
|
||||
- **Live smoke**: hold a left fist → "Poing G" fills then reloads the current
|
||||
morceau; right fist → next preset; the sensitivity slider changes how easily a
|
||||
fist registers; an open/quick fist does nothing; absent hands never trigger.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A separate fist HOLD duration (reuses `~matPinchHoldMs`).
|
||||
- Per-hand fist sensitivity (one global `~matFistThresh`).
|
||||
- Making pointG/pointD remappable (fixed: G=reload-current, D=next).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- Fist threshold 0.15 (live slider, saved), hysteresis +0.15; hold reuses the
|
||||
pinch hold; G=reload-current, D=next; progress indicators in the menu.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Matrix global morceau actions — hand-gesture global actions + "Morceau" panel
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending plan
|
||||
**Scope**: `sound_algo/data_only/matrix.scd` (engine + detection + storage + OSC),
|
||||
`sound_algo/data_only/matrix_presets/generate_presets.py` (emit defaults + test),
|
||||
`web_realart/public/control/` (bottom "Morceau" panel + persistence). No
|
||||
`data_only_viz` changes.
|
||||
|
||||
## Context
|
||||
|
||||
The matrix has per-voice modulation (continuous, `default_mods`) and per-voice
|
||||
pose bindings (`~matVoicePoses`, actions `\trigger`/`\gate`). The user wants a
|
||||
distinct, **morceau-level** layer: opening/closing a hand fires a **global
|
||||
action on the whole matrix** (not per-voice), with the gesture→action mapping
|
||||
**editable and saved per morceau** via a new menu at the bottom of the matrix
|
||||
editor.
|
||||
|
||||
This is NOT a removal from the instrument editor (the per-voice editor and its
|
||||
`lOpen`/`rOpen` mod sources + `pinch*` pose choices stay). It is a new global
|
||||
layer added "into the matrix patterns".
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Effect scope**: whole morceau (global), not per-voice.
|
||||
- **Gestures**: 4 slots = screen-left/right hand × open/closed.
|
||||
- **Default semantics**: LH open=fill, LH closed=drop, RH open=relance+evolve,
|
||||
RH closed=breakdown.
|
||||
- **Timing**: immediate (fire on the open/closed flip, no bar quantization).
|
||||
- **Duration**: momentary — transient effects auto-revert after N bars
|
||||
(default 1 measure); one-shots act once.
|
||||
- **Detection**: SC-side, derived from the `lOpen`/`rOpen` openness already in
|
||||
`/pose/hands` (no `data_only_viz` change, no new OSC route inbound).
|
||||
- **Mapping + settings**: editable, saved per `.matrix`, surfaced in a new
|
||||
bottom "Morceau" panel. Menu content = global controls + gesture mappings +
|
||||
tuning settings (panel option 2+3+4).
|
||||
- **Delivery**: 3 phases, each testable/deployable.
|
||||
|
||||
## `/pose/hands` payload (already received by matrix.scd)
|
||||
|
||||
`/pose/hands [0, lx, ly, lopen, lspeed, rx, ry, ropen, rspeed, dist]`
|
||||
→ `lx=msg[2]`, `lopen=msg[4]`, `rx=msg[6]`, `ropen=msg[8]`.
|
||||
An absent hand emits `[0,0,0,0]` for its slot (`_hand_slot(None)` in
|
||||
`pose_bridge.py`), so **`x == 0.0` ⟺ hand absent**; a present hand always has
|
||||
`x > 0`. `openness` is normalised 0..1 (0 = fist, 1 = open).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Hand open/closed detection (matrix.scd, in the `/pose/hands` OSCdef)
|
||||
|
||||
Per hand slot (L: `lx`,`lopen`; R: `rx`,`ropen`):
|
||||
|
||||
- `present = x > 0.001`.
|
||||
- Hysteresis on openness → state:
|
||||
- `\open` if `openness >= openThresh` (default 0.65)
|
||||
- `\closed` if `openness <= closeThresh` (default 0.30)
|
||||
- deadband (between thresholds) → keep previous state.
|
||||
- **Flip = fire**: when a present hand transitions between two *established*
|
||||
present states (`\open`↔`\closed`), fire the slot's action immediately.
|
||||
- First established state on (re)appearance sets the baseline only — no fire.
|
||||
- Hand disappearing (`present` false) resets that hand's state to `\unknown`
|
||||
(no fire on disappear; next appearance re-baselines).
|
||||
|
||||
Per-hand state kept in `~matHandState[\L]`/`~matHandState[\R]` (`\unknown` /
|
||||
`\open` / `\closed`). Slot index: `0=LH open, 1=LH closed, 2=RH open, 3=RH closed`.
|
||||
|
||||
### 2. `~matTransient` — the momentary-override primitive
|
||||
|
||||
A single global override active for `transientBars` bars after a gesture, then
|
||||
auto-expires. Held as an Event:
|
||||
`~matTransient = (kind: \fill, voices: [...], untilBeat: <clock beats>)` or
|
||||
`nil` when inactive.
|
||||
|
||||
The per-bar voice sourcing in `~matPlay` (and `~matVariation`) consults
|
||||
`~matTransient` when building each voice's pattern for the current bar:
|
||||
|
||||
- expired (`clock.beats >= untilBeat`) → clear to `nil`, source normally.
|
||||
- `\fill` → source every active voice from colour 6.
|
||||
- `\drop` → all voices silent.
|
||||
- `\breakdown` → all voices silent except `voices` (kept set).
|
||||
- `\muteDrums` / `\muteMelo` → voices in that `VOICE_CLASS` group silent.
|
||||
- `\doubleTime` → multiply each voice's `stretch` by 0.5 in `~matVariation`.
|
||||
- `\halfTime` → multiply `stretch` by 2.0.
|
||||
- `\washReverb` → boost every voice's `rev` send (additive, clamped).
|
||||
|
||||
Setting a transient: `~matSetTransient.(kind, voices)` stamps
|
||||
`untilBeat = clock.beats + (transientBars * beatsPerBar)` and re-sources playing
|
||||
voices immediately (immediate effect, the chosen timing).
|
||||
|
||||
### 3. Action vocabulary (assignable to the 4 slots)
|
||||
|
||||
| symbol | label (fr) | effect | type |
|
||||
|--------|-----------|--------|------|
|
||||
| `none` | aucune | — | — |
|
||||
| `fill` | fill | all voices → colour 6 | transient |
|
||||
| `drop` | drop | all voices silent | transient |
|
||||
| `breakdown` | breakdown | all silent except kept voices | transient |
|
||||
| `evolve` | relance + evolve | re-source all + 1 evolve mutation | one-shot |
|
||||
| `muteDrums` | mute drums | drum-class voices silent | transient |
|
||||
| `muteMelo` | mute mélo | melodic-class voices silent | transient |
|
||||
| `doubleTime` | double-time | global stretch ×0.5 | transient |
|
||||
| `halfTime` | half-time | global stretch ×2 | transient |
|
||||
| `washReverb` | wash reverb | boost rev on all voices | transient |
|
||||
| `next` | suivant | load next preset (list order) now | one-shot |
|
||||
|
||||
One-shots (`evolve`, `next`) do not use `~matTransient`; they call the existing
|
||||
`~matEvolveOnce`/load path immediately.
|
||||
|
||||
### 4. Settings (per morceau)
|
||||
|
||||
`~matGlobalSettings` Event:
|
||||
- `openThresh` (default 0.65), `closeThresh` (default 0.30) — detection hysteresis.
|
||||
- `transientBars` (default 1) — transient duration.
|
||||
- `breakdownKeep` (default `[\kick, \sub]`) — voices kept during `breakdown`.
|
||||
|
||||
### 5. Mapping storage + persistence
|
||||
|
||||
- `~matGlobalActions` = 4-element Array of action symbols (slot order above).
|
||||
Default: `[\fill, \drop, \evolve, \breakdown]`.
|
||||
- OSC:
|
||||
- `/matrix/globalaction [slot, actionSym]` → set one slot; echo same address.
|
||||
- `/matrix/globalaction/get` → push all 4 (`/matrix/globalaction slot sym` ×4).
|
||||
- `/matrix/globalsettings [openThresh, closeThresh, transientBars, keepN, keep0..]`
|
||||
→ set; echo same address. `/matrix/globalsettings/get` → push.
|
||||
- **Saved per `.matrix`**: new fields `globalActions: [ \fill, \drop, \evolve,
|
||||
\breakdown ]` and `globalSettings: (openThresh: 0.65, closeThresh: 0.30,
|
||||
transientBars: 1, breakdownKeep: [\kick, \sub])`. `~matLoadFile` parses them
|
||||
with the listed defaults when absent (back-compat with old presets);
|
||||
`~matSave` writes them; on load, SC pushes both to the UI.
|
||||
- `generate_presets.py` emits both fields (defaults) into every `.matrix`.
|
||||
- Web: `matrix-state.js` holds `matGlobalActions` + `matGlobalSettings`,
|
||||
persisted in the existing localStorage blob.
|
||||
|
||||
### 6. Web "Morceau" panel (bottom of the matrix editor, collapsible)
|
||||
|
||||
New module `web_realart/public/control/js/morceau-panel.js` + a container in
|
||||
`index.html` + `control.css` styles. Three groups:
|
||||
|
||||
1. **Global** — evolve on/off + rate, full-loop, tempo, charger-suivant.
|
||||
Consolidates the existing matrix buttons (`#matrix-evolve`,
|
||||
`#matrix-evolve-rate`, `#matrix-loop-full`, `#matrix-loadnext`) and the
|
||||
tempo control into the panel. (Phase 3.)
|
||||
2. **Gestes mains** — 4 dropdowns (MG ouverte / MG fermée / MD ouverte / MD
|
||||
fermée) → action from the vocabulary. `GLOBAL_ACTION_CHOICES` in
|
||||
`matrix-state.js`. Change → `/matrix/globalaction slot sym`; `on()` handler
|
||||
keeps dropdowns synced + persists.
|
||||
3. **Réglages** — open/closed threshold sliders, transient-duration field,
|
||||
breakdown kept-voices multi-select. Change → `/matrix/globalsettings ...`.
|
||||
|
||||
Behaviour note surfaced in UI help text: `lOpen`/`rOpen` are screen-left /
|
||||
screen-right hands (matching existing "Main G/D" labels). RH actions
|
||||
(relance+evolve, breakdown) require **both hands visible** (a single hand is
|
||||
always classified screen-left).
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 1 (core)** — detection (§1) + `~matTransient` (§2) + 4 core actions
|
||||
(fill, drop, breakdown, evolve) + `~matGlobalActions` storage + OSC
|
||||
get/set + save/load + `generate_presets.py` emit + the 4 gesture dropdowns in
|
||||
a minimal bottom panel. Default mapping live.
|
||||
- **Phase 2 (vocabulary + settings)** — extended actions (muteDrums, muteMelo,
|
||||
doubleTime, halfTime, washReverb, next) + `~matGlobalSettings` (§4) + the
|
||||
Réglages group + settings persistence.
|
||||
- **Phase 3 (full panel)** — consolidate existing global controls
|
||||
(evolve/rate, full-loop, charger-suivant) into the panel + add a matrix
|
||||
tempo route `/matrix/bpm` (none exists today) + tempo control in the panel.
|
||||
|
||||
Each phase ends green (tests + validate) and is deployed to macm1.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python** (`generate_presets.py`): assert every emitted `.matrix` contains
|
||||
`globalActions` (4 syms) and `globalSettings` with the documented defaults
|
||||
(extend `test_sections.py` or a new `test_global_actions.py`); deterministic
|
||||
regen; `validate_presets.scd` parses the new fields on 84/84.
|
||||
- **SC** (`test_global_actions.scd`): feed synthetic `/pose/hands` sequences →
|
||||
assert flip detection (no fire on appear/disappear, fire on open↔closed),
|
||||
`~matSetTransient` apply + auto-expire, breakdown keeps only kept voices,
|
||||
mute-group counts, mapping + settings save/load roundtrip.
|
||||
- **Web** (`web_realart/test/*.test.mjs`, pure logic): action-choice list,
|
||||
slot serialize/parse, settings parse/serialize, localStorage roundtrip.
|
||||
- **Live smoke** (macm1): open/close each hand → hear fill/drop/breakdown/evolve
|
||||
(and Phase-2 actions); edit a slot in the panel → re-map takes effect; save a
|
||||
matrix, reload → mapping + settings preserved; old presets load with defaults.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-voice pose/mod editor changes (untouched).
|
||||
- New gesture detection in `data_only_viz` (openness from `/pose/hands` suffices).
|
||||
- Body-pose or finger global actions (only hand open/closed in this feature).
|
||||
- Quantized/held-state variants (decided: immediate + momentary).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Matrix `_live` section = continuous full groove (no intro/breakdown)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending plan
|
||||
**Scope**: `sound_algo/data_only/matrix_presets/generate_presets.py` + `test_sections.py` + regenerated `*.matrix`. Follow-on refinement to presets v4.
|
||||
|
||||
## Context
|
||||
|
||||
In presets v4 each morceau is split into `_intro` / `_live` / `_outro`. The
|
||||
`_live` section reuses the morceau's original arc, which is a standalone 64-bar
|
||||
piece WITH its own intro: the grid starts near-empty and rebuilds (I=8, B=34, …,
|
||||
plateau P=133) and has a mid breakdown (K=30) — measured on `techno_drive_live`.
|
||||
So `_live` re-introduces instead of being the sustained groove the `_intro`
|
||||
builds up to. The user wants `_live` to be the **continuous full loop**: full
|
||||
from bar 0, no intro ramp, no breakdown — the intro builds to it, the outro
|
||||
descends from it.
|
||||
|
||||
## Decision (from brainstorming)
|
||||
|
||||
`_live` = the **plateau (P, bars 32-47) tiled across all 64 bars** — a clean,
|
||||
loopable full groove. No intro, no breakdown. Intro/outro sections unchanged.
|
||||
|
||||
## Design
|
||||
|
||||
### `make_live_continuous(g)` (generate_presets.py)
|
||||
Applied to the `_live` grid AFTER `apply_colors` (so it tiles the coloured
|
||||
plateau, which already carries the plateau's internal variation — e.g. the
|
||||
double-time hats on the plateau's second half):
|
||||
```python
|
||||
def make_live_continuous(g):
|
||||
"""Live = the plateau groove tiled across all 64 bars: full from bar 0, no
|
||||
intro ramp, no breakdown. The intro builds to it, the outro descends from it."""
|
||||
ps, pn = SECTIONS["P"] # (32, 16)
|
||||
for row in g:
|
||||
plateau = row[ps:ps + pn] # the 16-bar full-groove slice
|
||||
for b in range(len(row)):
|
||||
row[b] = plateau[(b - ps) % pn]
|
||||
return g
|
||||
```
|
||||
`(b - ps) % pn` keeps the plateau anchored at bars 32-47 and repeats it to fill
|
||||
0-31 and 48-63, so each 16-bar block is the full plateau.
|
||||
|
||||
### Wiring (`to_sc` + `main`)
|
||||
- `to_sc(name, arc_fn, instkit, melodies, extra, post=None)`: after
|
||||
`apply_colors(g)`, call `post(g)` if given.
|
||||
- `main()`: `post = make_live_continuous if sec == "live" else None`.
|
||||
- intro/outro pass `post=None` (unchanged).
|
||||
|
||||
## Testing
|
||||
|
||||
- **`test_sections.py`**: mirror the generator — apply `make_live_continuous` to
|
||||
the `_live` grid in `grid_of(...)` when `sec == "live"`. Then:
|
||||
- existing checks hold: intro builds (late>early), outro breaks (early>late),
|
||||
live is the densest.
|
||||
- NEW: `_live` is FULL from the start — `active(live, 0, 16)` is within ~15% of
|
||||
`active(live, 32, 48)` (no intro ramp); and `_live` has NO breakdown dip —
|
||||
`active(live, 48, 64)` is also within ~15% of the plateau. (Tiling makes all
|
||||
four 16-bar blocks equal, so these hold with margin.)
|
||||
- **SC**: `validate_presets.scd` → `84/84 PASS` + `DEFAULTS PASS` unchanged
|
||||
(peak_drop_live grid changes but the voiceMods/voicePoses assertions are
|
||||
unaffected — the transform only moves cells, doesn't touch mods/poses).
|
||||
- **Live smoke** (macm1): reload a `_live` preset → full groove from bar 0, loops
|
||||
cleanly, no intro/breakdown; `_intro`/`_outro` unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing `_intro`/`_outro` arcs, the pattern families, or the per-voice
|
||||
mods/poses.
|
||||
- Any new live dynamics beyond the plateau's own internal variation.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- `_live` = plateau tiled across 64 bars (full loop, no intro/breakdown).
|
||||
- Transform applied post-`apply_colors`, live section only, via `to_sc(post=)`.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Matrix `_live` per-style variation (full loop that breathes)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending plan
|
||||
**Scope**: `sound_algo/data_only/matrix_presets/generate_presets.py` + `test_sections.py` + regenerated `*.matrix`. Refinement of the `_live` continuous loop.
|
||||
|
||||
## Context
|
||||
|
||||
`make_live_continuous` tiles the plateau across all 64 bars, so the `_live`
|
||||
loop is four identical 16-bar blocks (133/133/133/133 on techno_drive) — full
|
||||
but static. The user wants variation in the live loop, **adapted to each style**
|
||||
(per genre), while staying full (no voice drops, no intro/breakdown — those
|
||||
were the point of the previous fix).
|
||||
|
||||
## Decision (from brainstorming)
|
||||
|
||||
Keep full density; add per-style variation by **recolouring active cells** (the
|
||||
matrix colour 1-6 system changes the pattern a voice plays without removing it)
|
||||
+ **phrase-end fills**. The variation profile is keyed to the morceau's style.
|
||||
|
||||
## Design
|
||||
|
||||
### Style derivation — `LIVE_STYLE` (generate_presets.py)
|
||||
Explicit morceau -> style map (7 groups), default `"techno"`:
|
||||
- **techno**: techno_drive, detroit_soul, peak_drop, rave_stab, acid_journey
|
||||
- **psy**: dub_techno, psytrance_roll, minimal_hypno, hypnotic_arp
|
||||
- **trance**: trance_euphoria, melodic_builder, melodic_deep, progressive_lift, synthwave_drive
|
||||
- **breaks**: breakbeat, bass_science, jungle_amen, dnb_roller, footwork_chop, neuro_reese, tribal_perc
|
||||
- **electro**: electro_808, ebm_body, electro_funk
|
||||
- **industrial**: schranz_drive, industrial_grind, hardcore_punk
|
||||
- **ambient**: ambient_intro
|
||||
|
||||
### Variation profiles — `LIVE_VAR`
|
||||
Per style: `busy` voices recoloured in `busy_blocks` (16-bar block indices 0-3)
|
||||
to `busy_color`; `fill` voices' active cells set to colour 6 on the last 2 bars
|
||||
of every block. Colours chosen per voice kind — rhythmic voices use 2 (busier)
|
||||
or 4 (double-time); melodic voices use 3 (octave-up lift), since step-mode
|
||||
ignores stretch so 2/4 are inaudible on melodic voices.
|
||||
|
||||
| style | busy voices | busy_blocks | busy_color | fill voices |
|
||||
|-------|-------------|-------------|-----------|-------------|
|
||||
| techno | hats, perc | 1, 3 | 4 (double) | snare, clap, perc |
|
||||
| psy | acid, arp, reese | 1, 3 | 3 (octave) | perc |
|
||||
| trance | arp, lead | 2 | 3 (octave) | snare, clap |
|
||||
| breaks | snare, shaker | 1, 3 | 2 (busier) | snare, perc, tom |
|
||||
| electro | hats, clap | 1, 3 | 2 (busier) | clap, perc |
|
||||
| industrial | perc, tom | 1, 3 | 2 (busier) | kick, perc, tom |
|
||||
| ambient | bells, melody | 2 | 3 (octave) | (none) |
|
||||
|
||||
### `make_live_continuous(g, style="techno")`
|
||||
1. Tile the plateau (existing).
|
||||
2. `_live_variation(g, style)`:
|
||||
```python
|
||||
def _live_variation(g, style):
|
||||
prof = LIVE_VAR.get(style, LIVE_VAR["techno"])
|
||||
for blk in prof["busy_blocks"]:
|
||||
for v in prof["busy"]:
|
||||
vi = VI[v]
|
||||
for b in range(blk*16, blk*16+16):
|
||||
if g[vi][b] > 0: g[vi][b] = prof["busy_color"]
|
||||
for blk in range(BARS // 16):
|
||||
for v in prof["fill"]:
|
||||
vi = VI[v]
|
||||
for b in (blk*16+14, blk*16+15):
|
||||
if g[vi][b] > 0: g[vi][b] = 6
|
||||
return g
|
||||
```
|
||||
Only recolours active (`> 0`) cells -> density unchanged, voices never dropped.
|
||||
|
||||
### Wiring
|
||||
- `main()`: `post = (lambda g: make_live_continuous(g, LIVE_STYLE.get(name, "techno")))
|
||||
if sec == "live" else None`.
|
||||
- `test_sections.grid_of`: `G.make_live_continuous(g, G.LIVE_STYLE.get(name, "techno"))`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`test_sections.py`**: the density asserts (live full-from-start, no breakdown
|
||||
dip, live densest) STILL hold — recolouring keeps cells non-zero. NEW: assert
|
||||
the live is not four identical blocks (variation present) for every morceau
|
||||
except the ones whose profile is genuinely minimal — i.e. the live's 4 blocks
|
||||
are not all byte-equal (`len({tuple(row[i*16:i*16+16]) ...}) > 1` for some voice).
|
||||
- **SC**: `validate_presets.scd` -> 84/84 + DEFAULTS PASS (cells only recoloured;
|
||||
mods/poses/instruments untouched). Deterministic regen.
|
||||
- **Live smoke**: reload several `_live` of different genres -> each loops full but
|
||||
evolves in a style-appropriate way (techno double-time hats + fills; breaks
|
||||
snare rolls; ambient gentle bell octaves; etc.); intro/outro unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-morceau (vs per-style) bespoke variation; new voices; intro/outro changes.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- 7 style profiles; recolour-active + phrase-fills; full density preserved.
|
||||
- Rhythmic busy -> colour 2/4, melodic busy -> colour 3.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Matrix — autofill the name field on load (so SAUVER updates the loaded matrix)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending plan
|
||||
**Scope**: `sound_algo/data_only/matrix.scd` (engine feedback) + `web_realart/public/control/js/main.js` (handler). Follow-on to the save-persist + queued-load feature.
|
||||
|
||||
## Context
|
||||
|
||||
After loading a matrix (CHARGER or CHARGER SUIVANT), the `#matrix-name` input
|
||||
stays whatever the user last typed, so clicking SAUVER saves under the wrong/empty
|
||||
name instead of updating the matrix they just loaded. The user wants the name
|
||||
field to track the **actually-loaded** matrix so SAUVER updates it in place.
|
||||
|
||||
## Design (approved)
|
||||
|
||||
- **Engine**: emit `/matrix/loaded <name>` at the *actual* load moment — NOT from
|
||||
the shared `~matLoad`/`~matLoadFile` (that would spam during the boot autoload).
|
||||
Emit from the three user-facing load points:
|
||||
1. the `/matrix/load` OSCdef (immediate CHARGER),
|
||||
2. the queued branch of `~matWrapStep` (the queued load firing at the wrap),
|
||||
3. the not-playing immediate fallback in the `/matrix/loadnext` OSCdef.
|
||||
- **Web** (`main.js`): `on("/matrix/loaded", args)` sets
|
||||
`document.getElementById("matrix-name").value = String(args[0])`.
|
||||
|
||||
## Timing note
|
||||
|
||||
For CHARGER SUIVANT the name fills **when the swap happens** (at the wrap, via
|
||||
`~matWrapStep`), not when armed — so SAUVER never saves the current grid under a
|
||||
not-yet-loaded preset's name.
|
||||
|
||||
## Testing
|
||||
|
||||
- SC: no new unit (the emit is a one-liner on existing paths); covered by the
|
||||
live smoke. Existing `test_matnames.scd` + `validate_presets.scd` must still PASS.
|
||||
- Web: `node --check main.js` + the existing `node --test test/*.test.mjs` suite
|
||||
unchanged (29 pass).
|
||||
- Live smoke: CHARGER a preset → `#matrix-name` shows its name; arm SUIVANT →
|
||||
name updates only when it swaps at the wrap; SAUVER then overwrites that name.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Emitting `/matrix/loaded` from the boot autoload or TouchOSC-specific paths.
|
||||
- Any change to SAUVER itself (it already saves under `#matrix-name`).
|
||||
@@ -0,0 +1,102 @@
|
||||
# Matrix pinch refinements — decouple piano, 2-col menu, hold-to-fire + progress
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending spec review
|
||||
**Scope**: `data_only_viz/{finger_strike.py,pose_bridge.py,action_head_pub.py}`,
|
||||
`launcher/concert/launch_concert.sh`, `sound_algo/data_only/matrix.scd`,
|
||||
`sound_algo/data_only/matrix_presets/generate_presets.py`,
|
||||
`web_realart/public/control/{js/matrix-state.js,js/morceau-panel.js,index.html,control.css}`.
|
||||
|
||||
## Context
|
||||
|
||||
The pinch-triggered global actions shipped, but live use revealed four needs:
|
||||
the air-piano finger-strike rides along with the pinch detector; the 8 slots
|
||||
should read as two hand columns; a quick accidental pinch fires immediately;
|
||||
and there is no detection feedback in the menu.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Decouple**: a new `PINCH_ENABLE` env gates ONLY pinch; the air-piano
|
||||
finger-strike stays on `FINGER_PIANO` (off by default). (Already coded.)
|
||||
- **2-column menu**: gesture slots laid out as a left column (MG, slots 0-3) and
|
||||
a right column (MD, slots 4-7).
|
||||
- **Hold-to-fire, timed in SC**: a pinch must be HELD ≥ `~matPinchHoldMs` to
|
||||
fire its global action; released sooner → nothing. `data_only_viz` emits pinch
|
||||
ENGAGE/RELEASE edges; the SC engine does the timing (web↔SC channel already
|
||||
exists, plus a clock). Hold duration is a LIVE menu slider.
|
||||
- **Progress indicator**: while a pinch is held, the slot's menu cell fills
|
||||
0→1; full → fires (then clears on release).
|
||||
|
||||
## Design
|
||||
|
||||
### data_only_viz — emit pinch edges (not just engage)
|
||||
- `PinchEvent` gains `state: int` (1 = engage, 0 = release).
|
||||
- `PinchDetector.step`: emit `PinchEvent(hand, finger, state=1)` on the engage
|
||||
edge (existing) AND `PinchEvent(hand, finger, state=0)` on the release edge
|
||||
(the `ratio > ratio_off` branch). One event per transition.
|
||||
- `pose_bridge.send_pinch`: `/pose/pinch [0, hand, finger, state]`.
|
||||
- `action_head_pub._emit_pinch`: gated by `_pinch_enabled` (PINCH_ENABLE) —
|
||||
already done; it forwards every event (engage + release) unchanged.
|
||||
|
||||
### launcher
|
||||
- `export PINCH_ENABLE="${PINCH_ENABLE:-1}"` (replacing `FINGER_PIANO=1`; the
|
||||
air-piano stays off).
|
||||
|
||||
### SC engine (matrix.scd)
|
||||
- Slot = `hand*4 + (finger-1)` (0..7), as today.
|
||||
- `~matPinchHoldMs` (default 300), `~matPinchHeld` (Array[8] of engage-time or
|
||||
nil), `~matPinchFired` (Array[8] bool).
|
||||
- `\mat_ev_pinch` reworked: read `state = msg[4]`.
|
||||
- `state == 1` (engage): `~matPinchHeld[slot] = Main.elapsedTime`,
|
||||
`~matPinchFired[slot] = false`; fire the per-voice binding immediately
|
||||
(`~matPoseFire(~matEventPinch(hand,finger))`, unchanged).
|
||||
- `state == 0` (release): clear `~matPinchHeld[slot] = nil`,
|
||||
`~matPinchFired[slot] = false`, emit `/matrix/pinchprogress [slot, 0]`.
|
||||
- `~matPinchTick` (called ~30 Hz by a free-running `AppClock` routine started
|
||||
once at load): for each held slot, `prog = ((now - held)*1000 / holdMs).clip(0,1)`,
|
||||
emit `/matrix/pinchprogress [slot, prog]`; when `prog >= 1` and not yet fired,
|
||||
set fired + `~matFireGlobal(slot)`.
|
||||
- Hold-duration route: `/matrix/pinchhold [ms]` (set, clip 50..2000, echo) +
|
||||
`/matrix/pinchhold/get`. `~matPinchHoldMs` saved per `.matrix` (top-level
|
||||
field `pinchHoldMs`, default 300 on legacy) — generator emits it.
|
||||
|
||||
### Generator
|
||||
- `to_sc` emits `'pinchHoldMs': 300` (a top-level field; separate from
|
||||
`globalSettings` to avoid re-churning that arg layout).
|
||||
|
||||
### Web
|
||||
- `matrix-state.js`: `matPinchHoldMs` (default 300) + setter + persistence
|
||||
(`phms`); the 8 `GLOBAL_SLOTS` already exist.
|
||||
- `index.html` + `control.css`: render the 8 gesture slots in TWO columns
|
||||
(left = slots 0-3, right = 4-7). Add a **hold-duration slider** (→
|
||||
`/matrix/pinchhold`). Each gesture cell carries a progress fill element.
|
||||
- `morceau-panel.js`: lay the slots out as MG-column / MD-column; wire the hold
|
||||
slider; on `/matrix/pinchprogress [slot, prog]`, set that slot cell's fill
|
||||
width to `prog*100%` (and a brief "fired" flash at 1).
|
||||
|
||||
## Testing
|
||||
|
||||
- **data_only_viz** (`tests/test_action_head_finger_emit.py` + a pinch-edge
|
||||
test): `PINCH_ENABLE` gates pinch independent of `FINGER_PIANO` (done);
|
||||
`PinchDetector` emits a state=1 then a state=0 across an engage→release.
|
||||
- **SC** (`test_global_actions.scd`): rework group P — engage (state 1) does
|
||||
NOT fire immediately; after the held time crosses `~matPinchHoldMs`,
|
||||
`~matPinchTick` fires the mapped action; release (state 0) before the hold
|
||||
fires nothing; `/matrix/pinchhold` updates `~matPinchHoldMs`; `pinchHoldMs`
|
||||
save/load roundtrips.
|
||||
- **Python** (`test_global_actions.py`): every `.matrix` carries `pinchHoldMs: 300`.
|
||||
- **Web** (`matrix-state.test.mjs`): `matPinchHoldMs` default + persistence.
|
||||
- **Live smoke**: pinch-and-hold a finger → the cell fills, fires at the
|
||||
threshold; a quick pinch does nothing; the hold slider changes the required
|
||||
time live; air-piano silent; 2 columns MG/MD.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Live pinch SENSITIVITY (still env `PINCH_RATIO_ON/OFF`).
|
||||
- Per-voice pinch bindings keep firing on engage (no hold) — the hold is for
|
||||
the global actions only.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- `PINCH_ENABLE=1` (air-piano off); hold 300 ms (live slider, saved per
|
||||
morceau); 2 columns MG/MD; progress fill per cell.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Matrix global actions — pinch triggers (replace open/close hand)
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved (decisions made), pending spec review
|
||||
**Scope**: `sound_algo/data_only/matrix.scd`, `sound_algo/data_only/matrix_presets/generate_presets.py`,
|
||||
`web_realart/public/control/` (panel), `launcher/concert/launch_concert.sh`. No `data_only_viz` code change.
|
||||
|
||||
## Context
|
||||
|
||||
Phases 1-2 (shipped) trigger global matrix actions from **open/closed hand**
|
||||
(`~matHandFlip`, openness hysteresis) over 4 slots (LH/RH × open/closed), with
|
||||
per-morceau editable mapping + a transient engine + an action vocabulary +
|
||||
threshold/duration/kept-voices settings. The user wants the trigger to be
|
||||
**finger pinches** instead — 8 triggers (4 per hand) — replacing open/close.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Trigger** = finger pinch (thumb↔finger), via the existing `PinchDetector`
|
||||
in `data_only_viz` (`/pose/pinch [pid, hand, finger]`, hand 0/1 = screen
|
||||
L/R, finger 1-4 = index/middle/ring/pinky). **8 slots** = MG/MD × 4 fingers.
|
||||
- **Replace** open/close entirely: remove `~matHandFlip` global triggering +
|
||||
the open/close threshold settings.
|
||||
- **Pinch sensitivity** = env `PINCH_RATIO_ON` / `PINCH_RATIO_OFF` (set at
|
||||
launch; no runtime slider, no `data_only_viz` code change).
|
||||
- **Activation**: `FINGER_PIANO=1` in `launch_concert.sh` (enables the pinch
|
||||
detector; also enables the air-piano finger-strike detector, inert because
|
||||
no voice has a finger binding by default).
|
||||
- Everything else kept: transient engine, action vocabulary, transient
|
||||
duration + breakdown kept-voices settings, per-morceau mapping, save/load.
|
||||
- The Phase-3 matrix tempo slider rides along (one panel update, one deploy).
|
||||
|
||||
## Design
|
||||
|
||||
### Slot model
|
||||
8 slots, index = `hand * 4 + (finger - 1)`:
|
||||
`0..3` = MG index/majeur/annulaire/auriculaire, `4..7` = MD index/.../auriculaire.
|
||||
`~matGlobalActions` grows from 4 to 8 elements.
|
||||
|
||||
**Default mapping** (a spread of the vocabulary; user-adjustable):
|
||||
MG = `fill, drop, breakdown, evolve` ; MD = `muteDrums, muteMelo, washReverb, next`
|
||||
→ `[\fill, \drop, \breakdown, \evolve, \muteDrums, \muteMelo, \washReverb, \next]`.
|
||||
|
||||
### SC engine (`matrix.scd`)
|
||||
- **Pinch → global fire**: extend the existing `/pose/pinch` OSCdef
|
||||
(`\mat_ev_pinch`, which already fires per-voice `~matPoseFire`) to ALSO map
|
||||
`(hand, finger)` → slot `0..7` and call `~matFireGlobal.(slot)`. Per-voice
|
||||
pinch bindings keep working (unchanged); the global fire is additive.
|
||||
- **Remove open/close global triggering**: drop the two `~matHandFlip` calls
|
||||
from the `/pose/hands` OSCdef. Remove `~matHandFlip` + `~matHandState`
|
||||
(now dead). `/pose/hands` still writes the modulation cache (`lOpen`/`rOpen`
|
||||
etc. stay available as MOD sources in the per-voice editor — untouched).
|
||||
- **Settings trim**: `~matGlobalSettings` keeps `transientBars` +
|
||||
`breakdownKeep`; drop `openThresh`/`closeThresh`. Update
|
||||
`~matGlobalSettingsPush`, the `/matrix/globalsettings` set OSCdef, save/load,
|
||||
and the generator literal accordingly (arg layout becomes
|
||||
`[transientBars, keepN, keep0..]`).
|
||||
- **`/matrix/globalaction` slot range** 0-3 → 0-7. `~matGlobalActionsPush`
|
||||
pushes 8.
|
||||
- Save/load: `globalActions` is now 8 symbols; default 8-element on legacy.
|
||||
|
||||
### Generator (`generate_presets.py`)
|
||||
- `globalActions` literal → 8 symbols (the default above).
|
||||
- `globalSettings` literal → `( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] )`
|
||||
(drop the two thresholds).
|
||||
|
||||
### Web (`matrix-state.js` + `morceau-panel.js`)
|
||||
- `GLOBAL_SLOTS` → 8 entries: `Pince MG index / majeur / annulaire / auriculaire`,
|
||||
then `Pince MD …`. `matGlobalActions` default → 8 elements.
|
||||
- `matGlobalSettings` → `{ transientBars, breakdownKeep }` (drop thresholds);
|
||||
`parseGlobalSettings`/`sendSettings` follow the new arg layout.
|
||||
- Réglages group: remove the two threshold sliders; keep the transient-duration
|
||||
slider + the kept-voices grid.
|
||||
- Add the **matrix tempo slider** (`#matrix-bpm` → `/matrix/bpm`, from Phase 3).
|
||||
|
||||
### Launcher (`launch_concert.sh`)
|
||||
- `export FINGER_PIANO=1` (and optionally `PINCH_RATIO_ON`/`PINCH_RATIO_OFF`
|
||||
defaults) so the pose pipeline emits `/pose/pinch`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **SC** (`test_global_actions.scd`): replace the open/close harness groups
|
||||
(G, J open/close part) with a pinch-fire group — feed a synthetic
|
||||
`/pose/pinch [pid, hand, finger]` (via the OSCdef func) and assert the
|
||||
mapped slot's action fired (transient kind / loaded name); assert slot index
|
||||
math `hand*4+(finger-1)`; settings save/load now round-trips
|
||||
`transientBars`/`breakdownKeep` only.
|
||||
- **Python** (`test_global_actions.py`): `globalActions` = 8 symbols literal;
|
||||
`globalSettings` literal without thresholds; 84/84.
|
||||
- **Web** (`matrix-state.test.mjs`): `GLOBAL_SLOTS` length 8 + labels;
|
||||
default 8-element mapping; `parseGlobalSettings` new layout.
|
||||
- **Live smoke**: `FINGER_PIANO=1`, pinch each finger L/R → the mapped global
|
||||
action fires; re-map a slot + save/reload; tempo slider works; open/close no
|
||||
longer triggers anything.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Runtime pinch-sensitivity control (env only).
|
||||
- Per-voice pinch bindings (unchanged; still available in the voice editor).
|
||||
- Removing `lOpen`/`rOpen` as per-voice MOD sources (kept).
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- 8 slots, replace open/close, env sensitivity, FINGER_PIANO=1, default mapping
|
||||
fill/drop/breakdown/evolve · muteDrums/muteMelo/washReverb/next.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Matrix load/save — list persistence fix + queued "load next"
|
||||
|
||||
**Date**: 2026-06-30
|
||||
**Status**: design approved, pending implementation plan
|
||||
**Scope**: `sound_algo/data_only/matrix.scd` (engine) + `web_realart/public/control/` (web UI) + tests. Two related changes to the matrix persistence bar. No preset/generator change.
|
||||
|
||||
## Context
|
||||
|
||||
The matrix control surface has a persistence bar (`.matrix-persist`): a name
|
||||
input, **SAUVER** (`/matrix/save`), a preset `<select>`, **CHARGER** (immediate
|
||||
`/matrix/load`), and a refresh. Two gaps:
|
||||
|
||||
1. **SAUVER bug** — user saves disappear from the CHARGER list after an engine
|
||||
restart. Root cause: `~matNames` (matrix.scd ~494) serves the *user* dir
|
||||
(`~matDir = ~/.config/av-live/matrices`) from the **in-memory `~matSavedNames`
|
||||
Set**, which is reset to empty on boot (~66). Files on disk (e.g. `saisia.matrix`)
|
||||
are never re-scanned, so they are absent from `/matrix/list` until re-saved.
|
||||
The disk-scan branch (`ls` + tmpfile + `systemCmd`) used for other dirs works
|
||||
headless — the Set was an over-cautious workaround that broke persistence.
|
||||
2. **No "load next"** — CHARGER swaps the grid immediately, cutting the current
|
||||
pattern mid-flight. The user wants an Ableton-style queued load that fires at
|
||||
the loop boundary.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- Queued load triggers at the **loop wrap** (the `~matPlay` point already used by
|
||||
ÉVOLUE — playhead jumped backward at the end of `~matBars`/the loop region).
|
||||
- The **SAUVER bug is a real fix**: `~matNames` for the user dir scans the disk
|
||||
(∪ `~matSavedNames` for same-session saves).
|
||||
- If the playhead is **not playing**, "SUIVANT" falls back to an immediate load
|
||||
(otherwise nothing would happen).
|
||||
|
||||
## Part A — save-list persistence fix
|
||||
|
||||
`~matNames` (matrix.scd): for `dir == ~matDir`, scan the directory from disk with
|
||||
the same `ls -1 <dir> > tmp; read; filter .matrix` mechanism already used for
|
||||
other dirs, then **union with `~matSavedNames`** (so a just-saved name appears
|
||||
even on the rare chance the scan misses it). For non-user dirs: unchanged. Result
|
||||
is de-duplicated (already `.as(Set)` in `~matListPush`).
|
||||
|
||||
## Part B — queued "load next"
|
||||
|
||||
### Engine (matrix.scd)
|
||||
- New state `~matQueuedLoad` (nil = nothing armed, else a preset name string).
|
||||
- New OSCdef `/matrix/loadnext <name>`:
|
||||
- if playing (`~lp[\matPlaying] == true`): set `~matQueuedLoad = name`;
|
||||
emit `/matrix/queued <name>` (UI shows armed state).
|
||||
- else: `~matLoad.(name)` immediately (fallback) + emit `/matrix/queued ""`.
|
||||
- The `~matPlay` playhead Routine already detects the wrap at line ~421-422
|
||||
(`~lp[\matBar] < ~matPrevBar` after `~matNextBar`), where ÉVOLUE mutates.
|
||||
Replace that single evolve `.if` with: on wrap, **if `~matQueuedLoad.notNil`**
|
||||
→ `~matLoad.(~matQueuedLoad)`, `~matQueuedLoad = nil`, emit `/matrix/queued ""`,
|
||||
and **skip this cycle's evolve** (freshly loaded preset plays one clean cycle);
|
||||
**else** the existing `~matEvolve.if({ ~matEvolveStep.() })`.
|
||||
- **Re-entrancy caveat** (verify in the plan): `~matLoad`/`~matLoadFile` is called
|
||||
from *inside* the running Routine, so it must only swap the grid/instruments/base
|
||||
data and **must not restart the playhead Routine** (no `~matPlay.()` from the
|
||||
load path) — otherwise it kills the routine it runs in. If `~matLoad` restarts
|
||||
playback, factor a grid-only load for the wrap hook.
|
||||
- The loaded preset becomes the new evolve base (via `~matLoad`'s base reset), so
|
||||
ÉVOLUE keeps working on it from the next wrap.
|
||||
|
||||
### Web (`web_realart/public/control/`)
|
||||
- `index.html`: a **SUIVANT** button in `.matrix-persist` after CHARGER.
|
||||
- `main.js`: SUIVANT click → `send("/matrix/loadnext", <select>.value)`. An
|
||||
`on("/matrix/queued", args)` handler sets/clears an `armed` CSS class on the
|
||||
SUIVANT button and shows the queued name (e.g. button label or a small badge);
|
||||
empty string clears it.
|
||||
- `control.css`: an `.armed` style (e.g. pulsing/highlight) for the SUIVANT button.
|
||||
|
||||
## Testing
|
||||
|
||||
- **SC** (`test_matrix.scd` or a focused snippet, `sclang` headless):
|
||||
- Part A: write a dummy `<x>.matrix` into a temp `~matDir`, call
|
||||
`~matNames.(~matDir)`, assert `\x` is included (disk-scanned, not just the Set).
|
||||
- Part B: arm `~matQueuedLoad`, simulate a wrap, assert the queued preset loads
|
||||
and `~matQueuedLoad` is nil afterward; arm while not playing → immediate load.
|
||||
- **Web** (`node --test web_realart/public/control/test/*.test.mjs`): SUIVANT
|
||||
click sends `/matrix/loadnext` with the selected name; `/matrix/queued <name>`
|
||||
sets the armed class, `/matrix/queued ""` clears it.
|
||||
- **Live smoke** (macm1): save a preset → restart engine → it still lists +
|
||||
loads (Part A); arm SUIVANT during playback → swaps at the next wrap (Part B).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing CHARGER (immediate) or SAUVER button behaviour beyond the list fix.
|
||||
- Adjustable quantization (1/4/8/16 bars) — wrap-only for now.
|
||||
- Per-voice / partial queued loads.
|
||||
|
||||
## Decided defaults
|
||||
|
||||
- Queued load fires at the loop wrap; skips that cycle's evolve.
|
||||
- "SUIVANT" while stopped = immediate load.
|
||||
- `~matNames` user dir = disk scan ∪ in-memory Set.
|
||||
@@ -0,0 +1,96 @@
|
||||
# data_only_viz refactor audit — reference for chantiers 1-6
|
||||
|
||||
**Date:** 2026-07-02 (architect analysis, validated file:line)
|
||||
**Status:** approved by user ("enchaine 1 a 6"), execution order 1 -> 2 -> 5 -> 3 -> 4 -> 6
|
||||
|
||||
## Live bugs found
|
||||
|
||||
- **B1 ARKit skeleton freeze**: `renderer.py:323-325` enables `use_arkit` on
|
||||
`bool(s.persons_arkit_2d)` with no freshness test and bypasses
|
||||
`pose_alive()`. `persons_arkit_2d_t` is written (`iphone_usb_source.py`)
|
||||
but never read. USB/app drop during a show = skeleton frozen on screen.
|
||||
- **B2 unreachable keyboard**: NSEvent monitors (ESC, space=concertNext,
|
||||
keymaps) are installed at `main.py:448-460` AFTER the YOLO fallback in
|
||||
`_start_pose_worker` — all normal paths `return` before (l.369, 384,
|
||||
399, 413, 440), and without `--pose` the function is never called.
|
||||
Move the hooks into `applicationDidFinishLaunching_`.
|
||||
- **B3 persons_smplx contract broken**: `action_head_pub._read_sources`
|
||||
reads dicts (`p.get("pid")`, `p.get("v3d")`, `action_head_pub.py:277-278`)
|
||||
but `multi_hmr_worker.py:790-802` writes `SMPLXPerson` dataclasses
|
||||
(field `vertices_3d`, no `.get`). Under `--multi-hmr`: AttributeError
|
||||
every tick, swallowed by `LOG.exception` at 30 Hz. Tests mask it
|
||||
(`tests/test_action_head_pub.py:32` builds dicts). Latent in concert
|
||||
mode (iphone-usb).
|
||||
- **B4 tearing race**: `iphone_usb_source.py:163-169` mutates
|
||||
`persons_arkit_joints[pid]` IN PLACE under lock, but `multi.py:471-474`
|
||||
shallow-copies the dict then reads the array outside the lock, and
|
||||
`ArkitFuse.apply` (`pose_filter.py:685-699`) reads `arr[i,:]` outside
|
||||
the lock -> joints mixing two frames. Fix: write a NEW array each frame
|
||||
(same pattern as the 2D path, `iphone_usb_source.py:174-179`).
|
||||
- **Hot-lock staging**: `renderer._update_uniforms` calls
|
||||
`_update_skeleton` + `_update_mesh` INSIDE `with s.lock():`
|
||||
(`renderer.py:260-301`). At 60 fps, per-segment Python loops (up to
|
||||
~2700 face tessellation segs/person) block MultiWorker, IphoneUSBSource
|
||||
and ActionHeadPublisher on the same RLock. Dominant jitter source.
|
||||
Pattern fix: snapshot refs under lock, build buffers outside.
|
||||
- Hot-loop logs: `renderer.py:309-314` (INFO every 120 frames, reads
|
||||
persons_* OUTSIDE the lock), `multi.py:521-522`.
|
||||
- **4x hand-ordering duplication**: `finger_strike.order_hands_by_cx`
|
||||
(l.27-41) == `FingerStrikeDetector._slot_hands` (l.73-87, verbatim
|
||||
copy); `hand_features.py:94` (cx sort); `renderer.py` panels
|
||||
(mirror-aware). Renderer routes by chirality; gesture detectors do NOT
|
||||
(L/R swap when hands cross).
|
||||
- Per-frame allocations: new PoseKp per keypoint per smoothing stage
|
||||
(`multi.py:71-79`, `pose_filter.py:833,893`); `frame.copy()` per read
|
||||
(`iphone_usb_source.py:238`, ~0.9 MB/tick); `pts_xy` 478-tuple list per
|
||||
face per frame.
|
||||
- **pose_bridge sends 1 UDP datagram per keypoint**: 68 face + 42 hands
|
||||
+ 33 body3d > 4000 sendto/s at 30 Hz (`pose_bridge.py:249-260,
|
||||
282-291, 318-326`). OSC bundles would divide syscalls by ~50.
|
||||
- **iphone-usb still loads MediaPipe**: `multi.py:323-368` loads the 3
|
||||
landmarkers (+ network `_ensure_model` l.82-90) even though inference
|
||||
is skipped (`multi.py:416-418`) in the default concert mode.
|
||||
- **Env sprawl**: 56 `os.environ` reads across 12 files (main.py 13,
|
||||
action_head_pub 13, multi 7...). Proposal: single frozen-dataclass
|
||||
`config.py` read at boot, injected.
|
||||
- Dead/near-dead code: YOLO/COCO fallback (`renderer.py:64-70, 452-456`;
|
||||
`state.pose_kp`; `pose.py`), `holistic.py`, `detrpose.py`,
|
||||
`coreml_pose.py`, `nlf_worker.py`, 3 scaffold .md. Backend cascade in
|
||||
`main.py:374-446` only serves 2 real paths (MediaPipe Multi,
|
||||
iphone-usb). KEEP the Mac-webcam MediaPipe fallback (only plan B if
|
||||
the iPhone dies in a show).
|
||||
- Missing tests: action_head_pub <-> real SMPLXPerson contract; ARKit
|
||||
freshness in renderer; multi.py tick integration in iphone-usb mode.
|
||||
|
||||
## Chantiers (execution order 1 -> 2 -> 5 -> 3 -> 4 -> 6)
|
||||
|
||||
1. **Correctifs live** (S): (a) ARKit 2D freshness gate in renderer via
|
||||
`persons_arkit_2d_t` (<1 s); (b) adapt `action_head_pub.py:276-309`
|
||||
to SMPLXPerson + test with the real dataclass; (c) move keyboard
|
||||
hooks into `applicationDidFinishLaunching_`.
|
||||
2. **No in-place mutations** (S): `iphone_usb_source.py:163-169` writes
|
||||
a fresh array (copy the 2D pattern); kills the tearing race.
|
||||
3. **HandRouter** (M): module `hand_slots.py` (iPhone chirality first,
|
||||
cx fallback) consumed by finger_strike, hand_features, renderer
|
||||
panels; removes 4 duplications and L/R swap. Re-test gestures live.
|
||||
4. **Renderer snapshot-out-of-lock** (M): `_update_uniforms` copies refs
|
||||
under lock, builds segs/tris outside; move the l.309-314 log behind
|
||||
`isEnabledFor(DEBUG)` and inside the lock or on snapshotted data.
|
||||
5. **Central config.py** (M) + gate MediaPipe loading under iphone-usb
|
||||
(`multi.py:341-368` conditional on `not iphone_usb`).
|
||||
6. **Degraissage** (L, 4 independent parts): (a) extract backend factory
|
||||
from main.py; (b) move ActionHeadPublisher out of
|
||||
MultiWorker.__init__; (c) delete YOLO/COCO/holistic/detrpose dead
|
||||
paths (KEEP webcam MediaPipe); (d) OSC bundles in pose_bridge.
|
||||
|
||||
## Non-negotiable invariants (all chantiers)
|
||||
|
||||
- Concert mode (`--pose --iphone-usb`) must keep working end-to-end:
|
||||
video + ARKit skeleton overlay + hand panels + /pose/* OSC to SC.
|
||||
- Gesture semantics (pinch gates, strikes) unchanged unless the chantier
|
||||
says otherwise (chantier 3 changes SLOTTING only, not detection).
|
||||
- Keep Mac-webcam MediaPipe fallback path alive.
|
||||
- All existing test suites stay green:
|
||||
`uv run pytest tests/ -v` from data_only_viz/.
|
||||
- Commit style: subject <= 50 chars, no AI attribution, no underscore in
|
||||
scope, one commit per coherent step.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Hand display cleanup + side panels — design
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Status:** approved
|
||||
**Scope:** `data_only_viz/iphone_usb_source.py`, `data_only_viz/state.py`, `data_only_viz/renderer.py`, new `data_only_viz/hand_display.py`, tests
|
||||
|
||||
## Problem
|
||||
|
||||
During the live pinch validation the hand overlay was unusable:
|
||||
ghost hands (false Vision detections drawn at full confidence), erratic
|
||||
spike segments from noisy landmarks, and filled mesh triangles cluttering
|
||||
the hands. Root cause found: the iPhone TAG_HANDS wire carries a real
|
||||
per-landmark Vision confidence (in the z slot) and a per-hand chirality
|
||||
byte, but `_decode_hands` discards both — every landmark is stored with
|
||||
`c=1.0` and chirality is skipped, so nothing downstream can filter.
|
||||
|
||||
Goal: keep the skeleton overlay on the video, remove the artifacts, and
|
||||
add two side panels showing the left hand (left edge) and right hand
|
||||
(right edge) front-view — a live close-up of exactly what the pinch
|
||||
detector sees.
|
||||
|
||||
## Design
|
||||
|
||||
### A. Decode: expose confidence + chirality (`iphone_usb_source.py`, `state.py`)
|
||||
|
||||
- `_decode_hands` stores the wire z value (Vision confidence) into
|
||||
`PoseKp.c`, clamped to [0, 1]. `.z` keeps the raw wire value
|
||||
(back-compat: finger-strike OSC reads z).
|
||||
- The chirality byte is no longer skipped: `_decode_hands` returns
|
||||
`(hands, chirality)` where `chirality[i]` is 0=left, 1=right.
|
||||
- New state field `persons_hands_chirality: list[int]`, written under
|
||||
the same lock wherever TAG_HANDS updates `persons_hands_iphone` /
|
||||
`persons_hands`.
|
||||
|
||||
### B. Overlay cleanup (`renderer.py` + new `hand_display.py`)
|
||||
|
||||
Pure geometry helpers in `hand_display.py` (unit-testable, no Metal):
|
||||
|
||||
- `hand_size(kp) -> float` — wrist(0) to middle-MCP(9) distance.
|
||||
- `hand_plausible(kp, conf_min=0.3, size_min=0.02, size_max=0.5) -> bool`
|
||||
— False when size out of range (ghost blobs) or mean landmark
|
||||
confidence < conf_min (Vision ghosts). MediaPipe hands keep c=1.0 so
|
||||
the confidence part is neutral for them.
|
||||
- `segment_ok(A, B, size, conf_min=0.3, max_bone_ratio=1.2) -> bool`
|
||||
— False when either endpoint confidence < conf_min or the bone is
|
||||
longer than max_bone_ratio × hand size (erratic spikes).
|
||||
|
||||
Renderer changes:
|
||||
- `_update_skeleton` hands loop: skip implausible hands entirely; skip
|
||||
bad segments; push with real `min(A.c, B.c)` instead of hardcoded 1.0.
|
||||
- `_update_mesh`: remove the HAND_TRIANGLES block — hands are wireframe
|
||||
only. Body and face mesh unchanged.
|
||||
|
||||
### C. Side panels front view (`hand_display.py` + `renderer.py`)
|
||||
|
||||
- Two square insets, side 30% of view height, vertically centered,
|
||||
anchored to the left and right edges (2% margin), with a thin border
|
||||
frame.
|
||||
- Left panel = left hand, right panel = right hand, selected by Vision
|
||||
chirality (`persons_hands_chirality`); fallback when chirality is
|
||||
missing (MediaPipe path): order by screen cx (mirror-aware).
|
||||
- Panel content: the hand's 21 landmarks normalized to their bbox
|
||||
(aspect preserved, 10% inner margin), drawn as the HAND_CONNECTIONS
|
||||
wireframe. X is flipped to match the mirrored video so on-screen
|
||||
motion direction is consistent.
|
||||
- Same plausibility gate as the overlay; a panel goes empty the moment
|
||||
its hand is absent or implausible (no freeze).
|
||||
- Helper `panel_segments(kp, side, bones, mirror) -> list[(ax,ay,bx,by)]`
|
||||
returns normalized [0,1] screen-space segments; `panel_frame(side)`
|
||||
returns the 4 border segments. The renderer pushes them through a
|
||||
screen-space push that does NOT re-apply the video mirror.
|
||||
- Stable colors: reuse the pid palette with fixed pids (left panel pid 5,
|
||||
right panel pid 6), frame at conf 1.0.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Fixing the "stale hands" freeze when the iPhone app stops sending
|
||||
TAG_HANDS frames (not observed as a problem; app sends count=0).
|
||||
- Pinch-state visualization inside the panels (engaged finger highlight)
|
||||
— possible follow-up.
|
||||
- MediaPipe hand mesh/topology changes beyond removing HAND_TRIANGLES.
|
||||
|
||||
## Testing
|
||||
|
||||
- Decode: TAG_HANDS payload with known conf/chirality round-trips into
|
||||
`.c` and the chirality list; count=0 yields `([], [])`.
|
||||
- Helpers: ghost (tiny/huge/low-conf) hands rejected; spike segment
|
||||
rejected; plausible hand accepted.
|
||||
- Panels: bbox normalization preserves aspect; left/right anchoring;
|
||||
mirror flip; empty output for implausible hand.
|
||||
- Renderer paths are exercised live (visual validation on macm1).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Pinch open-hand gate + debounce — design
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Status:** approved (option A)
|
||||
**Scope:** `data_only_viz/finger_strike.py`, `data_only_viz/action_head_pub.py`, tests, launcher docs
|
||||
|
||||
## Problem
|
||||
|
||||
Finger pinch drives the matrix global actions (8 hold-to-fire slots) and
|
||||
per-voice pinch bindings, but it was disabled by default (`1491aef`,
|
||||
`PINCH_ENABLE=0`) because it fired accidentally during full-body play.
|
||||
|
||||
Root cause: `PinchDetector` only tests "thumb tip near a fingertip". A
|
||||
relaxed hand or a fist during play naturally has the thumb close to the
|
||||
index/middle tip, so the ratio drops below `ratio_on` (0.45) and a false
|
||||
engage fires. The closest-wins margin (`16cd6a3`) rejects ambiguity
|
||||
*between* fingers but not the fist itself. At full-body distance the
|
||||
hand is small in frame, so Vision landmark jitter adds spurious
|
||||
threshold crossings.
|
||||
|
||||
Goal: pinch usable at any distance during the set, without false
|
||||
positives from relaxed hands/fists.
|
||||
|
||||
## Design
|
||||
|
||||
Two new gates inside `PinchDetector.step()`, keeping the existing
|
||||
ratio_on/ratio_off/margin logic unchanged.
|
||||
|
||||
### 1. Open-hand extension gate
|
||||
|
||||
A deliberate pinch is performed with an open hand: thumb + target
|
||||
finger touch, the other fingers stay extended. A fist or relaxed hand
|
||||
has everything curled.
|
||||
|
||||
- Per finger, compute an extension ratio: `dist(tip, wrist) / size`
|
||||
where `size` is the existing wrist→middle-MCP hand size.
|
||||
- A finger counts as *extended* when its extension ratio exceeds
|
||||
`ext_ratio` (default **1.35**; extended fingers measure ~1.6–1.9,
|
||||
curled ~0.8–1.1).
|
||||
- The winner finger may engage only if at least `ext_min` (default
|
||||
**2**) of the 3 non-pinching fingers (thumb and winner excluded) are
|
||||
extended.
|
||||
|
||||
This kills the fist/relaxed-hand false positive — the dominant failure
|
||||
mode — without touching detection sensitivity for the pinch itself.
|
||||
|
||||
### 2. Engage debounce
|
||||
|
||||
- The winner must satisfy all conditions (ratio_on, margin, extension
|
||||
gate, refractory) for `debounce_frames` consecutive `step()` calls
|
||||
(default **3**, ~100 ms at 30 Hz) before the engage event is emitted.
|
||||
- A counter per (slot, finger) increments on qualifying frames and
|
||||
resets to zero on any non-qualifying frame or when another finger is
|
||||
the winner.
|
||||
- **Release stays immediate** (unchanged): the SC hold-to-fire progress
|
||||
(`/matrix/pinchprogress`) depends on prompt release edges. The
|
||||
debounce delays engage by ~100 ms, which only shifts the hold window,
|
||||
it does not break it.
|
||||
|
||||
### Parameters / env vars
|
||||
|
||||
Read in `action_head_pub.py` next to the existing pinch env vars:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `PINCH_EXT_RATIO` | `1.35` | tip↔wrist / hand-size above which a finger is extended |
|
||||
| `PINCH_EXT_MIN` | `2` | min extended non-pinching fingers required to engage |
|
||||
| `PINCH_DEBOUNCE_FRAMES` | `3` | consecutive qualifying frames before engage |
|
||||
|
||||
`PINCH_EXT_MIN=0` disables the extension gate; `PINCH_DEBOUNCE_FRAMES=1`
|
||||
disables the debounce — both gates are individually defeatable for
|
||||
live tuning without code changes.
|
||||
|
||||
Constructor: `PinchDetector(__init__)` gains `ext_ratio`, `ext_min`,
|
||||
`debounce_frames` keyword args with the defaults above, so existing
|
||||
tests and call sites stay valid.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Wrist-velocity stability gate (option C) — add later only if false
|
||||
positives survive in live testing.
|
||||
- Threshold changes to `ratio_on`/`ratio_off`/`margin`.
|
||||
- FINGER_PIANO strike detector — untouched.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Implement + unit tests green.
|
||||
2. Live validation on macm1 with `PINCH_ENABLE=1` (manual gate, user
|
||||
confirms no false engages during full-body play and deliberate
|
||||
pinches still fire).
|
||||
3. Only after live validation: flip `PINCH_ENABLE` default back to `1`
|
||||
in `launcher/concert/launch_concert.sh` (separate commit) and
|
||||
document the new env vars in the root `CLAUDE.md` env table.
|
||||
|
||||
## Testing
|
||||
|
||||
Extend `data_only_viz/tests/test_finger_strike.py`:
|
||||
|
||||
- **Fist rejection**: all fingertips near wrist and thumb near index →
|
||||
no engage ever, even after many frames.
|
||||
- **Open-hand pinch**: thumb touches index, middle/ring/pinky extended
|
||||
→ engage fires exactly once after `debounce_frames` frames.
|
||||
- **Jitter rejection**: qualifying pose for 2 frames, then open → no
|
||||
engage (below debounce).
|
||||
- **Immediate release**: after engage, opening the pinch emits release
|
||||
on the very next frame.
|
||||
- **Gate defeat**: `ext_min=0, debounce_frames=1` reproduces current
|
||||
behavior (regression guard for existing tests).
|
||||
@@ -2,7 +2,6 @@ import ARKit
|
||||
import AVLiveWire
|
||||
import Combine
|
||||
import Foundation
|
||||
import RealityKit
|
||||
import SwiftUI
|
||||
import Vision
|
||||
|
||||
@@ -53,8 +52,6 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
private let bodyParents: [Int] =
|
||||
ARSkeletonDefinition.defaultBody3D.parentIndices
|
||||
|
||||
let arView = ARView(frame: .zero)
|
||||
|
||||
nonisolated static func thermalString(
|
||||
_ s: ProcessInfo.ThermalState) -> String {
|
||||
switch s {
|
||||
@@ -68,12 +65,16 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
arView.session = session
|
||||
arView.session.delegate = self
|
||||
arView.environment.background = .color(.black)
|
||||
arView.debugOptions = []
|
||||
session.delegate = self
|
||||
usb.onState = { [weak self] s in
|
||||
Task { @MainActor in self?.usbState = s }
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let was = self.usbState
|
||||
self.usbState = s
|
||||
if s == .connected && was != .connected {
|
||||
self.publishTopology()
|
||||
}
|
||||
}
|
||||
}
|
||||
videoEncoder.onPayload = { [weak self] payload in
|
||||
Task { @MainActor in
|
||||
@@ -164,7 +165,7 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
self.videoEncoder.encode(img, pts: t)
|
||||
}
|
||||
|
||||
// Vision hands+face at ~15 fps, off-main, drop if busy.
|
||||
// Vision hands at ~15 fps, off-main, drop if busy.
|
||||
if t - self.lastVisionTime >= 1.0 / 15.0 {
|
||||
self.visionAttempts += 1
|
||||
if self.visionBusy {
|
||||
@@ -172,7 +173,9 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
} else {
|
||||
self.lastVisionTime = t
|
||||
self.visionBusy = true
|
||||
let buf = img // CVPixelBuffer captured for the worker
|
||||
// CVPixelBuffer is non-Sendable; this is a deliberate
|
||||
// single-owner handoff to the Vision worker queue.
|
||||
nonisolated(unsafe) let buf = img
|
||||
self.visionQueue.async {
|
||||
self.runVision(on: buf, timestamp: t)
|
||||
Task { @MainActor in self.visionBusy = false }
|
||||
@@ -190,7 +193,8 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
var firstBody: ARBodyAnchor?
|
||||
for anchor in frame.anchors {
|
||||
guard let body = anchor as? ARBodyAnchor else { continue }
|
||||
self.publishUSB(pid: count, timestamp: t, body: body)
|
||||
self.publishUSB(pid: count, timestamp: t, body: body,
|
||||
camera: frame.camera)
|
||||
if count == 0 { firstBody = body }
|
||||
count += 1
|
||||
}
|
||||
@@ -247,17 +251,16 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
/// indices without re-reading the ARKit skeleton definition.
|
||||
var bodyParentIndices: [Int] { bodyParents }
|
||||
|
||||
// MARK: - Vision hand + face capture
|
||||
// MARK: - Vision hand capture
|
||||
|
||||
nonisolated func runVision(on pixelBuffer: CVPixelBuffer,
|
||||
timestamp t: TimeInterval) {
|
||||
let handReq = VNDetectHumanHandPoseRequest()
|
||||
handReq.maximumHandCount = 2
|
||||
let faceReq = VNDetectFaceLandmarksRequest()
|
||||
let handler = VNImageRequestHandler(
|
||||
cvPixelBuffer: pixelBuffer, orientation: .right, options: [:])
|
||||
let t0 = Date()
|
||||
try? handler.perform([handReq, faceReq])
|
||||
try? handler.perform([handReq])
|
||||
let ms = Date().timeIntervalSince(t0) * 1000.0
|
||||
Task { @MainActor in
|
||||
self.visionMs = self.visionMs == 0
|
||||
@@ -290,38 +293,18 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
isRight: obs.chirality == .right, points: p))
|
||||
}
|
||||
|
||||
// ---- face (most prominent) ----
|
||||
var facePayload: FacePayload?
|
||||
if let face = (faceReq.results ?? []).first,
|
||||
let all = face.landmarks?.allPoints {
|
||||
let bb = face.boundingBox
|
||||
let pts = all.normalizedPoints.map { np -> SIMD2<Float> in
|
||||
// landmark points are relative to the face bbox.
|
||||
let x = bb.origin.x + Double(np.x) * bb.size.width
|
||||
let y = bb.origin.y + Double(np.y) * bb.size.height
|
||||
return SIMD2(Float(x), Float(1.0 - y))
|
||||
}
|
||||
facePayload = FacePayload(
|
||||
confidence: face.confidence, points: pts)
|
||||
}
|
||||
|
||||
let handsData = HandsPayload(hands: handsOut).encoded()
|
||||
let faceData = facePayload?.encoded()
|
||||
Task { @MainActor in
|
||||
guard self.usbState == .connected else { return }
|
||||
self.usb.send(tag: .hands, pid: -1,
|
||||
timestamp: t, payload: handsData)
|
||||
if let faceData {
|
||||
self.usb.send(tag: .face, pid: -1,
|
||||
timestamp: t, payload: faceData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - USB skeleton
|
||||
|
||||
private func publishUSB(pid: Int, timestamp: TimeInterval,
|
||||
body: ARBodyAnchor) {
|
||||
body: ARBodyAnchor, camera: ARCamera) {
|
||||
guard usbState == .connected else { return }
|
||||
let skeleton = body.skeleton
|
||||
let transforms = skeleton.jointModelTransforms
|
||||
@@ -339,12 +322,42 @@ final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
|
||||
pid: Int16(clamping: pid),
|
||||
timestamp: timestamp,
|
||||
payload: payload.encoded())
|
||||
|
||||
// 2D projected skeleton: viewport-normalized [0,1] coords per joint.
|
||||
var p2 = Skeleton2DPayload()
|
||||
let orient = currentInterfaceOrientation()
|
||||
for i in 0..<min(Skeleton2DPayload.jointCount, transforms.count) {
|
||||
let w = root * transforms[i]
|
||||
let p3 = SIMD3<Float>(w.columns.3.x, w.columns.3.y, w.columns.3.z)
|
||||
let proj = camera.projectPoint(p3,
|
||||
orientation: orient,
|
||||
viewportSize: viewportSize)
|
||||
let nx = Float(proj.x / max(viewportSize.width, 1))
|
||||
let ny = Float(proj.y / max(viewportSize.height, 1))
|
||||
if nx.isFinite && ny.isFinite {
|
||||
p2.points[i] = SIMD2<Float>(nx, ny)
|
||||
p2.valid[i] = skeleton.isJointTracked(i)
|
||||
}
|
||||
}
|
||||
usb.send(tag: .skeleton2D,
|
||||
pid: Int16(clamping: pid),
|
||||
timestamp: timestamp,
|
||||
payload: p2.encoded())
|
||||
}
|
||||
|
||||
/// Send the ARKit body joint names + parent indices once, so the Mac
|
||||
/// renders bones generically. Static data — available before any body
|
||||
/// is tracked, so it is safe to emit right after connect.
|
||||
@MainActor
|
||||
private func publishTopology() {
|
||||
guard usbState == .connected else { return }
|
||||
let def = ARSkeletonDefinition.defaultBody3D
|
||||
let names = def.jointNames
|
||||
let parents = def.parentIndices.map { Int16(clamping: $0) }
|
||||
let payload = TopologyPayload(jointNames: names, parents: parents)
|
||||
usb.send(tag: .topology, pid: -1,
|
||||
timestamp: lastFrameTime,
|
||||
payload: payload.encoded())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ARViewContainer: UIViewRepresentable {
|
||||
@ObservedObject var session: ARBodySession
|
||||
func makeUIView(context: Context) -> ARView { session.arView }
|
||||
func updateUIView(_ uiView: ARView, context: Context) {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import SwiftUI
|
||||
import ARKit
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@@ -88,7 +87,7 @@ struct ContentView: View {
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
}
|
||||
} else {
|
||||
ARViewContainer(session: session)
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,39 @@ set -u
|
||||
REPO="$HOME/Documents/Projets/AV-Live"
|
||||
DEV="AC1CDE5F-DDC4-5B2C-80D7-9953D8ABA0AC" # iPhone 16 Pro (xcrun devicectl list devices)
|
||||
export PATH=/opt/homebrew/bin:$PATH
|
||||
export MEDIAPIPE_DELEGATE=cpu
|
||||
export POSE_FILTER=median+kalman+lookahead+ik+arkit_fuse
|
||||
# MediaPipe delegate: CPU by default. The GPU (Metal) delegate is faster but
|
||||
# CRASHES under sustained load -- gpu_buffer_storage_cv_pixel_buffer.cc fatal
|
||||
# CVPixelBuffer alloc failure (-6662) in Image::ConvertToGpu() -- on M1 Max too,
|
||||
# not just M5. The original choppiness it was meant to fix was CPU STARVATION
|
||||
# (baby-brain + model servers hogging the box); those are now stopped, so the CPU
|
||||
# delegate has headroom and stays fluid AND stable. Override with
|
||||
# MEDIAPIPE_DELEGATE=gpu only for short sessions where you accept the crash risk.
|
||||
export MEDIAPIPE_DELEGATE="${MEDIAPIPE_DELEGATE:-cpu}"
|
||||
# Finger pinches drive the 8 matrix global actions. ENABLED by default
|
||||
# since 2026-07-02: the full gate chain (plausibility, persistence, near,
|
||||
# facing, open-hand, debounce) was validated live. PINCH_ENABLE=0 disables.
|
||||
export PINCH_ENABLE="${PINCH_ENABLE:-1}"
|
||||
# iPhone rides PORTRAIT since 2026-07-02 (better hand detection, ARKit
|
||||
# body still tracks). The HEVC stream arrives sensor-landscape: rotate the
|
||||
# displayed video ccw. The 2D landmark streams arrive already uprighted
|
||||
# (see iphone_usb_source) and are not rotated.
|
||||
export VIDEO_ROTATE="${VIDEO_ROTATE:-ccw}"
|
||||
# Fixed viz mode: voronoi (the t-key effect) always active — the auto
|
||||
# openpos switch is disabled by data_only_viz when a mode is fixed.
|
||||
export VIZ_DEFAULT_MODE="${VIZ_DEFAULT_MODE:-voronoi}"
|
||||
# Gestures use the iPhone Vision hands exclusively (stable, rotation-invariant);
|
||||
# the Mac MediaPipe hand detector is skipped under --iphone-usb (multi.py).
|
||||
export FINGER_SOURCE="${FINGER_SOURCE:-iphone}"
|
||||
# ARKit is now the body source (built directly in multi.py under --iphone-usb),
|
||||
# so arkit_fuse (which spliced ARKit into MediaPipe pose) is no longer needed.
|
||||
export POSE_FILTER=median+kalman+lookahead+ik
|
||||
# CONCERT_MIRROR=0 in the environment disables the video mirror.
|
||||
|
||||
PY="$REPO/data_only_viz/.venv/bin/python"
|
||||
SCLANG="/Applications/SuperCollider.app/Contents/MacOS/sclang"
|
||||
# matrix preset auto-loaded on boot (a sound_algo/.../matrix_presets/*.matrix name
|
||||
# without extension); set MATRIX_PRESET="" to start from an empty grid.
|
||||
MATRIX_PRESET="${MATRIX_PRESET:-techno_drive}"
|
||||
MATRIX_PRESET="${MATRIX_PRESET:-techno_drive_live}"
|
||||
|
||||
notify() { osascript -e "display notification \"$1\" with title \"AV-Live Concert\"" 2>/dev/null; }
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum FrameTag: UInt8 {
|
||||
case meta = 3
|
||||
case hands = 4
|
||||
case face = 5
|
||||
case skeleton2D = 6
|
||||
case topology = 7
|
||||
}
|
||||
|
||||
/// Fixed-size frame header. Layout (big-endian):
|
||||
|
||||
@@ -42,6 +42,47 @@ public struct SkeletonPayload: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 91 ARKit joints in 2-D image space + a per-joint validity flag.
|
||||
public struct Skeleton2DPayload: Equatable {
|
||||
public static let jointCount = 91
|
||||
/// 91 * 2 * 4 bytes (floats) + 91 validity bytes.
|
||||
public static let byteCount = jointCount * 8 + jointCount
|
||||
|
||||
public var points: [SIMD2<Float>]
|
||||
public var valid: [Bool]
|
||||
|
||||
public init() {
|
||||
points = Array(repeating: .zero, count: Self.jointCount)
|
||||
valid = Array(repeating: false, count: Self.jointCount)
|
||||
}
|
||||
|
||||
public func encoded() -> Data {
|
||||
var d = Data(capacity: Self.byteCount)
|
||||
for p in points {
|
||||
d.appendBE(p.x.bitPattern); d.appendBE(p.y.bitPattern)
|
||||
}
|
||||
for v in valid { d.append(v ? 1 : 0) }
|
||||
return d
|
||||
}
|
||||
|
||||
public init?(decoding data: Data) {
|
||||
guard data.count == Self.byteCount else { return nil }
|
||||
let b = [UInt8](data)
|
||||
self.init()
|
||||
var o = 0
|
||||
for i in 0..<Self.jointCount {
|
||||
func f() -> Float {
|
||||
let v = Float(bitPattern:
|
||||
UInt32(bigEndianBytes: b[o..<o+4]))
|
||||
o += 4; return v
|
||||
}
|
||||
points[i] = SIMD2(f(), f())
|
||||
}
|
||||
let floatBytes = Self.jointCount * 8
|
||||
for i in 0..<Self.jointCount { valid[i] = b[floatBytes + i] != 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One HEVC access unit + a keyframe flag.
|
||||
public struct VideoPayload: Equatable {
|
||||
public var isKeyframe: Bool
|
||||
@@ -154,3 +195,52 @@ public struct FacePayload: Equatable {
|
||||
points = pts
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-describing skeleton topology: joint names + parent indices.
|
||||
/// Sent once per connection so consumers draw bones generically
|
||||
/// (parent -> child) with no hardcoded joint mapping.
|
||||
/// Layout (big-endian): u16 jointCount; per joint [u8 nameLen + utf8];
|
||||
/// then per joint i16 parent (-1 = root).
|
||||
public struct TopologyPayload: Equatable {
|
||||
public var jointNames: [String]
|
||||
public var parents: [Int16]
|
||||
public init(jointNames: [String], parents: [Int16]) {
|
||||
self.jointNames = jointNames
|
||||
self.parents = parents
|
||||
}
|
||||
|
||||
public func encoded() -> Data {
|
||||
var d = Data()
|
||||
d.appendBE(UInt16(min(jointNames.count, 65535)))
|
||||
for name in jointNames {
|
||||
let bytes = Array(name.utf8.prefix(255))
|
||||
d.append(UInt8(bytes.count))
|
||||
d.append(contentsOf: bytes)
|
||||
}
|
||||
for p in parents { d.appendBE(UInt16(bitPattern: p)) }
|
||||
return d
|
||||
}
|
||||
|
||||
public init?(decoding data: Data) {
|
||||
let b = [UInt8](data)
|
||||
guard b.count >= 2 else { return nil }
|
||||
let n = Int(UInt16(bigEndianBytes: b[0..<2]))
|
||||
var o = 2
|
||||
var names: [String] = []
|
||||
for _ in 0..<n {
|
||||
guard o < b.count else { return nil }
|
||||
let len = Int(b[o]); o += 1
|
||||
guard o + len <= b.count else { return nil }
|
||||
names.append(String(decoding: b[o..<o+len], as: UTF8.self))
|
||||
o += len
|
||||
}
|
||||
guard o + n * 2 <= b.count else { return nil }
|
||||
var ps: [Int16] = []
|
||||
for _ in 0..<n {
|
||||
ps.append(Int16(bitPattern: UInt16(bigEndianBytes: b[o..<o+2])))
|
||||
o += 2
|
||||
}
|
||||
jointNames = names
|
||||
parents = ps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import XCTest
|
||||
@testable import AVLiveWire
|
||||
|
||||
final class Skeleton2DPayloadTests: XCTestCase {
|
||||
func testRoundTrip() {
|
||||
var p = Skeleton2DPayload()
|
||||
for i in 0..<Skeleton2DPayload.jointCount {
|
||||
p.points[i] = SIMD2(Float(i) * 0.001, Float(i) * 0.002)
|
||||
p.valid[i] = (i % 2 == 0)
|
||||
}
|
||||
let data = p.encoded()
|
||||
let r = Skeleton2DPayload(decoding: data)
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.points[10].x, p.points[10].x, accuracy: 1e-6)
|
||||
XCTAssertEqual(r!.valid[10], p.valid[10])
|
||||
XCTAssertEqual(r!.valid[11], p.valid[11])
|
||||
}
|
||||
|
||||
func testByteCount() {
|
||||
// 91 joints × 2 floats × 4 bytes + 91 valid bytes = 91*8+91 = 819
|
||||
XCTAssertEqual(Skeleton2DPayload.byteCount, 91 * 8 + 91)
|
||||
}
|
||||
|
||||
func testRejectsWrongSize() {
|
||||
XCTAssertNil(Skeleton2DPayload(decoding: Data([0, 1, 2])))
|
||||
}
|
||||
|
||||
func testFrameTagSkeleton2D() {
|
||||
XCTAssertEqual(FrameTag.skeleton2D.rawValue, 6)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import XCTest
|
||||
@testable import AVLiveWire
|
||||
|
||||
final class TopologyPayloadTests: XCTestCase {
|
||||
func testFrameTagTopology() {
|
||||
XCTAssertEqual(FrameTag.topology.rawValue, 7)
|
||||
}
|
||||
|
||||
func testRoundTrip() {
|
||||
let names = ["root_joint", "hips_joint", "left_arm_joint"]
|
||||
let parents: [Int16] = [-1, 0, 1]
|
||||
let p = TopologyPayload(jointNames: names, parents: parents)
|
||||
let r = TopologyPayload(decoding: p.encoded())
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.jointNames, names)
|
||||
XCTAssertEqual(r!.parents, parents)
|
||||
}
|
||||
|
||||
func testRejectsTruncated() {
|
||||
// Claims 3 joints but provides no body.
|
||||
let bytes: [UInt8] = [0x00, 0x03]
|
||||
XCTAssertNil(TopologyPayload(decoding: Data(bytes)))
|
||||
}
|
||||
|
||||
func testEmpty() {
|
||||
let p = TopologyPayload(jointNames: [], parents: [])
|
||||
let r = TopologyPayload(decoding: p.encoded())
|
||||
XCTAssertNotNil(r)
|
||||
XCTAssertEqual(r!.jointNames, [])
|
||||
XCTAssertEqual(r!.parents, [])
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,141 @@ SynthDef(\lp_fx, { |out=0, amp=0.4, pan=0, rev=0, len=1.5|
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
|
||||
// ===================================================================
|
||||
// Vintage 90s/2000s additions — 10 new classic-machine emulations.
|
||||
// All note-terminating; cutoff+res args where matrix ~matResVoices
|
||||
// targets exist for the assigned voice(s). Standard UGens only.
|
||||
// ===================================================================
|
||||
// --- Roland TR-909 Snare (2 sines + HPF noise, percussive) ---
|
||||
SynthDef(\tr909_snare, { |out=0, amp=0.5, snappy=0.6, pan=0, rev=0|
|
||||
var tenv = EnvGen.kr(Env.perc(0.001, 0.09, 1, -4));
|
||||
var nenv = EnvGen.kr(Env.perc(0.001, 0.22, 1, -3), doneAction: 2);
|
||||
var tone = (SinOsc.ar(180) + SinOsc.ar(330)) * tenv * 0.35;
|
||||
var noise = HPF.ar(WhiteNoise.ar, 1500) * nenv * snappy;
|
||||
var sig = Pan2.ar(((tone + noise) * 2.0).tanh, pan.clip(-1,1)) * amp;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Alpha Juno-2 Hoover / Mentasm (PWM saw + pitch-drop + BBD chorus, gated) ---
|
||||
SynthDef(\hoover, { |out=0, freq=110, amp=0.4, cutoff=8000, res=0.2, gate=1, pan=0, rev=0|
|
||||
var env = EnvGen.kr(Env.asr(0.01, 1, 0.3), gate, doneAction: 2);
|
||||
var penv = EnvGen.kr(Env([1.5, 1.5, 1.0, 1.0], [0.01, 0.28, 0.5], [\lin, \exp, \lin]));
|
||||
var f = freq.clip(40, 2000) * penv;
|
||||
var osc = Saw.ar(f * (1 + SinOsc.kr(8, 0, 0.04)));
|
||||
var sub = LFPulse.ar(f * 0.5, 0, 0.5) * 0.18;
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var flt = RLPF.ar(osc + sub, cutoff.clip(200, 14000), rq);
|
||||
var ch = flt
|
||||
+ DelayC.ar(flt, 0.02, SinOsc.kr(0.5, 0, 0.004, 0.007))
|
||||
+ DelayC.ar(flt, 0.02, SinOsc.kr(0.6, pi, 0.004, 0.007));
|
||||
var sig = Pan2.ar(ch * 0.33 * env, pan.clip(-1,1)) * amp;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Korg M1 House Piano (additive + key-click, percussive) ---
|
||||
SynthDef(\m1_piano, { |out=0, freq=440, amp=0.5, cutoff=8000, res=0.5, pan=0, rev=0|
|
||||
var f = freq.clip(80, 4000);
|
||||
var click = SinOsc.ar(f * 8) * EnvGen.kr(Env.perc(0.0005, 0.008)) * 0.3;
|
||||
var env = EnvGen.kr(Env.perc(0.001, 0.38, 1, -5), doneAction: 2);
|
||||
var osc = Mix(SinOsc.ar(f * [1.0, 2.0, 3.0, 4.1, 5.2, 6.3, 7.5, 8.8], 0,
|
||||
[1.0, 0.32, 0.12, 0.06, 0.03, 0.015, 0.008, 0.004]));
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar((osc + click) * env, cutoff.clip(200, 14000), rq).tanh * 0.5;
|
||||
sig = Pan2.ar(sig, pan.clip(-1,1)) * amp;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Korg M1 Organ (drawbar additive harmonics, gated) ---
|
||||
SynthDef(\m1_organ, { |out=0, freq=55, amp=0.3, cutoff=4000, res=0.3, gate=1, pan=0, rev=0|
|
||||
var f = freq.clip(20, 1000);
|
||||
var osc = Mix(SinOsc.ar(f * [1, 2, 3, 4, 5, 6, 8], 0,
|
||||
[0.8, 0.8, 0.6, 0.4, 0.2, 0.1, 0.1]));
|
||||
var env = EnvGen.kr(Env.asr(0.008, 1, 0.06), gate, doneAction: 2);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(osc * 0.12, cutoff.clip(200, 12000), rq).tanh;
|
||||
sig = Pan2.ar(sig * env, pan.clip(-1,1)) * amp * 0.55;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Yamaha DX7 Electric Piano (high-ratio FM tine click + body, percussive) ---
|
||||
SynthDef(\dx7_epiano, { |out=0, freq=220, amp=0.4, cutoff=8000, res=0.5, modidx=0.3, pan=0, rev=0|
|
||||
var f = freq.clip(40, 4000);
|
||||
var menv = EnvGen.kr(Env.perc(0.0003, 0.012, 1, -3));
|
||||
var mod = SinOsc.ar(f * 14) * f * 14 * modidx * menv;
|
||||
var env = EnvGen.kr(Env.perc(0.001, 0.5, 1, -3), doneAction: 2);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(SinOsc.ar(f + mod) * env, cutoff.clip(200, 14000), rq) * 0.5;
|
||||
sig = Pan2.ar(sig, pan.clip(-1,1)) * amp;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- JP-8000 / Jupiter-8 Brass Stab (filter-env stab on dual saws, gated) ---
|
||||
SynthDef(\brass_stab, { |out=0, freq=220, amp=0.4, cutoff=800, res=0.3, gate=1, pan=0, rev=0|
|
||||
var fenv = EnvGen.kr(Env.perc(0.003, 0.12, 1, -4));
|
||||
var aenv = EnvGen.kr(Env.asr(0.003, 1, 0.15), gate, doneAction: 2);
|
||||
var f = freq.clip(30, 3000);
|
||||
var osc = Mix(Saw.ar([f, f * 1.006])) + (Pulse.ar((f * 0.5).clip(10, 1500), 0.5) * 0.3);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(osc, (cutoff + (fenv * 2000)).clip(100, 12000), rq);
|
||||
sig = Pan2.ar(sig * aenv, pan.clip(-1,1)) * amp * 0.45;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Nord Lead 2 VA Lead (2 detuned saws + delayed vibrato + filter, gated) ---
|
||||
SynthDef(\nord_lead, { |out=0, freq=220, amp=0.3, cutoff=4000, res=0.3, gate=1, pan=0, rev=0|
|
||||
var env = EnvGen.kr(Env.asr(0.005, 1, 0.2), gate, doneAction: 2);
|
||||
var f = freq.clip(40, 5000);
|
||||
var vib = SinOsc.kr(5) * 0.004 * EnvGen.kr(Env([0, 0, 1, 1], [0.2, 0.05, 0.5]));
|
||||
var f2 = f * (1 + vib);
|
||||
var osc = Mix(Saw.ar([f2, f2 * 1.007]));
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(osc, (cutoff * (1 + (env * 0.3))).clip(200, 14000), rq);
|
||||
sig = Pan2.ar(sig * env, pan.clip(-1,1)) * amp * 0.5;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Access Virus Hypersaw (9 detuned saws + soft saturation, gated) ---
|
||||
SynthDef(\virus_saw, { |out=0, freq=220, amp=0.3, cutoff=6000, res=0.25, detune=0.08, gate=1, pan=0, rev=0|
|
||||
var env = EnvGen.kr(Env.asr(0.002, 1, 0.2), gate, doneAction: 2);
|
||||
var f = freq.clip(20, 5000);
|
||||
var dets = [-0.7, -0.5, -0.33, -0.17, 0, 0.17, 0.33, 0.5, 0.7] * detune;
|
||||
var osc = HPF.ar((Mix(Saw.ar(f * (1 + dets))) * (1/9) * 3).tanh, 60);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(osc, cutoff.clip(100, 18000), rq);
|
||||
sig = Pan2.ar(sig * env, pan.clip(-1,1)) * amp * 0.5;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Rave Piano Stab (M1/S950 inharmonic additive, percussive) ---
|
||||
SynthDef(\rave_stab, { |out=0, freq=440, amp=0.5, cutoff=8000, res=0.5, pan=0, rev=0|
|
||||
var f = freq.clip(80, 4000);
|
||||
var osc = Mix(SinOsc.ar(f * [1.0, 2.002, 3.005, 4.007, 5.009, 6.012, 7.014, 8.016], 0,
|
||||
[1.0, 0.4, 0.15, 0.08, 0.04, 0.02, 0.01, 0.005]));
|
||||
var env = EnvGen.kr(Env.perc(0.001, 0.3, 1, -4), doneAction: 2);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.1);
|
||||
var sig = RLPF.ar(osc * env, cutoff.clip(200, 14000), rq).tanh * 0.8;
|
||||
sig = Pan2.ar(sig, pan.clip(-1,1)) * amp * 0.5;
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
// --- Juno-106 Reese Bass (PWM pulse + BBD chorus, distinct from lp_reese multi-saw, gated) ---
|
||||
SynthDef(\reese_juno, { |out=0, freq=55, amp=0.0, cutoff=0, res=0.3, gate=1, pan=0, rev=0|
|
||||
var env = EnvGen.kr(Env.asr(0.02, 1, 0.12), gate, doneAction: 2);
|
||||
var f = freq.clip(20, 200);
|
||||
var lfo = SinOsc.kr(0.5);
|
||||
var pw = lfo.range(0.3, 0.7);
|
||||
var osc = Pulse.ar(f, pw) + (Pulse.ar(f * 1.007, 1.0 - pw) * 0.85);
|
||||
var ch = osc
|
||||
+ DelayC.ar(osc, 0.02, SinOsc.kr(0.6, 0, 0.003, 0.007))
|
||||
+ DelayC.ar(osc, 0.02, SinOsc.kr(0.5, pi, 0.003, 0.007));
|
||||
var cut = Select.kr(cutoff > 0, [(f * 5), cutoff]).clip(80, 3000);
|
||||
var rq = res.clip(0, 1).linexp(0, 1, 1.0, 0.15);
|
||||
var sig = LeakDC.ar(RLPF.ar(ch * 0.35, cut, rq)) * env;
|
||||
sig = Pan2.ar(sig, pan.clip(-1,1)) * amp.lag(0.1);
|
||||
Out.ar(out, sig);
|
||||
~doReverbBus.notNil.if({ Out.ar(~doReverbBus, sig * rev) });
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
~lp = ~lp ? ();
|
||||
|
||||
+687
-196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -1,27 +0,0 @@
|
||||
(
|
||||
'grid': [
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \default, \default, \tb303, \default, \default, \default, \default, \tr909_ride, \default, \default, \default, \default, \default, \default, \default, \default, \lp_snare, \lp_crash, \tr909_hat ]
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,4,4,4,4,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,5,5,5,5,5,5,5,5,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \lp_pluck, \lp_pluck, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00)]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:3, vel:0.50),nil]), (octave:-1, steps:[(degree:7, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:7, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.70),nil,(degree:3, vel:0.60),nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),(degree:3, vel:0.50),(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.90),(degree:0, vel:0.50),(degree:14, vel:0.80),(degree:0, vel:0.50),(degree:10, vel:0.70),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:0, vel:0.90),(degree:7, vel:0.50),(degree:5, vel:0.70),(degree:10, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.80),(degree:2, vel:0.60),(degree:7, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),(degree:7, vel:0.50),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:4, vel:0.80),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:7, vel:0.85),nil,(degree:7, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:14, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:12, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,(degree:7, vel:0.60),(degree:9, vel:0.85),(degree:9, vel:0.60),nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,(degree:7, vel:0.60),nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.60),(degree:8, vel:0.70),(degree:9, vel:0.80),(degree:10, vel:0.90)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:3, vel:0.80),(degree:3, vel:0.50),(degree:0, vel:0.60),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:0, vel:0.60),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:7, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:12, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:14, vel:0.85),nil,(degree:3, vel:0.70),(degree:9, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:4, vel:0.50),nil,(degree:5, vel:0.65),(degree:5, vel:0.50),nil,(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:0, vel:0.60),(degree:0, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:12, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,(degree:7, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:5, vel:0.65),nil,(degree:5, vel:0.60),nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,nil,nil,nil,nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:7, vel:0.75),(degree:3, vel:0.60),(degree:12, vel:0.70),(degree:7, vel:0.75),(degree:12, vel:0.65),(degree:3, vel:0.60),(degree:9, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.75),(degree:5, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:10, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:5, vel:0.60),(degree:6, vel:0.70),(degree:7, vel:0.80),(degree:8, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[3,3,3,3,3,3,3,3,3,3,3,3,3,3,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,3,3,3,3,3,3,3,3,3,3,3,3,3,3,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \lp_pluck, \brass_stab, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \dx7_epiano, \lp_sweep, \lp_pluck, \m1_organ, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00)]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.90),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.90),nil,nil,nil,(degree:3, vel:0.80),nil,nil,nil]), (octave:-1, steps:[nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:5, vel:0.60),nil,nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:8, vel:0.80),nil]), (octave:-1, steps:[(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil,(degree:5, vel:0.50),nil]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:-1, steps:[(degree:0, vel:0.61),nil,nil,nil,(degree:0, vel:0.41),nil,nil,nil,(degree:0, vel:0.61),nil,nil,nil,(degree:3, vel:0.54),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:7, vel:0.80),(degree:0, vel:0.55),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.55),(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.55)]), (octave:0, steps:[(degree:7, vel:0.60),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:12, vel:0.80),(degree:5, vel:0.55),(degree:8, vel:0.70),nil,(degree:12, vel:0.85),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:10, vel:0.70),(degree:8, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.80)]), (octave:0, steps:[(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:9, vel:0.80),(degree:2, vel:0.55),(degree:5, vel:0.70),(degree:5, vel:0.50),(degree:9, vel:0.85),(degree:2, vel:0.55),(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:7, vel:0.70),(degree:5, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.80),(degree:4, vel:0.60),(degree:2, vel:0.55)]), (octave:0, steps:[(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92)]), (octave:0, steps:[(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.72),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:7, vel:0.70),(degree:4, vel:0.66),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:4, vel:0.66),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:11, vel:0.86),(degree:9, vel:0.80),(degree:7, vel:0.72),(degree:4, vel:0.66)]), (octave:1, steps:[(degree:12, vel:0.72),(degree:9, vel:0.66),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:9, vel:0.72),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:12, vel:0.70),(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:9, vel:0.66),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:14, vel:0.86),(degree:14, vel:0.80)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:6, vel:0.72),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:9, vel:0.70),(degree:6, vel:0.66),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:6, vel:0.66),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:13, vel:0.86),(degree:11, vel:0.80),(degree:9, vel:0.72),(degree:6, vel:0.66)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.48),(degree:4, vel:0.45),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.82),nil,(degree:5, vel:0.82),nil,(degree:7, vel:0.88),nil,nil,nil]), (octave:1, steps:[nil,nil,(degree:12, vel:0.90),nil,nil,(degree:14, vel:0.85),nil,nil,(degree:12, vel:0.80),nil,(degree:9, vel:0.82),nil,(degree:10, vel:0.82),nil,(degree:12, vel:0.88),nil]), (octave:1, steps:[(degree:9, vel:0.90),(degree:9, vel:0.50),nil,(degree:11, vel:0.85),(degree:11, vel:0.50),nil,(degree:9, vel:0.80),(degree:9, vel:0.50),(degree:6, vel:0.82),nil,(degree:7, vel:0.82),(degree:7, vel:0.50),(degree:9, vel:0.88),nil,(degree:9, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:1, steps:[(degree:7, vel:0.61),nil,nil,(degree:9, vel:0.58),nil,nil,nil,nil,(degree:4, vel:0.56),nil,(degree:5, vel:0.56),nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[nil,nil,(degree:2, vel:0.70),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:3, vel:0.80),nil]), (octave:0, steps:[(degree:8, vel:0.80),nil,nil,nil,(degree:7, vel:0.70),nil,nil,nil,(degree:5, vel:0.80),nil,nil,nil,(degree:5, vel:0.70),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:4, vel:0.70),(degree:4, vel:0.50),nil,(degree:4, vel:0.50),(degree:2, vel:0.80),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.70),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil]), (octave:0, steps:[(degree:7, vel:0.88),(degree:7, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88)]), (octave:0, steps:[nil,nil,(degree:2, vel:0.48),nil,nil,nil,(degree:0, vel:0.54),nil,nil,nil,(degree:0, vel:0.48),nil,nil,nil,(degree:3, vel:0.54),nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.75),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:8, vel:0.70),(degree:7, vel:0.60),(degree:5, vel:0.85),nil,(degree:5, vel:0.60),nil,(degree:8, vel:0.75),nil,(degree:5, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:5, vel:0.60),nil,(degree:12, vel:0.85),nil]), (octave:-1, steps:[(degree:2, vel:0.85),(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:5, vel:0.75),(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:2, vel:0.60),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),(degree:5, vel:0.70),(degree:4, vel:0.60)]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:-1, steps:[(degree:0, vel:0.58),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil,(degree:5, vel:0.54),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.68),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.64),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:5, vel:0.60),nil,(degree:12, vel:0.68),nil,nil,nil,(degree:9, vel:0.60),nil,nil,nil,(degree:10, vel:0.64),nil,nil,nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:9, vel:0.68),(degree:9, vel:0.50),nil,(degree:9, vel:0.50),(degree:6, vel:0.60),nil,(degree:6, vel:0.50),nil,(degree:7, vel:0.64),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:2, vel:0.60),(degree:2, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88)]), (octave:2, steps:[(degree:7, vel:0.46),nil,nil,nil,(degree:4, vel:0.41),nil,nil,nil,(degree:5, vel:0.44),nil,nil,nil,(degree:2, vel:0.41),nil,nil,nil]), (octave:2, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.66),(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.72),(degree:7, vel:0.76),(degree:5, vel:0.66),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.76),(degree:5, vel:0.72),(degree:4, vel:0.66),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:8, vel:0.60),(degree:10, vel:0.66),(degree:5, vel:0.75),(degree:8, vel:0.60),(degree:10, vel:0.72),(degree:12, vel:0.76),(degree:10, vel:0.66),(degree:8, vel:0.60),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.76),(degree:10, vel:0.72)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:5, vel:0.60),(degree:7, vel:0.66),(degree:2, vel:0.75),(degree:5, vel:0.60),(degree:7, vel:0.72),(degree:9, vel:0.76),(degree:7, vel:0.66),(degree:5, vel:0.60),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.76),(degree:7, vel:0.72),(degree:6, vel:0.66),(degree:4, vel:0.60)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:5, vel:0.45),(degree:3, vel:0.41),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,2,2,2,2,2,2,2,2,2,2,2,2,2,2,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,2,2,2,2,2,2,2,2,2,2,2,2,2,2,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \hoover, \lp_pluck, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \hoover, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:1.00),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:0.80)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.90),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.90),nil,nil,nil,(degree:3, vel:0.80),nil,nil,nil]), (octave:-1, steps:[nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:5, vel:0.60),nil,nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:8, vel:0.80),nil]), (octave:-1, steps:[(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil,(degree:5, vel:0.50),nil]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:-1, steps:[(degree:0, vel:0.61),nil,nil,nil,(degree:0, vel:0.41),nil,nil,nil,(degree:0, vel:0.61),nil,nil,nil,(degree:3, vel:0.54),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:7, vel:0.80),(degree:0, vel:0.55),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.55),(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.55)]), (octave:0, steps:[(degree:7, vel:0.60),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:12, vel:0.80),(degree:5, vel:0.55),(degree:8, vel:0.70),nil,(degree:12, vel:0.85),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:10, vel:0.70),(degree:8, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.80)]), (octave:0, steps:[(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:9, vel:0.80),(degree:2, vel:0.55),(degree:5, vel:0.70),(degree:5, vel:0.50),(degree:9, vel:0.85),(degree:2, vel:0.55),(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:7, vel:0.70),(degree:5, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.80),(degree:4, vel:0.60),(degree:2, vel:0.55)]), (octave:0, steps:[(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92)]), (octave:0, steps:[(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.72),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:7, vel:0.70),(degree:4, vel:0.66),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:4, vel:0.66),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:11, vel:0.86),(degree:9, vel:0.80),(degree:7, vel:0.72),(degree:4, vel:0.66)]), (octave:1, steps:[(degree:12, vel:0.72),(degree:9, vel:0.66),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:9, vel:0.72),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:12, vel:0.70),(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:9, vel:0.66),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:14, vel:0.86),(degree:14, vel:0.80)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:6, vel:0.72),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:9, vel:0.70),(degree:6, vel:0.66),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:6, vel:0.66),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:13, vel:0.86),(degree:11, vel:0.80),(degree:9, vel:0.72),(degree:6, vel:0.66)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.48),(degree:4, vel:0.45),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.82),nil,(degree:5, vel:0.82),nil,(degree:7, vel:0.88),nil,nil,nil]), (octave:1, steps:[nil,nil,(degree:12, vel:0.90),nil,nil,(degree:14, vel:0.85),nil,nil,(degree:12, vel:0.80),nil,(degree:9, vel:0.82),nil,(degree:10, vel:0.82),nil,(degree:12, vel:0.88),nil]), (octave:1, steps:[(degree:9, vel:0.90),(degree:9, vel:0.50),nil,(degree:11, vel:0.85),(degree:11, vel:0.50),nil,(degree:9, vel:0.80),(degree:9, vel:0.50),(degree:6, vel:0.82),nil,(degree:7, vel:0.82),(degree:7, vel:0.50),(degree:9, vel:0.88),nil,(degree:9, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:1, steps:[(degree:7, vel:0.61),nil,nil,(degree:9, vel:0.58),nil,nil,nil,nil,(degree:4, vel:0.56),nil,(degree:5, vel:0.56),nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[nil,nil,(degree:2, vel:0.70),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:3, vel:0.80),nil]), (octave:0, steps:[(degree:8, vel:0.80),nil,nil,nil,(degree:7, vel:0.70),nil,nil,nil,(degree:5, vel:0.80),nil,nil,nil,(degree:5, vel:0.70),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:4, vel:0.70),(degree:4, vel:0.50),nil,(degree:4, vel:0.50),(degree:2, vel:0.80),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.70),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil]), (octave:0, steps:[(degree:7, vel:0.88),(degree:7, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88)]), (octave:0, steps:[nil,nil,(degree:2, vel:0.48),nil,nil,nil,(degree:0, vel:0.54),nil,nil,nil,(degree:0, vel:0.48),nil,nil,nil,(degree:3, vel:0.54),nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.75),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:8, vel:0.70),(degree:7, vel:0.60),(degree:5, vel:0.85),nil,(degree:5, vel:0.60),nil,(degree:8, vel:0.75),nil,(degree:5, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:5, vel:0.60),nil,(degree:12, vel:0.85),nil]), (octave:-1, steps:[(degree:2, vel:0.85),(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:5, vel:0.75),(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:2, vel:0.60),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),(degree:5, vel:0.70),(degree:4, vel:0.60)]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:-1, steps:[(degree:0, vel:0.58),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil,(degree:5, vel:0.54),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.68),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.64),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:5, vel:0.60),nil,(degree:12, vel:0.68),nil,nil,nil,(degree:9, vel:0.60),nil,nil,nil,(degree:10, vel:0.64),nil,nil,nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:9, vel:0.68),(degree:9, vel:0.50),nil,(degree:9, vel:0.50),(degree:6, vel:0.60),nil,(degree:6, vel:0.50),nil,(degree:7, vel:0.64),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:2, vel:0.60),(degree:2, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88)]), (octave:2, steps:[(degree:7, vel:0.46),nil,nil,nil,(degree:4, vel:0.41),nil,nil,nil,(degree:5, vel:0.44),nil,nil,nil,(degree:2, vel:0.41),nil,nil,nil]), (octave:2, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.66),(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.72),(degree:7, vel:0.76),(degree:5, vel:0.66),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.76),(degree:5, vel:0.72),(degree:4, vel:0.66),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:8, vel:0.60),(degree:10, vel:0.66),(degree:5, vel:0.75),(degree:8, vel:0.60),(degree:10, vel:0.72),(degree:12, vel:0.76),(degree:10, vel:0.66),(degree:8, vel:0.60),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.76),(degree:10, vel:0.72)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:5, vel:0.60),(degree:7, vel:0.66),(degree:2, vel:0.75),(degree:5, vel:0.60),(degree:7, vel:0.72),(degree:9, vel:0.76),(degree:7, vel:0.66),(degree:5, vel:0.60),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.76),(degree:7, vel:0.72),(degree:6, vel:0.66),(degree:4, vel:0.60)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:5, vel:0.45),(degree:3, vel:0.41),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[3,3,3,3,3,3,3,3,3,3,3,3,3,3,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,3,3,3,3,3,3,3,3,3,3,3,3,3,3,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \virus_saw, \virus_saw, \rave_stab, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:1.00),nil,nil,(degree:0, vel:1.00),(degree:0, vel:1.00),nil,nil,(degree:0, vel:1.00),(degree:0, vel:1.00),nil,nil,(degree:0, vel:1.00),(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.90),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.90),nil,nil,nil,(degree:3, vel:0.80),nil,nil,nil]), (octave:-1, steps:[nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:5, vel:0.60),nil,nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:8, vel:0.80),nil]), (octave:-1, steps:[(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil,(degree:5, vel:0.50),nil]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:-1, steps:[(degree:0, vel:0.61),nil,nil,nil,(degree:0, vel:0.41),nil,nil,nil,(degree:0, vel:0.61),nil,nil,nil,(degree:3, vel:0.54),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:7, vel:0.80),(degree:0, vel:0.55),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.55),(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.55)]), (octave:0, steps:[(degree:7, vel:0.60),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:12, vel:0.80),(degree:5, vel:0.55),(degree:8, vel:0.70),nil,(degree:12, vel:0.85),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:10, vel:0.70),(degree:8, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.80)]), (octave:0, steps:[(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:9, vel:0.80),(degree:2, vel:0.55),(degree:5, vel:0.70),(degree:5, vel:0.50),(degree:9, vel:0.85),(degree:2, vel:0.55),(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:7, vel:0.70),(degree:5, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.80),(degree:4, vel:0.60),(degree:2, vel:0.55)]), (octave:0, steps:[(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92)]), (octave:0, steps:[(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.72),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:7, vel:0.70),(degree:4, vel:0.66),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:4, vel:0.66),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:11, vel:0.86),(degree:9, vel:0.80),(degree:7, vel:0.72),(degree:4, vel:0.66)]), (octave:1, steps:[(degree:12, vel:0.72),(degree:9, vel:0.66),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:9, vel:0.72),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:12, vel:0.70),(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:9, vel:0.66),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:14, vel:0.86),(degree:14, vel:0.80)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:6, vel:0.72),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:9, vel:0.70),(degree:6, vel:0.66),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:6, vel:0.66),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:13, vel:0.86),(degree:11, vel:0.80),(degree:9, vel:0.72),(degree:6, vel:0.66)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.48),(degree:4, vel:0.45),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.82),nil,(degree:5, vel:0.82),nil,(degree:7, vel:0.88),nil,nil,nil]), (octave:1, steps:[nil,nil,(degree:12, vel:0.90),nil,nil,(degree:14, vel:0.85),nil,nil,(degree:12, vel:0.80),nil,(degree:9, vel:0.82),nil,(degree:10, vel:0.82),nil,(degree:12, vel:0.88),nil]), (octave:1, steps:[(degree:9, vel:0.90),(degree:9, vel:0.50),nil,(degree:11, vel:0.85),(degree:11, vel:0.50),nil,(degree:9, vel:0.80),(degree:9, vel:0.50),(degree:6, vel:0.82),nil,(degree:7, vel:0.82),(degree:7, vel:0.50),(degree:9, vel:0.88),nil,(degree:9, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:1, steps:[(degree:7, vel:0.61),nil,nil,(degree:9, vel:0.58),nil,nil,nil,nil,(degree:4, vel:0.56),nil,(degree:5, vel:0.56),nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[nil,nil,(degree:2, vel:0.70),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:3, vel:0.80),nil]), (octave:0, steps:[(degree:8, vel:0.80),nil,nil,nil,(degree:7, vel:0.70),nil,nil,nil,(degree:5, vel:0.80),nil,nil,nil,(degree:5, vel:0.70),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:4, vel:0.70),(degree:4, vel:0.50),nil,(degree:4, vel:0.50),(degree:2, vel:0.80),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.70),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil]), (octave:0, steps:[(degree:7, vel:0.88),(degree:7, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88)]), (octave:0, steps:[nil,nil,(degree:2, vel:0.48),nil,nil,nil,(degree:0, vel:0.54),nil,nil,nil,(degree:0, vel:0.48),nil,nil,nil,(degree:3, vel:0.54),nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.75),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:8, vel:0.70),(degree:7, vel:0.60),(degree:5, vel:0.85),nil,(degree:5, vel:0.60),nil,(degree:8, vel:0.75),nil,(degree:5, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:5, vel:0.60),nil,(degree:12, vel:0.85),nil]), (octave:-1, steps:[(degree:2, vel:0.85),(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:5, vel:0.75),(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:2, vel:0.60),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),(degree:5, vel:0.70),(degree:4, vel:0.60)]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:-1, steps:[(degree:0, vel:0.58),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil,(degree:5, vel:0.54),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.68),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.64),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:5, vel:0.60),nil,(degree:12, vel:0.68),nil,nil,nil,(degree:9, vel:0.60),nil,nil,nil,(degree:10, vel:0.64),nil,nil,nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:9, vel:0.68),(degree:9, vel:0.50),nil,(degree:9, vel:0.50),(degree:6, vel:0.60),nil,(degree:6, vel:0.50),nil,(degree:7, vel:0.64),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:2, vel:0.60),(degree:2, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88)]), (octave:2, steps:[(degree:7, vel:0.46),nil,nil,nil,(degree:4, vel:0.41),nil,nil,nil,(degree:5, vel:0.44),nil,nil,nil,(degree:2, vel:0.41),nil,nil,nil]), (octave:2, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.66),(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.72),(degree:7, vel:0.76),(degree:5, vel:0.66),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.76),(degree:5, vel:0.72),(degree:4, vel:0.66),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:8, vel:0.60),(degree:10, vel:0.66),(degree:5, vel:0.75),(degree:8, vel:0.60),(degree:10, vel:0.72),(degree:12, vel:0.76),(degree:10, vel:0.66),(degree:8, vel:0.60),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.76),(degree:10, vel:0.72)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:5, vel:0.60),(degree:7, vel:0.66),(degree:2, vel:0.75),(degree:5, vel:0.60),(degree:7, vel:0.72),(degree:9, vel:0.76),(degree:7, vel:0.66),(degree:5, vel:0.60),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.76),(degree:7, vel:0.72),(degree:6, vel:0.66),(degree:4, vel:0.60)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:5, vel:0.45),(degree:3, vel:0.41),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \lp_pluck, \brass_stab, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \tr909_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.90),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.90),nil,nil,nil,(degree:3, vel:0.80),nil,nil,nil]), (octave:-1, steps:[nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:5, vel:0.60),nil,nil,nil,(degree:5, vel:0.90),nil,nil,nil,(degree:8, vel:0.80),nil]), (octave:-1, steps:[(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.90),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil,(degree:5, vel:0.50),nil]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:-1, steps:[(degree:0, vel:0.61),nil,nil,nil,(degree:0, vel:0.41),nil,nil,nil,(degree:0, vel:0.61),nil,nil,nil,(degree:3, vel:0.54),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:7, vel:0.80),(degree:0, vel:0.55),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.55),(degree:0, vel:0.90),(degree:0, vel:0.55),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.55)]), (octave:0, steps:[(degree:7, vel:0.60),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:12, vel:0.80),(degree:5, vel:0.55),(degree:8, vel:0.70),nil,(degree:12, vel:0.85),(degree:5, vel:0.55),(degree:5, vel:0.90),(degree:5, vel:0.55),(degree:10, vel:0.70),(degree:8, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.80)]), (octave:0, steps:[(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:9, vel:0.80),(degree:2, vel:0.55),(degree:5, vel:0.70),(degree:5, vel:0.50),(degree:9, vel:0.85),(degree:2, vel:0.55),(degree:2, vel:0.90),(degree:2, vel:0.55),(degree:7, vel:0.70),(degree:5, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.80),(degree:4, vel:0.60),(degree:2, vel:0.55)]), (octave:0, steps:[(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92)]), (octave:0, steps:[(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.61),(degree:0, vel:0.37),nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.72),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:7, vel:0.70),(degree:4, vel:0.66),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:4, vel:0.66),(degree:7, vel:0.78),(degree:9, vel:0.82),(degree:11, vel:0.86),(degree:9, vel:0.80),(degree:7, vel:0.72),(degree:4, vel:0.66)]), (octave:1, steps:[(degree:12, vel:0.72),(degree:9, vel:0.66),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:9, vel:0.72),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:12, vel:0.70),(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:9, vel:0.66),(degree:12, vel:0.78),(degree:14, vel:0.82),(degree:14, vel:0.86),(degree:14, vel:0.80)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:6, vel:0.72),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:9, vel:0.70),(degree:6, vel:0.66),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:6, vel:0.66),(degree:9, vel:0.78),(degree:11, vel:0.82),(degree:13, vel:0.86),(degree:11, vel:0.80),(degree:9, vel:0.72),(degree:6, vel:0.66)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:0, vel:0.48),(degree:4, vel:0.45),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.82),nil,(degree:5, vel:0.82),nil,(degree:7, vel:0.88),nil,nil,nil]), (octave:1, steps:[nil,nil,(degree:12, vel:0.90),nil,nil,(degree:14, vel:0.85),nil,nil,(degree:12, vel:0.80),nil,(degree:9, vel:0.82),nil,(degree:10, vel:0.82),nil,(degree:12, vel:0.88),nil]), (octave:1, steps:[(degree:9, vel:0.90),(degree:9, vel:0.50),nil,(degree:11, vel:0.85),(degree:11, vel:0.50),nil,(degree:9, vel:0.80),(degree:9, vel:0.50),(degree:6, vel:0.82),nil,(degree:7, vel:0.82),(degree:7, vel:0.50),(degree:9, vel:0.88),nil,(degree:9, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88)]), (octave:1, steps:[(degree:7, vel:0.61),nil,nil,(degree:9, vel:0.58),nil,nil,nil,nil,(degree:4, vel:0.56),nil,(degree:5, vel:0.56),nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[nil,nil,(degree:2, vel:0.70),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:3, vel:0.80),nil]), (octave:0, steps:[(degree:8, vel:0.80),nil,nil,nil,(degree:7, vel:0.70),nil,nil,nil,(degree:5, vel:0.80),nil,nil,nil,(degree:5, vel:0.70),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:4, vel:0.70),(degree:4, vel:0.50),nil,(degree:4, vel:0.50),(degree:2, vel:0.80),nil,(degree:2, vel:0.50),nil,(degree:2, vel:0.70),(degree:2, vel:0.50),nil,(degree:2, vel:0.50),(degree:5, vel:0.80),nil]), (octave:0, steps:[(degree:7, vel:0.88),(degree:7, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88)]), (octave:0, steps:[nil,nil,(degree:2, vel:0.48),nil,nil,nil,(degree:0, vel:0.54),nil,nil,nil,(degree:0, vel:0.48),nil,nil,nil,(degree:3, vel:0.54),nil]), (octave:0, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.75),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:8, vel:0.70),(degree:7, vel:0.60),(degree:5, vel:0.85),nil,(degree:5, vel:0.60),nil,(degree:8, vel:0.75),nil,(degree:5, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:5, vel:0.60),nil,(degree:12, vel:0.85),nil]), (octave:-1, steps:[(degree:2, vel:0.85),(degree:2, vel:0.50),(degree:2, vel:0.60),nil,(degree:5, vel:0.75),(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:2, vel:0.60),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),(degree:5, vel:0.70),(degree:4, vel:0.60)]), (octave:-1, steps:[(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:-1, steps:[(degree:0, vel:0.58),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil,(degree:5, vel:0.54),nil,(degree:0, vel:0.41),nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.68),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.64),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:5, vel:0.60),nil,(degree:12, vel:0.68),nil,nil,nil,(degree:9, vel:0.60),nil,nil,nil,(degree:10, vel:0.64),nil,nil,nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:9, vel:0.68),(degree:9, vel:0.50),nil,(degree:9, vel:0.50),(degree:6, vel:0.60),nil,(degree:6, vel:0.50),nil,(degree:7, vel:0.64),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:2, vel:0.60),(degree:2, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.88),(degree:14, vel:0.92),(degree:14, vel:0.88),(degree:13, vel:0.92),(degree:13, vel:0.88)]), (octave:2, steps:[(degree:7, vel:0.46),nil,nil,nil,(degree:4, vel:0.41),nil,nil,nil,(degree:5, vel:0.44),nil,nil,nil,(degree:2, vel:0.41),nil,nil,nil]), (octave:2, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.66),(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.72),(degree:7, vel:0.76),(degree:5, vel:0.66),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.76),(degree:5, vel:0.72),(degree:4, vel:0.66),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:9, vel:0.66),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.60),(degree:8, vel:0.60),(degree:10, vel:0.66),(degree:5, vel:0.75),(degree:8, vel:0.60),(degree:10, vel:0.72),(degree:12, vel:0.76),(degree:10, vel:0.66),(degree:8, vel:0.60),(degree:7, vel:0.60),(degree:5, vel:0.70),(degree:12, vel:0.76),(degree:10, vel:0.72)]), (octave:1, steps:[(degree:2, vel:0.70),(degree:4, vel:0.60),(degree:5, vel:0.60),(degree:7, vel:0.66),(degree:2, vel:0.75),(degree:5, vel:0.60),(degree:7, vel:0.72),(degree:9, vel:0.76),(degree:7, vel:0.66),(degree:5, vel:0.60),(degree:4, vel:0.60),(degree:2, vel:0.70),(degree:9, vel:0.76),(degree:7, vel:0.72),(degree:6, vel:0.66),(degree:4, vel:0.60)]), (octave:1, steps:[(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:13, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92),(degree:14, vel:0.92)]), (octave:1, steps:[(degree:0, vel:0.48),(degree:2, vel:0.41),nil,nil,nil,nil,nil,nil,(degree:5, vel:0.45),(degree:3, vel:0.41),nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),nil,nil,nil,(degree:0, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil,(degree:4, vel:0.70),nil,nil,nil])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.50),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.50),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.50),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.50),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.50),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,4,4,4,4,4,4,4,4,4,4,4,4,4,4,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,4,4,4,4,4,4,4,4,4,4,4,4,4,4,6,6],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \lp_pluck, \lp_pluck, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00)]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:3, vel:0.50),nil]), (octave:-1, steps:[(degree:7, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:7, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.70),nil,(degree:3, vel:0.60),nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),(degree:3, vel:0.50),(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.90),(degree:0, vel:0.50),(degree:14, vel:0.80),(degree:0, vel:0.50),(degree:10, vel:0.70),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:0, vel:0.90),(degree:7, vel:0.50),(degree:5, vel:0.70),(degree:10, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.80),(degree:2, vel:0.60),(degree:7, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),(degree:7, vel:0.50),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:4, vel:0.80),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:7, vel:0.85),nil,(degree:7, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:14, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:12, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,(degree:7, vel:0.60),(degree:9, vel:0.85),(degree:9, vel:0.60),nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,(degree:7, vel:0.60),nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.60),(degree:8, vel:0.70),(degree:9, vel:0.80),(degree:10, vel:0.90)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:3, vel:0.80),(degree:3, vel:0.50),(degree:0, vel:0.60),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:0, vel:0.60),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:7, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:12, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:14, vel:0.85),nil,(degree:3, vel:0.70),(degree:9, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:4, vel:0.50),nil,(degree:5, vel:0.65),(degree:5, vel:0.50),nil,(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:0, vel:0.60),(degree:0, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:12, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,(degree:7, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:5, vel:0.65),nil,(degree:5, vel:0.60),nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,nil,nil,nil,nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:7, vel:0.75),(degree:3, vel:0.60),(degree:12, vel:0.70),(degree:7, vel:0.75),(degree:12, vel:0.65),(degree:3, vel:0.60),(degree:9, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.75),(degree:5, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:10, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:5, vel:0.60),(degree:6, vel:0.70),(degree:7, vel:0.80),(degree:8, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
(
|
||||
'grid': [
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,6,6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,4,4,4,4,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,1,1,1,1,1,1,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,5,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
'instruments': [ \tr909_kick, \tr909_hat, \tr909_clap, \lp_perc, \lp_sub, \tb303, \lp_pluck, \lp_pluck, \lp_pluck, \lp_pad, \tr909_ride, \lp_rim, \lp_tom, \lp_reese, \lp_bells, \lp_sweep, \lp_pluck, \lp_pad, \lp_fx, \lp_snare, \lp_crash, \tr909_hat ],
|
||||
'colorDefs': [
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00)]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.85),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:3, vel:0.50),nil]), (octave:-1, steps:[(degree:7, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:7, vel:0.85),nil,nil,nil,(degree:3, vel:0.70),nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.70),nil,(degree:3, vel:0.60),nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,nil,nil,(degree:0, vel:0.60),nil,nil,nil,(degree:0, vel:0.85),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),(degree:3, vel:0.50),(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.90),(degree:0, vel:0.50),(degree:14, vel:0.80),(degree:0, vel:0.50),(degree:10, vel:0.70),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:0, vel:0.90),(degree:7, vel:0.50),(degree:5, vel:0.70),(degree:10, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.80),(degree:2, vel:0.60),(degree:7, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.80),(degree:2, vel:0.60),(degree:0, vel:0.50)]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:7, vel:0.80),(degree:0, vel:0.50),(degree:3, vel:0.70),nil,(degree:7, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.90),(degree:0, vel:0.50),(degree:5, vel:0.70),(degree:3, vel:0.60),(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:11, vel:0.70),(degree:7, vel:0.75),(degree:16, vel:0.80),(degree:7, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60)]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:9, vel:0.80),(degree:7, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:4, vel:0.70),(degree:7, vel:0.75),(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),(degree:7, vel:0.50),nil,(degree:9, vel:0.85),(degree:9, vel:0.50),nil,(degree:7, vel:0.80),(degree:7, vel:0.50),(degree:4, vel:0.80),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:7, vel:0.85),nil,(degree:7, vel:0.50),nil]), (octave:1, steps:[(degree:14, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:14, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:12, vel:0.80),nil,(degree:7, vel:0.85),nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,(degree:7, vel:0.60),(degree:9, vel:0.85),(degree:9, vel:0.60),nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.85),nil,(degree:7, vel:0.60),nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:7, vel:0.90),nil,nil,(degree:9, vel:0.85),nil,nil,(degree:7, vel:0.80),nil,(degree:4, vel:0.80),nil,(degree:5, vel:0.80),nil,(degree:7, vel:0.60),(degree:8, vel:0.70),(degree:9, vel:0.80),(degree:10, vel:0.90)])],
|
||||
[(octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.80),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:7, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50)]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,(degree:0, vel:0.80),nil,nil,(degree:0, vel:0.80),nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),(degree:0, vel:0.50),(degree:0, vel:0.60),nil,(degree:3, vel:0.80),(degree:3, vel:0.50),(degree:0, vel:0.60),nil,(degree:5, vel:0.80),(degree:5, vel:0.50),(degree:0, vel:0.60),nil,(degree:7, vel:0.85),(degree:7, vel:0.50),(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:7, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:10, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:12, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:14, vel:0.85),nil,(degree:3, vel:0.70),(degree:9, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:7, vel:0.85),nil,(degree:3, vel:0.70),(degree:2, vel:0.60)]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,nil,nil,nil,nil,nil,nil]), (octave:-1, steps:[(degree:0, vel:0.85),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:5, vel:0.80),nil,(degree:0, vel:0.60),nil,(degree:2, vel:0.60),(degree:3, vel:0.70),(degree:4, vel:0.80),(degree:5, vel:0.90)])],
|
||||
[(octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),(degree:7, vel:0.50),nil,(degree:7, vel:0.50),(degree:4, vel:0.60),nil,(degree:4, vel:0.50),nil,(degree:5, vel:0.65),(degree:5, vel:0.50),nil,(degree:5, vel:0.50),(degree:2, vel:0.60),nil,(degree:0, vel:0.60),(degree:0, vel:0.50)]), (octave:2, steps:[(degree:14, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:12, vel:0.65),nil,nil,nil,(degree:2, vel:0.60),nil,(degree:7, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,(degree:7, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:4, vel:0.60),nil,(degree:5, vel:0.65),nil,(degree:5, vel:0.60),nil,(degree:2, vel:0.60),nil,(degree:0, vel:0.60),nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,nil,nil,nil,nil]), (octave:2, steps:[(degree:7, vel:0.70),nil,nil,nil,(degree:4, vel:0.60),nil,nil,nil,(degree:5, vel:0.65),nil,nil,nil,(degree:0, vel:0.60),(degree:1, vel:0.70),(degree:2, vel:0.80),(degree:3, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30)]), (octave:0, steps:[nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:7, vel:0.75),(degree:3, vel:0.60),(degree:12, vel:0.70),(degree:7, vel:0.75),(degree:12, vel:0.65),(degree:3, vel:0.60),(degree:9, vel:0.60),(degree:0, vel:0.70),(degree:14, vel:0.75),(degree:5, vel:0.70),(degree:11, vel:0.65),(degree:2, vel:0.60),(degree:7, vel:0.70),(degree:2, vel:0.60),(degree:10, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:2, vel:0.60),(degree:3, vel:0.60),(degree:5, vel:0.65)]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),nil,nil,nil,nil,nil,nil,nil,nil]), (octave:1, steps:[(degree:0, vel:0.75),(degree:3, vel:0.60),(degree:5, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.65),(degree:3, vel:0.60),(degree:2, vel:0.60),(degree:0, vel:0.70),(degree:7, vel:0.75),(degree:5, vel:0.70),(degree:4, vel:0.65),(degree:2, vel:0.60),(degree:5, vel:0.60),(degree:6, vel:0.70),(degree:7, vel:0.80),(degree:8, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),nil,(degree:0, vel:0.50),(degree:3, vel:0.60),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil,(degree:3, vel:0.50),nil]), (octave:0, steps:[(degree:7, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:0, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil,(degree:3, vel:0.60),nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.60),nil,nil,nil,nil,nil,nil,nil,(degree:3, vel:0.60),nil,nil,nil,(degree:3, vel:0.60),(degree:4, vel:0.70),(degree:5, vel:0.80),(degree:6, vel:0.90)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:1.00),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:1.00),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil]), (octave:0, steps:[(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:0.80),nil,(degree:0, vel:1.00),nil,(degree:0, vel:0.80),nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.30),nil,nil,nil]), (octave:0, steps:[nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil,nil]), (octave:0, steps:[(degree:0, vel:1.00),nil,nil,nil,nil,nil,nil,nil,(degree:0, vel:1.00),nil,nil,nil,(degree:0, vel:0.80),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])],
|
||||
[(octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:0.30),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil,(degree:0, vel:0.30),(degree:0, vel:0.80),nil,(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80),(degree:0, vel:0.80)]), (octave:0, steps:[(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil,(degree:0, vel:0.30),nil]), (octave:0, steps:[(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:0.80),(degree:0, vel:0.30),(degree:0, vel:1.00),(degree:0, vel:1.00),(degree:0, vel:1.00)])]
|
||||
],
|
||||
'voiceMods': [ [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \res, depth: 0.40), (source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyY, target: \pitch, depth: 0.30), (source: \bodyVitesse, target: \amp, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \cutoff, depth: 0.50), (source: \bodyY, target: \pitch, depth: 0.30), (source: \limbSpan, target: \res, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \rHandY, target: \pitch, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \rHandX, target: \amp, depth: 0.70), (source: \lHandY, target: \cutoff, depth: 0.70), (source: \lHandX, target: \res, depth: 0.70)], [(source: \bodyVitesse, target: \cutoff, depth: 0.40), (source: \limbSpan, target: \rev, depth: 0.40), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \danse, target: \rev, depth: 0.50), (source: \bodyVitesse, target: \amp, depth: 0.40)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)], [(source: \bodyVitesse, target: \rev, depth: 0.30), (source: \bodyX, target: \pan, depth: 0.30)] ],
|
||||
'voicePoses': [ [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [], [] ],
|
||||
'globalActions': [ \fill, \doubleTime, \washReverb, \muteDrums, \breakdown, \halfTime, \next, \muteMelo ],
|
||||
'globalSettings': ( 'transientBars': 1, 'breakdownKeep': [ \kick, \sub ] ),
|
||||
'pinchHoldMs': 300,
|
||||
'fistThresh': 0.15
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
(
|
||||
'grid': [
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0],
|
||||
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
'instruments': [ \default, \default, \default, \default, \default, \default, \default, \default, \default, \juno, \default, \default, \default, \default, \dx7, \lp_sweep, \supersaw, \default, \default, \default, \default, \default ]
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user