236 Commits

Author SHA1 Message Date
L'électron rare 8cfa0694a7 feat(web): tempo + master steppers on matrix tab
CI build oscope-of / build-check (push) Has been cancelled
Same -/+ stepper controls duplicated on the MATRICE tab; the wiring is
factorized into bindStepGroup so both tabs' sliders and readouts stay
in sync while driving the same OSC addresses.
2026-07-02 22:43:02 +02:00
L'électron rare 1fe186bdb8 feat(web): master gain slider with -/+ steppers
CI build oscope-of / build-check (push) Has been cancelled
The web surface had no master control at all (SC-only via
/control/doMaster 0..2). Adds the slider next to Tempo with 0.05
steppers and a live readout, same pattern as the tempo control.
2026-07-02 22:39:47 +02:00
L'électron rare d058858b23 feat(web): tempo -/+ stepper buttons
CI build oscope-of / build-check (push) Has been cancelled
One-BPM steps beside the tempo slider (fine adjustment is hard on a
touch slider mid-performance) plus a live numeric readout; both paths
share the same /launch/tempo send.
2026-07-02 22:38:16 +02:00
L'électron rare 144d55a7fa feat(matrix): MD-index pinch slot = breakdown
CI build oscope-of / build-check (push) Has been cancelled
User request live: right-hand index pinch fires breakdown instead of
relanceEvolve — SC default, generator default, web defaults, the 196
presets and the test pins all updated. relanceEvolve stays in the
assignable vocabulary.
2026-07-02 21:44:40 +02:00
L'électron rare 0bc15e7000 feat(matrix): composed live variants
CI build oscope-of / build-check (push) Has been cancelled
Regenerate 112 *_live-[1-4].matrix with the composition engine.
84 intro/live/outro originals untouched. All 6 preset tests pass.
2026-07-02 21:22:12 +02:00
L'électron rare 0e5c4df3d7 feat(matrix): melodic composition engine
7 style families x 8 melodic roles x 6 composition stages. Chord
progressions (i-VI-III-VII etc.) aligned across all voices per colour;
call-response via +4-step offset for melody/stab vs arp/acid. Pure
deterministic transforms: rotate, ornament, climax (+7 oct lift),
sparse, resolve-to-anchors. New MEL[role]["composed_<style>"] entries;
existing hand-authored families untouched.
2026-07-02 21:22:05 +02:00
L'électron rare e71ae814f7 feat(viz): hold last X/Y markers when hand drops
CI build oscope-of / build-check (push) Has been cancelled
SC's mod cache keeps the last received value when a hand vanishes, but
the gauge markers and wrist cross disappeared — the screen no longer
showed what the matrix was hearing. Markers now hold the last known
position (dim conf 0.45) and go full-bright when the hand is live.
2026-07-02 20:43:14 +02:00
L'électron rare ba5db3237e feat(matrix): 112 live variant presets
CI build oscope-of / build-check (push) Has been cancelled
Context: live sets needed variety; the single *_live.matrix per style
gave no choice during a performance.

Approach: 28 styles x 4 variants each produced by
generate_live_variant_sc() with deterministic era kits and family
overrides.

Changes:
- Add *_live-1..4.matrix for every style (112 files)
- v1 house/M1: m1_organ/m1_piano/dx7_epiano on chord/bells/melody
- v2 acid/hoover: tb303 on acid/arp, hoover on lead/reese
- v3 virus/nord: virus_saw on lead/arp, nord_lead on melody/arp
- v4 raw 909/rave: tr909_snare on snare, rave_stab/brass_stab on stab
- Each variant uses a distinct pattern family and busy-block profile

Impact: 196 total .matrix presets; SC engine discovers all via ls scan.
2026-07-02 20:28:04 +02:00
L'électron rare c8318cecc2 feat(matrix): live variant generator
Add generate_live_variant_sc() to produce 4 deterministic live
variants per style (112 files). Each variant differs by:
  - pattern family (VARIANT_FAMILIES per style group)
  - era instrument kit overlay (house/M1, acid/hoover, virus/nord,
    raw 909/rave) merged from VARIANT_ERA_KITS
  - busy-block recolour config (VARIANT_BUSY, 4 distinct profiles)

Also: fix generator defaults to match live state:
  - globalActions: fill/doubleTime/washReverb/muteDrums/relanceEvolve/
    halfTime/next/muteMelo (was fill/drop/breakdown/evolve/...)
  - hand-mod depths 0.70 (was 0.40/0.50 for rHandX/rHandY/lHandX/lHandY)
  - INST_CHOICES synced with matrix.scd ~matInstChoices (10 new vintage
    synths: tr909_snare hoover m1_piano m1_organ dx7_epiano brass_stab
    nord_lead virus_saw rave_stab reese_juno)

Update test_global_actions.py to expect new 8-action mapping.
Add test_live_variants.py: 112 files, legal instruments, distinct
variants, determinism.
2026-07-02 20:27:36 +02:00
L'électron rare 876afc0cf9 feat(matrix): wire vintage synths into voices
Add hoover/nord_lead/virus_saw to arp+lead, brass_stab/rave_stab to
stab, m1_piano/m1_organ/dx7_epiano to chord+sub+bells+melody,
reese_juno to sub+pad+reese, tr909_snare to clap+snare.
Mirror changes in web UI MATRIX_INST_CHOICES.
2026-07-02 20:14:42 +02:00
L'électron rare a6556e809a feat(sound): vintage 90s synthdefs
Add 10 classic machine emulations for the matrix:
tr909_snare, hoover, m1_piano, m1_organ, dx7_epiano,
brass_stab, nord_lead, virus_saw, rave_stab, reese_juno.
All note-terminating; cutoff+res args on res-voice targets.
2026-07-02 20:14:36 +02:00
L'électron rare 51a3412158 feat(viz): bold hand overlay strokes
CI build oscope-of / build-check (push) Has been cancelled
Same concentric-offset technique as the body skeleton, one step
thinner (SKEL_LINE_WIDTH - 1) so small far hands do not blob.
2026-07-02 19:34:06 +02:00
L'électron rare 69c560dfe6 feat(viz): bold body skeleton strokes
CI build oscope-of / build-check (push) Has been cancelled
Metal lines are 1px and the skeleton was barely visible in
performance. Each ARKit bone is now drawn in concentric offset passes
(SKEL_LINE_WIDTH, default 4 approx px; 1 restores the thin look).
2026-07-02 18:57:36 +02:00
L'électron rare 7b039fbb78 feat(viz): fixed default viz mode (voronoi on t)
CI build oscope-of / build-check (push) Has been cancelled
VIZ_DEFAULT_MODE=<name> boots the viz on that mode and disables the
auto-openpos switch entirely, so the mode is truly always active (user
request: the t-key voronoi effect permanent). Concert launcher defaults
to voronoi; "auto" keeps the historical behavior.
2026-07-02 18:54:48 +02:00
L'électron rare d970f4cbe9 docs(viz): portrait orientation validated, doc fixed
CI build oscope-of / build-check (push) Has been cancelled
The module doc still mandated landscape (2026-06-30 ARKit constraint);
portrait was validated live 2026-07-02 and is now the concert default.
Document the rotation split (video ccw, 2D points already upright).
2026-07-02 18:49:23 +02:00
L'électron rare 8d6d42ed3c feat(pose): wrist-anchored xy mods + wrist markers
CI build oscope-of / build-check (push) Has been cancelled
The X/Y voice mods were anchored to the 21-landmark centroid, so
opening or pinching the fingers shifted the mod values (gesture/mod
cross-talk). cx/cy now track the WRIST (kp 0). The renderer draws a
cross marker at each wrist on the video whenever features exist, so
the control point stays visible even for far hands the overlay
filters out (user request live 2026-07-02).
2026-07-02 18:47:57 +02:00
L'électron rare fbc6d4efa3 chore: drop preset pycache from git
The deepen-bindings commit accidentally staged the preset generator's
__pycache__ directory (compiled .pyc artifacts). Untrack the files and
add a local .gitignore so preset regeneration never stages them again.
2026-07-02 18:46:37 +02:00
L'électron rare 1302ffd87a feat(matrix): deepen hand mod bindings to 0.70
CI build oscope-of / build-check (push) Has been cancelled
User request live: more audible depth on the hand X/Y mods. All 1848
lHand/rHand X/Y bindings across the 84 presets raised from 0.40-0.50
to 0.70 (per-voice editable in the web UI as before).
2026-07-02 18:44:10 +02:00
L'électron rare 720d566294 feat(concert): portrait video + pinch on defaults
CI build oscope-of / build-check (push) Has been cancelled
Session-validated setup becomes the launcher default so app-icon
launches match: VIDEO_ROTATE=ccw (iPhone portrait) and PINCH_ENABLE=1
(full gate chain validated live 2026-07-02).
2026-07-02 18:38:35 +02:00
L'électron rare 1d59d68777 feat(viz): gate numeric source keys too
CI build oscope-of / build-check (push) Has been cancelled
0-9 preset bundles join wxcvbn behind VIZ_SOURCE_KEYS (default off) —
the whole source row is now perf-safe.
2026-07-02 18:33:39 +02:00
L'électron rare 8d93163766 feat(viz): gate audio-scene keys off by default
CI build oscope-of / build-check (push) Has been cancelled
qsdfghjkl fired auto-synth scenes on stray keypresses during matrix
performance — same gate as wxcvbn (VIZ_AUDIO_KEYS=1 re-enables). The
m matrix-mode shortcut (scene stop + openpos) stays active.
2026-07-02 16:53:27 +02:00
L'électron rare 6d43120c0a feat(viz): m key = matrix mode, fix test pins
CI build oscope-of / build-check (push) Has been cancelled
m now stops the auto data-synth scene AND forces the openpos viz
(matrix unaffected — it is not a scene). Repairs the two test pins the
frozen pinch defaults broke (ext_min 1) and makes the skeleton path
safe for __new__-built renderers (hand gate getattr).
2026-07-02 16:49:16 +02:00
L'électron rare 1c07211dd8 feat(viz): perf-safe keys, frozen pinch tuning
CI build oscope-of / build-check (push) Has been cancelled
Live session outcomes: (1) pinch thresholds validated for inner
fingers frozen as defaults (ratio_on 0.50, margin 0.05, ext_min 1);
(2) panel frames + X/Y gauges now permanent (no early-return when
nothing is detected); (3) wxcvbn source-bundle shortcuts gated off by
default (VIZ_SOURCE_KEYS=1 re-enables) — a stray key mid-performance
switched the audio scene.
2026-07-02 16:44:28 +02:00
L'électron rare 6fe98c6b00 feat(viz): continuous frame quality intensity
CI build oscope-of / build-check (push) Has been cancelled
Add gesture_quality() pure helper (hand_display.py) that maps a slot's
plausibility, facing, and proximity to a score in [0,1]. Renderer uses
it to modulate panel frame brightness (0.25+0.75*q) and stroke count
(1..3 passes at q≥0, ≥0.5, ≥0.85 / status==3). Status still drives
hue (pid 7/8/9). New gesture_slot_quality field in State written
alongside gesture_slot_status each tick.

Add gauge_segments() for X/Y position gauges around each panel: a
horizontal rail below and a vertical rail on the outer side, each with
a bold two-tick notch at the hand's normalised cx/cy (mirror-aware for
X). Dim rails (conf 0.25) when slot absent. Renderer reads hand_feats
L/R directly from state.

11 new tests (6 gesture_quality + 5 gauge_segments); suite 430 passed.
2026-07-02 16:34:08 +02:00
L'électron rare 32a722e281 feat(viz): gesture status panel indicators
CI build oscope-of / build-check (push) Has been cancelled
Add gesture_slot_status [0..3] to State (absent/detected/armed/engaged).
PinchDetector.engaged_slots() exposes per-slot pinch state. Publisher
writes status after each tick via _update_gesture_slot_status().
Renderer draws panel frame always: dim (pid7 conf=0.3) when absent,
normal (pid7) detected, pid8 armed, pid9 double-stroke engaged.
2026-07-02 15:16:14 +02:00
L'électron rare eb3803680b feat(pose): facing gate for gestures
Add hand_facing() palm-spread metric (dist(kp5,kp17)/hand_size).
Gate GestureSlotStabilizer slot activation on facing >= HAND_FACE_MIN
(default 0.5) with 0.8x hysteresis; side-on palms no longer arm
pinch/strike. Add VizConfig.hand_face_min + HAND_FACE_MIN env var.

Update test fixtures: _near_hand() now spreads kp[5]/kp[17] so
existing tests keep passing through both near+face gates.
2026-07-02 15:16:01 +02:00
L'électron rare bcf7ac180e chore(matrix): migrate presets to new mapping
CI build oscope-of / build-check (push) Has been cancelled
Update globalActions in all 84 .matrix preset files from old
default to: [\fill, \doubleTime, \washReverb, \muteDrums,
\relanceEvolve, \halfTime, \next, \muteMelo].
2026-07-02 14:56:50 +02:00
L'électron rare a569122f14 feat(matrix): drum feel + relance actions
Add \doubleTime and \halfTime as drum-only subdivision feel toggles
(\matDrumFeel \normal/\double/\half, mutually exclusive). Engaging a
feel re-sources only drum voices (matLastColor reset + matApplyBar).
The \relanceEvolve composite action seeks to bar 0 then fires
matRelanceEvolve. \matDrumFeelPush sends /matrix/drumfeel on toggle
and on preset load (resets to \normal). OSCdef mat_drumfeel_get added.
Drum feel stretch applied in matVariationOverlay + matColorStepPattern
via txStretch local var; non-drum voices use matTxStretch unchanged.
Web vocab: relanceEvolve added, evolve relabelled Évolue.
Test file extended with sections R (feel toggle) and S (relanceEvolve).
2026-07-02 14:56:07 +02:00
L'électron rare feb32938fb fix(viz): skeleton points not rotated by default
CI build oscope-of / build-check (push) Has been cancelled
Live: skeleton still misrotated after the conjugation fix. Both app
2D streams (ARKit skeleton2D + Vision hands) arrive already uprighted
in portrait — the interface orientation drives the projection. Points
are no longer rotated by default; VIDEO_ROTATE only turns the video.
IPHONE_SKEL_ROTATE / IPHONE_HANDS_ROTATE remain as scan knobs.
2026-07-02 14:53:28 +02:00
L'électron rare c878056733 fix(viz): separate hands rotation from skeleton
CI build oscope-of / build-check (push) Has been cancelled
Live report: skeleton straight but hands off the wrists. The app runs
Vision with a hardcoded .right orientation (ARBodySession.swift:261),
so hand coords live in an uprighted frame while ARKit skeleton2D lives
in the raw sensor frame. Hands get their own IPHONE_HANDS_ROTATE knob
(default none); VIDEO_ROTATE now only rotates the skeleton2D points.
2026-07-02 14:47:05 +02:00
L'électron rare 3199aa326d feat(matrix): remove fist gestures
SC handlers (edge detector, tick, OSCdefs, pose/hands calls) and web
UI (Poings section, sensibilite poing slider, fistprogress indicators)
removed. Python FIST_ENABLE=0 kill-switch was already in effect.
Old .matrix files with fistThresh key load without error (key silently
ignored). Test harness Q section removed from test_global_actions.scd.
2026-07-02 14:42:43 +02:00
L'électron rare c66c4271fa fix(viz): mirror-conjugate point rotation
CI build oscope-of / build-check (push) Has been cancelled
Live report: video straight but skeleton rotated wrong. Flip and
90-degree rotation do not commute — the video is flipped THEN rotated
while points are rotated THEN display-flipped. Points now use the
mirror-conjugated mode (cw/ccw swapped under mirror), proven by a
composition test.
2026-07-02 14:41:57 +02:00
L'électron rare ef1d557eff feat(viz): rotate iphone 2d points, hide fingers
CI build oscope-of / build-check (push) Has been cancelled
Portrait mode validated live: VIDEO_ROTATE now also rotates the
iPhone 2D landmarks (skeleton2D + Vision hands) at the decode layer so
overlays/panels/gestures stay aligned with the rotated video. ARKit
per-finger joints are masked from the wireframe (unreliable 2D, seen
as false hands); the display shows the filtered Vision hands instead.
2026-07-02 14:36:57 +02:00
L'électron rare 3ffd81afc7 feat(pose): validate hands before analysis
Apply hand_plausible + HandPersistenceGate in _step_stab before
route_hands so gestures and /pose/hands features only see quality-
gated hands. Cache per-tick validated set (_tick_hands/_tick_chir/
_tick_mirror) so all three emitters share one acquisition. Gate
stepped at 30 Hz publisher cadence (audit R6). Non-protocol hands
(raw numpy arrays) pass through via try/except for backward compat.
2026-07-02 14:35:03 +02:00
L'électron rare 65f13b4621 test(pose): gesture input validation gates
Tests (a-f) for plausibility gate, persistence establishment,
chirality alignment, feature path, MediaPipe pass-through.
Update helpers with .x/.y/.c _Kp objects; add HAND_PERSIST_FRAMES=1
to legacy _pub()/_pub_envs() so existing tests need no warmup ticks.
2026-07-02 14:34:53 +02:00
L'électron rare b85a9c93a9 fix(viz): no body mesh when arkit is the source
CI build oscope-of / build-check (push) Has been cancelled
The body-mesh triangles (solid cyan blob reported live) were the one
surface no filter covered: under iphone-usb persons_body is the ARKit
conversion with c=1.0, so garbage/stale joints painted full triangles.
Skip BODY_TRIANGLES whenever the ARKit topology is present (webcam
MediaPipe path unchanged); the body stays the freshness-gated ARKit
wireframe.
2026-07-02 14:26:03 +02:00
L'électron rare 651fd86d37 docs(viz): fix config docstrings + env tables
config.py: PINCH_EXT_MIN docs said "min frames for debounce"
— it is min extended fingers required to fire; fix wording.
HAND_PERSIST_FRAMES said "persist after disappearance" — it
is frames required before a hand is drawn; fix wording.

CLAUDE.md: MEDIAPIPE_DELEGATE default was "cpu" — real
default is "gpu" (config.py); fix the RC0.1+ table row.
Add missing PINCH_ENABLE / VIZ_HUD / CONCERT_MIRROR rows
to the main env-vars table.
2026-07-02 14:24:01 +02:00
L'électron rare d337ed9b29 fix(pose): clear iphone hands on stream drop
iphone_usb_source.py: after stream ends, clear
persons_hands_iphone, persons_hands_chirality, and
persons_hands (when write_hands=True) under state.lock()
to prevent renderer display freeze.

test_iphone_usb_reconnect.py: add
test_hands_cleared_on_stream_drop to verify lists are
empty after simulated USB disconnect.
2026-07-02 14:23:53 +02:00
L'électron rare 0126fc97a8 fix(viz): wire arkit-full + osc port via config
renderer.py: read ARKIT_FULL_SKELETON via VizConfig at
initWithState_ instead of module-level os.environ; store as
self._arkit_full, use getattr fallback for __new__ test bypass.

main.py: pass cfg.iphone_osc_port to IphoneOSCListener
instead of hardcoded 57128; log actual port.

hand_slots.py: import _finite/_coord/_clamp from hand_features
at module level (acyclic: hand_features imports hand_slots only
lazily inside step()); delete the duplicate copies.
2026-07-02 14:23:47 +02:00
L'électron rare 4ea0bb2c2a feat(pose): fist kill-switch, default off
CI build oscope-of / build-check (push) Has been cancelled
User request live 2026-07-02: disable the fist gesture completely
(false held-fist actions during play). fist is forced to 0.0 at the
extractor unless FIST_ENABLE=1; continuous features unchanged. SC
held-fist actions (reload/next) go silent without touching SC.
2026-07-02 14:22:31 +02:00
L'électron rare ba4a1bb103 perf(pose): 30hz gesture loop under iphone-usb
CI build oscope-of / build-check (push) Has been cancelled
iphone-usb path: camera decode + ARKit conversion is cheap (no
MediaPipe inference on Mac). Frame-based gesture calibrations
(debounce/hold/grace/persist) assume 30 Hz. Keep 18 Hz for the
webcam MediaPipe path (M5 CPU protection).
Tests: period formula + MultiWorker.__init__ signature check.
2026-07-02 14:13:52 +02:00
L'électron rare 05d0fc1df1 fix(pose): mirror-coherent gesture fallback
Read state.mirror_2d in _pick_hands and thread it through
_step_stab (route_hands mirror=) and _emit_hands
(HandFeatureExtractor.step mirror=) so the cx fallback slot
assignment matches the renderer panel layout on chirality dropout.

Add mirror kwarg to HandFeatureExtractor.step plumbed to route_hands.
Tests: extractor-level mirror flip + publisher _emit_hands mirror_2d.
2026-07-02 14:13:36 +02:00
L'électron rare caa41fd7f1 test(viz): pin stabilizer defaults, fix seams
CI build oscope-of / build-check (push) Has been cancelled
Detection v3 review follow-ups: pin hand_persist_grace/near_off/
hold_frames defaults; use the module-level route_hands (restores the
monkeypatch seam, drops the dead local alias); document the grace
count-inheritance window in the gate docstring.
2026-07-02 12:00:28 +02:00
L'électron rare e10361c56d docs(pose): fix stale swap note, pin mesh rig
CI build oscope-of / build-check (push) Has been cancelled
hand_slots module docstring still claimed inverted chirality (default
1) after the live A/B validated chirality correct (default 0); align
it. Pin the MESH_RIG ne0 bool convention (chantier 5 review minor).
2026-07-02 11:52:20 +02:00
L'électron rare a7a1563ef8 docs: detection v3 env vars 2026-07-02 11:49:32 +02:00
L'électron rare 5769af3d26 feat(pose): fist near + established gates 2026-07-02 11:48:58 +02:00
L'électron rare f3ca0ac100 feat(pose): gesture slot stabilizer 2026-07-02 11:48:01 +02:00
L'électron rare 4e33a03e1b feat(viz): persistence grace frames 2026-07-02 11:46:01 +02:00
L'électron rare 8b08282aa5 fix(viz): remote log vars + avbody host param
Chantier 5 review: NameError on MULTIHMR_BACKEND=remote (LOG.info
referenced removed locals) and SMPLXTCPSender host param silently
ignored after the VizConfig migration (env-present check restores the
pre-migration contract).
2026-07-02 11:40:55 +02:00
L'électron rare 91b3923431 docs(viz): chantier-5 report + HAND_SWAP_LR doc
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 11:33:42 +02:00
L'électron rare e2a41c0d8a test(viz): VizConfig and iphone-usb gating tests
61 tests: all VizConfig defaults, four bool conventions,
numeric cast fallback, monkeypatch round-trip, and Part B
gating (_ensure_model never called under --iphone-usb).
Fix hand_swap_lr from_env default: was True, now False.
2026-07-02 11:32:24 +02:00
L'électron rare 65bf3aad08 refactor(viz): remaining consumers read VizConfig
pose_filter _parse_env_* read POSE_FILTER* via VizConfig.
multi_hmr_worker reads MULTIHMR_BACKEND/AUTOCAST/REMOTE via VizConfig.
multihmr_remote reads JPEG/ASYNC/HOST/PORT via VizConfig.
smplx_osc_sender reads AVBODY_HOST/REID/ALPHA via VizConfig.
pose_bridge reads AVBODY_HOST/VDMX_* via VizConfig.
iphone_usb_source reads CONCERT_MIRROR via VizConfig.
lidar_calib reads ICP_LIDAR_EXTRINSIC via VizConfig.
multihmr_coreml reads COREML_COMPUTE_UNITS via VizConfig.
2026-07-02 11:26:57 +02:00
L'électron rare c0cde337c9 refactor(viz): consumers read VizConfig
multi.py reads 5 discrimination thresholds via VizConfig.
main.py reads ICP/Multi-HMR/AV_LIVE_* flags via VizConfig.
2026-07-02 11:24:19 +02:00
L'électron rare e7ce016688 feat(viz): central VizConfig from env
Add data_only_viz/config.py: frozen dataclass VizConfig with typed
fields for all 56 env reads in data_only_viz. from_env() reads
os.environ at call time — no module-level singleton, test-compat.

Migrate action_head_pub._init_finger_piano and renderer.initWithState_
to consume VizConfig. Keep renderer.py ARKIT_FULL module-level (test
uses MetalRenderer.__new__ without calling initWithState_).

HAND_SWAP_LR default changed False (was True): Vision chirality
validated correct live 2026-07-02; swap knob now defaults off.
2026-07-02 11:21:55 +02:00
L'électron rare 5d425b1fbd fix(pose): plumb swap into hand features slots
CI build oscope-of / build-check (push) Has been cancelled
Review C1 (Critical): /pose/hands per-voice L/R mods routed without
the HAND_SWAP_LR swap while pinch/strike used it — the two paths
contradicted each other on an inverted-chirality source. step() now
takes swap, the publisher passes its env-read value, two tests pin
the contract. Also hoists the route_hands imports (M5) and fixes the
stale slot comment (M1).
2026-07-02 11:09:06 +02:00
L'électron rare 32cdfa0d3e fix(pose): swap chirality only, default on
CI build oscope-of / build-check (push) Has been cancelled
User-confirmed live: ARBodyTracker Vision chirality arrives inverted
vs the mirrored view. HAND_SWAP_LR now inverts the chirality mapping
inside route_hands (cx fallback untouched, it is already screen
relative) and defaults to 1. Also: HandPersistenceGate greedy-match
limitation documented + malformed-entry guard (review follow-ups).
2026-07-02 11:00:25 +02:00
L'électron rare e20a4d130a docs: hand router env vars 2026-07-02 10:55:38 +02:00
L'électron rare 366d916e9d refactor(pose): detectors consume routed slots
Convert all four consumers to accept pre-routed [L|None, R|None]:
- finger_strike: remove order_hands_by_cx + _slot_hands; step()
  iterates enumerate(slotted), None slot resets state / synthesizes
  release edges in-place.
- hand_features: step(hands, chirality=None) delegates to route_hands;
  speed buffer indexed by slot, not cx order.
- action_head_pub: _pick_hands returns chirality; _emit_fingers/
  _emit_pinch call route_hands with HAND_SWAP_LR/HAND_NEAR_MIN;
  _emit_hands plumbs chirality to HandFeatureExtractor.
- renderer: panels block replaced with route_hands call; persistence
  gate + hand_plausible mask applied before routing so a ghost hand
  cannot steal a slot; HAND_SWAP_LR read at init.
Test updates: [hand, None] slot lists in test_finger_strike;
_hand() fixture in test_action_head_finger_emit gains realistic size
so near_min=0.10 gate passes.
2026-07-02 10:55:32 +02:00
L'électron rare 9ee7990dcd feat(pose): hand router chirality + near gate
New module hand_slots.route_hands():
- Chirality path: iPhone ARBodyTracker Vision 0/1 -> L/R slots,
  alignment preserved before invalid-hand filtering.
- Fallback: ascending cx sort (mirror-aware).
- swap=True: swaps the two slots at the very end (safety knob,
  HAND_SWAP_LR=1, if a source inverts Vision chirality).
- near_min gate: drops slots below wrist->middle-MCP distance.
18 new tests covering all branches.
2026-07-02 10:55:21 +02:00
L'électron rare 8bc7acbd14 fix(pose): fresh arkit joint arrays per frame
Extract apply_skeleton_joints() as a pure module-level function that
copies prev_arr (or zeros) and writes a new array, never mutating the
old one.  The TAG_SKELETON handler now stores the new array under the
state lock, so readers that captured a reference to the previous array
(multi.py shallow-copy + ArkitFuse.apply outside the lock) always see
a complete, consistent snapshot — eliminating the B4 tearing race.

6 new tests cover: shape, identity (is not), value preservation for
invalid joints, value application for valid joints, wrong-shape reset,
and no-mutation of prev_arr.
2026-07-02 10:41:41 +02:00
L'électron rare 0a4f51ebfb test(viz): pin arkit max-len behavior change
Review follow-up: make the v2 filter's rejection of legacy 0.707
diagonal fixtures explicit instead of silently retuned, and document
the close-up caveat on max_len (ARKIT_BONE_MAX raises it live).
2026-07-02 10:37:02 +02:00
L'électron rare 039abaa4a8 docs: hand filter env vars
CI build oscope-of / build-check (push) Has been cancelled
Add HAND_CONF_MIN, HAND_PERSIST_FRAMES, ARKIT_BONE_MAX to the
Environment variables table in the root CLAUDE.md.
2026-07-02 10:28:23 +02:00
L'électron rare 5bfc63f86d feat(viz): hand persistence gate + conf knobs
HandPersistenceGate (min_frames=3, radius=0.15) suppresses ghost
hands that flash for fewer than min_frames consecutive frames.
Gate is called once per frame in the renderer on persons_hands;
result filters both the video overlay loop and the side panels.

hand_plausible now rejects wrists outside [-0.1, 1.1] (out-of-frame
ghost anchor check).  panel_segments gains a conf_min param.

Env knobs read at renderer init (single parse, no per-frame cost):
  HAND_CONF_MIN (default 0.45, raised from 0.3)
  HAND_PERSIST_FRAMES (default 3)
  ARKIT_BONE_MAX (default 0.5, passed to arkit_segments)

New tests: 12 gate tests + 4 wrist-bounds tests.
2026-07-02 10:27:58 +02:00
L'électron rare ac6768d8a8 fix(viz): arkit bone bounds + length filter
arkit_segments now accepts max_len and bounds params.
Bones whose endpoints fall outside [-bounds, 1+bounds] or whose
length exceeds max_len are skipped (tracking-loss garbage).
Defaults: max_len=0.5, bounds=0.1.  Renderer will pass
max_len=ARKIT_BONE_MAX explicitly (Part 3).

Tests updated to use realistic bone lengths; new tests added
for the out-of-bounds and overlong-bone cases.
2026-07-02 10:27:49 +02:00
L'électron rare 287c4977a9 fix(viz): install key monitors at app launch
NSEvent keyboard monitors were placed at the END of
_start_pose_worker, unreachable: every backend path returns
early (lines 369/384/399/413/440) so monitors were never
installed. ESC, space (concertNext) and keymaps were silently
dead in all real concert paths.

Move the local + global monitor installation into
applicationDidFinishLaunching_, after window focus setup,
non-headless path only. The --multi-hmr headless early-return
(line 95) is unaffected.
2026-07-02 10:15:19 +02:00
L'électron rare b8ae1a668c fix(pose): read real smplx person dataclass
_read_sources used .get("pid")/.get("v3d") (dict API) but
multi_hmr_worker emits SMPLXPerson dataclasses (field: vertices_3d).
AttributeError was swallowed by LOG.exception at 30 Hz.

- Dual-read via isinstance check: dataclass path uses p.pid /
  p.vertices_3d / p.expression; dict path keeps p.get() unchanged
- 3 new tests: real SMPLXPerson emits OSC, expression field accepted,
  legacy dict format still works
2026-07-02 10:14:17 +02:00
L'électron rare 48b04084fa fix(viz): gate arkit overlay on 2d freshness
persons_arkit_2d_t was written by IphoneUSBSource but never
read. A USB drop left the frozen skeleton on screen indefinitely.

- Add arkit_2d_fresh(ts_by_pid, now, max_age=1.0) pure helper
  in hand_display.py (8 TDD tests, all green)
- renderer._update_skeleton: use_arkit now requires at least one
  pid younger than 1 s (replaces bool check)
- Per-pid freshness gate in draw loop skips stale pids
2026-07-02 10:13:05 +02:00
L'électron rare 06b868e377 docs(viz): refactor audit ref (chantiers 1-6)
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 10:07:00 +02:00
L'électron rare 0a2865e136 feat(viz): left/right hand front-view panels
CI build oscope-of / build-check (push) Has been cancelled
Add panel_rect/panel_segments/panel_frame helpers to hand_display.py
(PANEL_SIDE=0.30, PANEL_MARGIN=0.02, PANEL_INNER=0.10). Segments use
uniform pixel-space scale so the hand aspect ratio is preserved
regardless of view aspect. mirror=True flips X within the panel.

In renderer._update_skeleton add push_panel (no mirror flip) and a
panels block before the segs==0 epilogue: routes persons_hands to
left/right panel via chirality list when aligned, screen-cx fallback
otherwise. Draws frame (pid 7) + wireframe (pid 5 left / 6 right).
Runs on every path including use_arkit. Guards: _mp_bones None, empty
hands, misaligned chirality, height==0.

26 tests pass (10 new for panel helpers).
2026-07-02 09:56:08 +02:00
L'électron rare 3a3e6b113f feat(viz): hud debug panel off by default
The AV-Live data-only text HUD hid the lower-left corner of the
performance display and would collide with the new left-hand panel.
VIZ_HUD=1 re-enables it; the H key toggle guards the absent view.
2026-07-02 09:47:54 +02:00
L'électron rare ef719e76f3 feat(viz): filter hand overlay artifacts
Add hand_display.py with hand_plausible / segment_ok / hand_size pure
helpers; wire into both skeleton hands loops (plausibility gate +
per-segment confidence+spike filter + real min(A.c,B.c) push); remove
filled mesh triangles for hands from _update_mesh (HAND_TRIANGLES block
deleted).
2026-07-02 09:42:51 +02:00
L'électron rare 60640a881e feat(pose): decode hand confidence + chirality
_decode_hands now returns (hands, chirality) tuple instead of a bare
list. Landmark .z keeps the raw wire value; .c = clamp(z, 0, 1).
Chirality byte (1=right, else 0=left) is decoded per hand and stored
in State.persons_hands_chirality under the same lock as
persons_hands_iphone.
2026-07-02 09:37:24 +02:00
L'électron rare 29c66c7151 docs(viz): hand display cleanup spec + plan 2026-07-02 09:33:34 +02:00
L'électron rare 1f19a5db96 feat(pose): pinch gate env vars + docs
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 09:18:14 +02:00
L'électron rare ea42ba4bb3 feat(pose): pinch engage debounce 2026-07-02 09:14:18 +02:00
L'électron rare 021c53f078 feat(pose): pinch open-hand extension gate 2026-07-02 09:10:06 +02:00
L'électron rare 50bc6f5d71 docs(pose): pinch gate implementation plan 2026-07-02 09:05:26 +02:00
L'électron rare 1510a37059 docs(pose): pinch open-hand gate + debounce spec 2026-07-02 09:00:56 +02:00
clement 095fd51812 Merge branch 'feat/disable-matrix-pinch'
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 22:51:39 +02:00
clement 1491aef30e feat(matrix): disable finger pinch by default 2026-06-30 22:49:41 +02:00
clement 24ce960992 fix(iphone): mirror skeleton overlay to video 2026-06-30 22:40:31 +02:00
clement 08b7c72e49 feat(iphone): render full arkit body skeleton 2026-06-30 22:14:35 +02:00
clement 86c071d0c9 feat(iphone): arkit skeleton segment builder 2026-06-30 22:09:10 +02:00
clement 04eb182fab feat(iphone): store topology + 2d validity
Add arkit_joint_names, arkit_parents, persons_arkit_2d_valid
to State. Wire TAG_TOPOLOGY=7 dispatch in IphoneUSBSource:
decode_topology stored under lock; TAG_SKELETON2D branch now
also stores per-joint validity bool array (91,) alongside pts.
2026-06-30 22:05:32 +02:00
clement b00b6dfbda fix(iphone): topology decode return type + test 2026-06-30 22:02:35 +02:00
clement fbc8b55044 feat(iphone): python topology codec 2026-06-30 21:58:42 +02:00
clement 67f347e705 feat(iphone): emit skeleton topology on connect 2026-06-30 21:58:35 +02:00
clement 6f941c52cb feat(wire): topology payload + tag 2026-06-30 21:49:37 +02:00
clement b2dab64aae fix(iphone): sendable hand buffer handoff 2026-06-30 21:47:29 +02:00
clement bb90820988 docs(iphone): phase 1 faithful skeleton plan 2026-06-30 21:45:31 +02:00
clement 5adb8abdc8 docs(iphone): SOTA 2026 refonte design spec 2026-06-30 21:33:45 +02:00
L'électron rare 40c21a246c perf(iphone): drop face vision + RealityKit view
CI build oscope-of / build-check (push) Has been cancelled
ARKit-only-body mode no longer consumes face: the Mac dropped face
entirely (face=0, /pose/mouth gone), so VNDetectFaceLandmarksRequest
and the .face USB frames were pure wasted per-frame compute. Drop the
face request and its send.

The on-device ARView (RealityKit) rendered a black background behind
the SwiftUI skeleton overlay -- it never showed the camera -- so it
only spun up the RealityKit AR render pipeline (the engine:rematerial
console spam) to draw black. Remove the ARView; the screen stays
black + skeleton overlay, identical UX, minus the GPU/thermal cost.

Video (HEVC) and hands are kept: the Mac uses the iPhone video as the
visualizer backdrop and Vision hands drive pinch/fist gestures.
2026-06-30 20:31:10 +02:00
L'électron rare 2b7b4a81c6 docs(data-only-viz): arkit body source mode
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 20:14:17 +02:00
L'électron rare ef99c32cac fix(iphone): explicit SIMD2<Float> for skeleton2D
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 19:42:22 +02:00
L'électron rare 27d5ce9d98 feat(iphone): send 2D projected skeleton
Add camera: ARCamera param to publishUSB and thread frame.camera
through from the session delegate. After the existing .skeleton
send, project all 91 joints to viewport 2D, normalize to [0,1],
and send as Skeleton2DPayload / FrameTag.skeleton2D (tag 6).
2026-06-30 19:41:02 +02:00
L'électron rare 33de64f599 chore(launcher): drop arkit_fuse, arkit is source 2026-06-30 19:32:04 +02:00
L'électron rare 5a9a6338a0 feat(pose): arkit body, skip mediapipe pose+face
Under --iphone-usb, skip all three MediaPipe detectors
(pose, face, hand) and rebuild the MP-33 body from
state.persons_arkit_{2d,joints} via new arkit_body_2d /
arkit_body_3d builders. Add head anchor (ARKIT_HEAD_IDX=51,
needs live verify) as MP33 slot 0. Webcam path unchanged.
2026-06-30 19:29:20 +02:00
L'électron rare c49de5c500 feat(pose): decode iphone 2D skeleton
Add TAG_SKELETON2D=6, SKEL2D_BYTES=819, decode_skeleton2D() to
iphone_usb_bridge.py; handler in IphoneUSBSource writing
persons_arkit_2d / persons_arkit_2d_t to state; new state fields
in State dataclass. TDD: test_arkit_body.py (round-trip + wrong
length → None). Import check verified.
2026-06-30 19:19:54 +02:00
L'électron rare 660d493ad6 feat(wire): skeleton2D frame payload
Add FrameTag.skeleton2D (raw 6) and Skeleton2DPayload for
2D-projected ARKit joints: 91×SIMD2<Float> + 91 valid bytes,
big-endian wire format mirroring SkeletonPayload style.

Note: raw value is 6 (not 5 as drafted) because face=5 is taken.
2026-06-30 19:15:16 +02:00
L'électron rare bd9f9fca29 docs: arkit-only body position plan 2026-06-30 18:47:10 +02:00
L'électron rare 7037ebcd82 docs: arkit-only body position spec 2026-06-30 18:43:20 +02:00
L'électron rare 02f7ad42b2 feat(pose): all-fingers-curled fist metric 2026-06-30 18:22:38 +02:00
L'électron rare 16cd6a3655 fix(pose): pinch closest-finger-wins + margin
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 18:06:34 +02:00
L'électron rare 4c0794a3db fix(pose): iphone-only hands, cut mac mediapipe
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 17:32:55 +02:00
L'électron rare 9111da4a5c feat(control): fist sensitivity + indicators
CI build oscope-of / build-check (push) Has been cancelled
Add matFistThresh state (default 0.15) with localStorage persistence.
Wire a "Sensibilite poing" slider in Reglages, synced via fisthresh.
Add two fist indicator rows in #morceau-fists driven by fistprogress.
2026-06-30 16:54:54 +02:00
L'électron rare 65c06ed4c6 feat(presets): emit default fist threshold
Context: Task 1 wired fistThresh into the SC matrix engine (default
0.15); the generator was not yet emitting this field, so every
.matrix was missing it and the engine fell back to its hardcoded
default.

Approach: TDD — added a failing check to test_global_actions.py,
then edited to_sc so pinchHoldMs ends with a comma and fistThresh
0.15 follows as the last field before the closing paren.
Regenerated 84 presets twice for determinism.

Changes:
- generate_presets.py: pinchHoldMs now ends with comma; fistThresh
  0.15 emitted as the final key in every preset's SC Event literal
- test_global_actions.py: EXPECT_FTH block checks to_sc output and
  all 84 on-disk .matrix files
- *.matrix (84 files): regenerated with fistThresh field

Impact: all four gates green (GLOBALACT-PY, test_sections,
test_families, GLOBALACT SC); fistThresh is now a first-class
preset field readable at load time by ~matLoadFile.
2026-06-30 16:50:02 +02:00
L'électron rare 24078111b4 feat(matrix): fist-held gestures (reload/next)
Held closed-fist (low openness, hysteresis+presence guard) per hand
fires an action after ~matPinchHoldMs: left hand reloads the current
preset, right hand advances to the next. Progress emitted on
/matrix/fistprogress.

- ~matFistEdge edge detector (engage/release/absent + hysteresis)
- ~matFistTick fires once past hold threshold (guarded by ~matFistFired)
- ~matFistReloadCurrent and ~matNextPreset wired to hand 0/1
- /matrix/fisthresh[/get] OSCdefs + ~matFistThreshPush
- fistThresh saved and restored per .matrix file
- 30 Hz poller now calls ~matFistTick alongside ~matPinchTick
- Harness group Q: A-Q all green
2026-06-30 16:42:58 +02:00
L'électron rare 4cfd2f0a98 docs(matrix): fist-held gestures plan 2026-06-30 16:39:02 +02:00
L'électron rare f9f62e2951 docs(matrix): fist-held gestures spec 2026-06-30 16:36:34 +02:00
L'électron rare 38dca5642e feat(control): pinch hold, progress, 2-col menu
CI build oscope-of / build-check (push) Has been cancelled
- matPinchHoldMs state (default 300ms) + setPinchHoldMs + persistence
- .morceau-slots column-first grid (MG left, MD right, 4 rows)
- per-slot .morceau-slot-fill progress bar (/matrix/pinchprogress)
- hold slider in Réglages wired to /matrix/pinchhold (50-2000ms)
2026-06-30 16:08:47 +02:00
L'électron rare 2ced4e728f feat(presets): emit default pinch hold ms
Add top-level 'pinchHoldMs': 300 to every generated .matrix so the SC
engine (Task 2) reads it instead of its hardcoded default.

- generate_presets.py: globalSettings ends with ',' and
  'pinchHoldMs': 300 is the last field before the closing ')'
- test_global_actions.py: EXPECT_PHMS check added (TDD red then green)
- 84 .matrix files regenerated (28 morceaux x intro/live/outro)
2026-06-30 16:01:59 +02:00
L'électron rare 0daa6d6bcd feat(matrix): pinch hold-to-fire + progress 2026-06-30 15:53:59 +02:00
L'électron rare b3ec348378 feat(pose): pinch engage/release edges + launcher
PinchDetector now emits state=1 on engage and state=0 on release
(and on hand-disappear). send_pinch appends the state as the 4th
OSC arg so SC can distinguish hold from lift. Launcher switches
from FINGER_PIANO to PINCH_ENABLE to gate only the pinch detector.
2026-06-30 15:46:13 +02:00
L'électron rare 2902f5c7cd docs(matrix): pinch refinements plan 2026-06-30 15:43:24 +02:00
L'électron rare 4bccbc1580 feat(pose): decouple pinch from air-piano gate 2026-06-30 15:40:33 +02:00
L'électron rare 50386efe8f docs(matrix): pinch refinements spec 2026-06-30 15:39:28 +02:00
L'électron rare d31c6c52d0 feat(launcher): enable pinch detection for matrix
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 15:10:35 +02:00
L'électron rare 65fe76d5f0 feat(control): 8 pinch slots panel + tempo 2026-06-30 15:07:06 +02:00
L'électron rare 140953f3cb feat(control): 8 pinch slots + trim settings state 2026-06-30 15:02:11 +02:00
L'électron rare 9590615069 feat(presets): 8 pinch actions, trim settings
Expand globalActions from 4 to 8 slots:
[\fill, \drop, \breakdown, \evolve,
 \muteDrums, \muteMelo, \washReverb, \next]

Drop openThresh/closeThresh from globalSettings (now
engine-side constants via Task 1). Regenerate all 84
.matrix files deterministically.

All gates: GLOBALACT-PY PASS, TEST PASS x2, GLOBALACT PASS
2026-06-30 14:56:50 +02:00
L'électron rare cc485003f6 feat(matrix): pinch-triggered global actions
Replace open/close hand detection with 8 pinch slots (hand*4+finger-1).
Global actions array grows from 4 to 8 entries (L/R x index..ring).
Remove ~matHandFlip, ~matHandState, openThresh/closeThresh entirely.
2026-06-30 14:49:49 +02:00
L'électron rare 2994778b02 docs(matrix): pinch-trigger global actions plan 2026-06-30 14:42:26 +02:00
L'électron rare e04fb88e42 docs(matrix): pinch-trigger global actions spec 2026-06-30 14:35:04 +02:00
L'électron rare afc220f83f feat(matrix): matrix bpm tempo route 2026-06-30 14:29:23 +02:00
L'électron rare d4306f6252 docs(matrix): global actions phase 3 plan 2026-06-30 14:27:13 +02:00
L'électron rare 9dbf0bcbc9 feat(control): extended global action choices
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 14:08:23 +02:00
L'électron rare 0bd8e46dd7 feat(matrix): mute groups, time-warp, wash, next 2026-06-30 14:00:09 +02:00
L'électron rare d483a29d3d feat(control): reglages group in morceau panel
Add the Reglages group to the morceau panel: two threshold sliders
(open/close, the close one being the user-requested "sensibilite"),
a transient-duration slider (1-8 bars), and a breakdown kept-voices
checkbox grid covering all MATRIX_VOICES.

Wired over /matrix/globalsettings with inbound sync and onOpen
get-request. Imports consolidated into a single coherent block;
no duplicate identifiers. DOM container added to index.html;
styles appended to control.css.
2026-06-30 13:45:17 +02:00
L'électron rare 64348aa90d feat(control): global settings state + persistence 2026-06-30 13:40:19 +02:00
L'électron rare 8bead14e23 feat(presets): emit default global settings
Context: Phase 2 Task 1 made the SC engine load a globalSettings Event
from .matrix files (openThresh/closeThresh/transientBars/breakdownKeep)
and default it when absent. Generator was not yet writing the field.

Approach: append globalSettings after globalActions in to_sc() return
string; extend test_global_actions.py with EXPECT_SET check against
both the to_sc sample and all 84 .matrix files on disk.

Changes:
- generate_presets.py: to_sc now ends with globalActions (comma) then
  globalSettings Event with default values matching SC defaults
- test_global_actions.py: add EXPECT_SET literal + two check() calls
- *.matrix (84 files): each preset now carries the globalSettings field

Impact: every preset ships with openThresh/closeThresh/transientBars/
breakdownKeep defaults; SC engine reads them on load; no more implicit
fallback needed at runtime for any preset in the library.
2026-06-30 13:36:00 +02:00
L'électron rare 9f53f27317 feat(matrix): editable global action settings
Replace four hard-coded constants (~matTransientBars,
~matBreakdownKeep, ~matOpenThresh, ~matCloseThresh) with
~matGlobalSettings Event (openThresh, closeThresh,
transientBars, breakdownKeep). All three consumers
(~matHandFlip, ~matSetTransient, ~matFireGlobal) now
read from settings. Add ~matGlobalSettingsPush + OSCdefs
/matrix/globalsettings[/get]. Save/restore in .matrix.
Harness A-K green.
2026-06-30 13:27:51 +02:00
L'électron rare 1d6f65bbb2 docs(matrix): global actions phase 2 plan 2026-06-30 13:20:41 +02:00
L'électron rare cadbced958 fix(matrix): no baseGrid leak in evolve one-shot
CI build oscope-of / build-check (push) Has been cancelled
When the evolve toggle is OFF, ~matRelanceEvolve now snapshots
~matBaseGrid, mutates once, then restores it to nil. When ON it
keeps the persistent base and re-mutates from it. Fixes stale-
snapshot corruption across preset loads. Case I added to harness.
2026-06-30 12:19:14 +02:00
L'électron rare 8ed0365309 feat(control): morceau panel with action slots
Add bottom-of-matrix panel with 4 gesture-slot dropdowns wired
over OSC (/matrix/globalaction). State lives in matrix-state.js;
syncs on connect and on incoming OSC echo.
2026-06-30 12:00:55 +02:00
L'électron rare 69176fb94d feat(control): global action state + persistence 2026-06-30 11:56:06 +02:00
L'électron rare 50e9e79a22 feat(presets): emit default global actions mapping
Context: SC engine (Task 2) loads 'globalActions' from .matrix files
and defaults to [\fill, \drop, \evolve, \breakdown] when absent.
Generated presets did not write this field, causing implicit reliance
on the engine default.

Approach: extend to_sc() return string to include the globalActions
key so every preset explicitly carries the default mapping
(LH-open=fill, LH-closed=drop, RH-open=evolve, RH-closed=breakdown).

Changes:
- generate_presets.py: add 'globalActions' line in to_sc() return
- test_global_actions.py: new Python test verifying to_sc() output
  and all 84 on-disk .matrix files carry the field
- 84 .matrix files: regenerated with globalActions field present

Impact: presets are self-documenting; SC engine reads the explicit
value rather than applying a silent default.
2026-06-30 11:50:51 +02:00
L'électron rare 0cc64194df feat(matrix): hand gesture detection + mapping io
Add ~matHandFlip (hysteresis open/closed per hand),
~matGlobalActionsPush, OSCdefs /matrix/globalaction + /get,
/pose/hands integration, globalActions save/load roundtrip.
2026-06-30 11:41:55 +02:00
L'électron rare b72056ecb3 feat(matrix): global actions + transient engine
Adds fill/drop/breakdown/evolve one-shot transient overrides:
- ~matEffColor: per-voice colour gate for any active transient
- ~matSetTransient/~matTransientTick: set and expire transients
- ~matRelanceEvolve: one-shot evolve mutation
- ~matFireGlobal: dispatch gesture-slot -> action
- ~matGlobalActions/~matBreakdownKeep: configurable defaults
- ~matApplyBar now routes through ~matEffColor (grid untouched)
- ~matPlay routine ticks transient once per bar
- test_global_actions.scd harness: GLOBALACT PASS
2026-06-30 11:29:53 +02:00
L'électron rare c0e13a0fa0 docs(matrix): global actions phase 1 plan 2026-06-30 11:24:41 +02:00
L'électron rare 2f9f43e664 docs(matrix): global morceau actions design spec 2026-06-30 11:16:05 +02:00
L'électron rare 8dde561c34 feat(presets): per-style variation in live loop
CI build oscope-of / build-check (push) Has been cancelled
The live loop tiled the plateau into 4 identical blocks -- full but static.
LIVE_STYLE maps each morceau to a style group; LIVE_VAR holds per-style
profiles; _live_variation recolours active cells in some 16-bar blocks
(rhythmic 2/4, melodic 3 octave) and adds phrase-end fills (colour 6) so the
full loop breathes in a genre-appropriate way -- techno double-time hats +
fills, breaks snare rolls, ambient gentle bell octaves, etc. Active cells
only -> density unchanged, no voice dropped, no intro/breakdown back.

test_sections asserts the live still has no intro/breakdown AND varies (not
4 identical blocks). Regen 84 deterministic, VALIDATE 84/84 + DEFAULTS PASS.
2026-06-30 10:27:05 +02:00
L'électron rare 15e0b79083 docs(presets): live per-style variation plan 2026-06-30 10:25:13 +02:00
L'électron rare d8d6017443 docs(presets): live per-style variation design 2026-06-30 10:22:19 +02:00
L'électron rare db8d7d465f feat(presets): live = continuous full loop
CI build oscope-of / build-check (push) Has been cancelled
Each morceau's _live section reused the standalone arc, which re-introduced
(near-empty intro -> rebuild -> plateau -> breakdown). Now make_live_continuous
tiles the plateau (P, bars 32-47) across all 64 bars: full groove from bar 0,
no intro ramp, no breakdown -- a clean loop the _intro builds to and the
_outro descends from. Applied post-apply_colors for the live section only via
to_sc(post=); intro/outro unchanged.

test_sections asserts the live is full-from-start + has no breakdown dip.
Regen 84 deterministic, VALIDATE 84/84 + DEFAULTS PASS, test_families PASS.
2026-06-30 10:03:16 +02:00
L'électron rare 635343262e docs(presets): live continuous groove plan 2026-06-30 10:01:08 +02:00
L'électron rare d02a83d6a2 docs(presets): live continuous groove design 2026-06-30 09:57:46 +02:00
L'électron rare 5def521587 fix(presets): remove default pose->voice triggers
CI build oscope-of / build-check (push) Has been cancelled
The SP-A default bound danse->gate on the fx and sweep voices, so the
action-head classifying the dancer as "danse" toggled those voices on/off
even with no fx/sweep cells in the grid -- poses triggering FX that the
user never placed. Pose->voice triggers are now opt-in (default_poses
returns []); continuous pose modulation (default_mods) is unchanged.

Updated test_default_mods.py + the validate_presets DEFAULTS assertion
(fx voicePoses now expected empty). Regenerated 84 presets. VALIDATE
84/84 + DEFAULTS PASS; test_families + test_sections PASS.
2026-06-30 09:48:21 +02:00
L'électron rare 6de2627f7e fix(launcher): cpu delegate, gpu crashes
CI build oscope-of / build-check (push) Has been cancelled
The Metal GPU MediaPipe delegate (set as default earlier to fix pose
choppiness) crashes under sustained load: fatal CVPixelBuffer alloc
failure (-6662) in gpu_buffer_storage_cv_pixel_buffer.cc /
Image::ConvertToGpu(), on M1 Max too. The choppiness it addressed was CPU
starvation (baby-brain + model servers), now stopped -- so the CPU
delegate has headroom and is both fluid and stable. Default to cpu,
overridable via MEDIAPIPE_DELEGATE=gpu for short sessions.
2026-06-30 09:41:13 +02:00
L'électron rare fc268405d2 fix(iphone): retry initial USB connect
CI build oscope-of / build-check (push) Has been cancelled
The iPhone USB source gave up permanently if the first connect_device()
failed (app not streaming on :7000 at startup -- phone locked, app not
launched/crashed, USB hiccup): start() returned False and the read thread
(which holds the reconnect loop) never ran. Auto-reconnect only covered a
mid-session drop, never a startup race.

Now start() launches the thread regardless and _run reconnects from the
top of its loop, so the initial connect retries with backoff exactly like
a mid-session drop -- the source connects whenever the iPhone app appears,
no data_only_viz restart needed.

Test: tests/test_iphone_usb_reconnect.py (start() keeps retrying when no
device). The 2 unrelated pre-existing collection errors are unchanged.
2026-06-30 09:34:23 +02:00
L'électron rare ce00b90f43 feat(matrix): autofill name on load
CI build oscope-of / build-check (push) Has been cancelled
Emit /matrix/loaded <name> at each user-facing load point (the /matrix/load
OSCdef, the queued branch of ~matWrapStep firing at the wrap, and the
not-playing fallback in /matrix/loadnext) - not from the shared ~matLoad,
to avoid spamming during the boot autoload. The web fills #matrix-name from
it, so SAUVER updates the loaded matrix in place. For CHARGER SUIVANT the
name tracks the actual swap (at the wrap), never the merely-armed preset.

VALIDATE 84/84, test_matnames PASS, node --check + 29 web tests pass.
2026-06-30 09:25:08 +02:00
L'électron rare f635240df9 docs(matrix): loaded-name feedback design 2026-06-30 09:23:27 +02:00
L'électron rare e0c4405044 feat(control): SUIVANT queued-load button
CI build oscope-of / build-check (push) Has been cancelled
Add a SUIVANT button to the matrix persist bar that arms the selected
preset to load at the next loop wrap (/matrix/loadnext), with an armed
pulse + queued-name label driven by the /matrix/queued feedback.

No jsdom unit test: the project's web tests cover pure logic modules, not
main.js DOM wiring (jsdom is not a dependency); the button is verified by
the live smoke. node --check + the existing 29-test suite pass.
2026-06-30 09:15:53 +02:00
L'électron rare 68fa3c4e3b feat(matrix): queued load at loop wrap 2026-06-30 09:13:23 +02:00
L'électron rare af55f8a145 fix(matrix): persist user saves in load list 2026-06-30 09:12:04 +02:00
L'électron rare f6b350a35c docs(matrix): save-persist + queued-load plan 2026-06-30 09:10:22 +02:00
L'électron rare 0e6604cb80 docs(matrix): save-persist + queued-load design 2026-06-30 09:07:08 +02:00
L'électron rare 3a5cf80aa7 refactor(presets): breaks arcs independent
CI build oscope-of / build-check (push) Has been cancelled
Expand the 7 breaks morceaux from the shared _breaks_intro/_breaks_outro
helper into 14 fully-independent arc bodies (one intro + one outro each),
per the "no shared helper" requirement. Each varies by morceau character
(bass_science reese-forward, jungle_amen amen-snare, footwork kick step2,
neuro_reese stab, tribal_perc tom/perc).

test_sections PASS, VALIDATE 84/84.
2026-06-30 00:24:28 +02:00
L'électron rare 4c7d960b45 feat(presets): wire families + live default
Wire the hand-authored families into the 84 presets and make the launcher
boot the live section.

Changes:
- patterns_for: strip the _intro/_live/_outro suffix before the
  PRESET_FAMILY lookup, so all 84 section presets resolve to their
  morceau's family.
- PRESET_FAMILY: retarget mel families to the new banks where they fit -
  house (detroit/rave/trance/melodic/electro), psy (hypnotic/psytrance/
  minimal), dub (dub_techno/melodic_deep), ambient (ambient_intro); drum
  families now style ride/shaker/clap/perc per dfam.
- launch_concert.sh: MATRIX_PRESET default -> techno_drive_live.

Impact: genre presets now use the authored families. test_families PASS,
test_sections PASS, regen deterministic, VALIDATE PASS 84/84.
2026-06-30 00:20:19 +02:00
L'électron rare 1090f2c223 feat(presets): 56 intro/outro arcs
Hand-write the intro and outro arc for each of the 28 morceaux (56 new
independent arc functions). intro = voices enter across the 64 bars
(sparse->full, capped at the plateau with a light O lead-in into live);
outro = voices exit (full early, dropping out toward the break/outro).

Approach:
- one arc_intro_<m> / arc_outro_<m> per morceau, registered in
  INTRO_ARCS/OUTRO_ARCS; composed from the primitive fill/hit grid API
  over the I/B/R/D/P/K/O section codes.
- the 7 breaks morceaux share a _breaks_intro/_breaks_outro shape
  parameterized per morceau (deviation from "fully independent" - flagged
  for review).
- test_sections.py gate: intro builds, outro breaks down, live densest -
  all 28 PASS.

Impact: 84 presets regenerated, VALIDATE PASS 84/84. Section feel is
shape-correct; musical review by ear pending.
2026-06-30 00:17:51 +02:00
L'électron rare fc73be4851 feat(presets): hand-authored pattern families
Add explicit 6-colour style families to the pattern bank so genre presets
feel musically distinct (merged SP-C2). All colours hand-written following
the role convention (base/busier/broken/double/sparse/fill), velocity-
humanized; existing families and every default stay untouched.

Changes:
- 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) - 21 families x 6 motifs.
- DRUMS: percu-voice + style-completion families for ride/shaker/crash/
  rim/fx/sweep/perc (techno/house/breaks/dnb/trance/ambient).

Impact: vocabulary for the genre presets; wired per preset in a later
task. test_families.py PASS (validity + >=4 distinct + coverage).
2026-06-30 00:06:08 +02:00
L'électron rare 38d79f8510 feat(presets): intro/live/outro 84 skeleton
Each morceau is now decomposed into three sections (intro/live/outro)
as three separate .matrix files, replacing the single-grid presets.

Approach:
- MORCEAUX: lazy view over PRESETS; live = the existing arc, intro/outro
  resolve from INTRO_ARCS/OUTRO_ARCS registries (fallback to live) so the
  per-morceau arcs can be populated anywhere at module level.
- main() emits <morceau>_<section>.matrix (28 x 3 = 84).
- test_sections.py: section-shape gate (intro builds, outro breaks down,
  live densest) - currently RED on outros (placeholders) to drive the
  hand-written arc tasks.
- validate_presets.scd: count 28 -> 84, DEFAULTS check on peak_drop_live.

Changes:
- generate_presets.py: MORCEAUX/_sections/_Morceaux, main() loop.
- removed the 28 old single-name .matrix; wrote 84 section files.

Impact: structural base for hand-authored intro/outro arcs (Tasks 3-9)
and the new pattern families (Task 2). VALIDATE PASS 84/84.
2026-06-30 00:00:25 +02:00
L'électron rare 6acc302982 docs(presets): v4 intro/live/outro impl plan 2026-06-29 23:53:51 +02:00
L'électron rare b7fa138172 docs(presets): v4 independent intro/outro arcs 2026-06-29 23:50:56 +02:00
L'électron rare 3d366be50c docs(presets): v4 intro/live/outro design 2026-06-29 23:48:31 +02:00
L'électron rare eb42da0f31 test(presets): pattern family validity harness 2026-06-29 23:41:28 +02:00
L'électron rare b7d537b10d docs(presets): hand-authored families plan 2026-06-29 23:38:16 +02:00
L'électron rare 6bb8e41af1 docs(presets): hand-authored families design 2026-06-29 23:35:08 +02:00
L'électron rare d8f1cd9e71 fix(launcher): gpu mediapipe delegate on non-M5
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 22:24:14 +02:00
L'électron rare 6f8903e303 fix(presets): alignment-independent contrast
CI build oscope-of / build-check (push) Has been cancelled
Busier/sparse/broken in derive_drum_colors and derive_mel_colors
now operate on the base's own rest/hit lists, not fixed i%4
positions. Stab (all hits at i%4==2) went from 4 distinct colours
(busier==broken==base, sparse EMPTY) to 6. Test strengthened with
distinctness and sparse-not-empty asserts for stab/kick/hats/acid/
shaker/pad. 28 presets regenerated; determinism confirmed (md5 zero
diff on two consecutive runs). VALIDATE PASS 28/28.
2026-06-29 21:45:17 +02:00
L'électron rare c7ba26100e test(presets): assert default colours are derived
Replace weak != property checks with equality assertions against
derive_drum_colors / derive_mel_colors. The old checks passed even
without wiring; equality fails if patterns_for reverts to raw authored
defaults.
2026-06-29 21:29:18 +02:00
L'électron rare d0a66a5fd3 feat(presets): contrasted default colours, regen
Wire derive_drum_colors/derive_mel_colors into patterns_for:
default family colours 2-6 are now derived from colour 1
(busier/broken/double/sparse/fill); authored style families
pass through unchanged. Remove dead _pick/_DRUM_VELS.
Regenerate 28 .matrix files; sclang VALIDATE PASS 28/28.
2026-06-29 21:24:09 +02:00
L'électron rare 39ac5d0ad1 feat(presets): 6-colour contrast derivation fns 2026-06-29 21:17:47 +02:00
L'électron rare f8c65b91ba docs(presets): colour contrast derivation plan 2026-06-29 21:12:32 +02:00
L'électron rare 9d2a414718 docs(presets): colour contrast derivation design 2026-06-29 21:08:36 +02:00
L'électron rare e99fe1f585 fix(matrix): evolve clear + idempotent enable
CI build oscope-of / build-check (push) Has been cancelled
Fix 1: ~matClear now mirrors the zero grid into ~matBaseGrid when
evolving, preventing resurrection on the next ~matEvolveStep cycle.

Fix 2: ~matSetEvolve.(true) guards the base snapshot with a
not-yet-evolving check; re-enabling while already evolving no longer
overwrites the original baseline.

Fix 3: ~matLoadFile now calls ~matEvolvePush after load so
reconnecting web clients see the correct toggle and rate.

RED->GREEN on two new test assertions (test_matrix.scd).
2026-06-29 20:50:03 +02:00
L'électron rare 2532a9a7e5 feat(web): matrix evolve toggle and rate 2026-06-29 20:37:25 +02:00
L'électron rare 47fe6a46af feat(matrix): evolve edit/save/load + OSC routes 2026-06-29 20:31:32 +02:00
L'électron rare b254d7cf1d feat(matrix): generative per-cycle evolve core 2026-06-29 20:25:19 +02:00
L'électron rare 8559cba14a docs(matrix): generative evolve impl plan 2026-06-29 20:22:12 +02:00
L'électron rare 93bbfd8960 docs(matrix): generative evolve design 2026-06-29 20:19:00 +02:00
L'électron rare ee4cd9b92c fix(matrix): neutral rest for position mod sources
CI build oscope-of / build-check (push) Has been cancelled
Position sources (lHandX/Y, rHandX/Y, bodyX/Y, headX/Y, bodySym)
now default to 0.5 (centre) when absent from cache instead of 0.
Magnitude sources (speed, accel, openness, span, danse, depth,
mouthOpen) keep 0 as correct rest. Fixes pitch-flat-at-boot-without-pose
and matching meter skew in matModValuesPush.
2026-06-29 19:46:05 +02:00
L'électron rare 7bd5a4f5fe feat(presets): regenerate with default mappings
Regenerate 28 .matrix presets with role-adapted voiceMods/voicePoses:
soloists (acid/arp/lead/stab/bells/melody) get hand bindings (rHandX/Y,
lHandX/Y); drums get body bindings (bodyVitesse/bodyX); fx/sweep get
danse->gate pose. Update validate_presets.scd to assert new scheme via
peak_drop (acid 4 hand bindings, kick 2 body, fx danse->gate).
2026-06-29 19:14:44 +02:00
L'électron rare cd77189c12 feat(presets): default pose-instrument mod scheme 2026-06-29 19:06:55 +02:00
L'électron rare e9ca68102a docs(presets): default pose mappings plan 2026-06-29 19:04:25 +02:00
L'électron rare ae146ac48e docs(presets): default pose mappings design 2026-06-29 18:54:37 +02:00
L'électron rare 0c3d410e98 feat(web): pitch and res mod targets
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 18:19:55 +02:00
L'électron rare e53ae8d94d feat(matrix): pitch modulation target 2026-06-29 18:12:21 +02:00
L'électron rare ece67bf1fb feat(matrix): res modulation target 2026-06-29 18:01:51 +02:00
L'électron rare 09fec3aa36 docs(matrix): pitch+res mod targets plan 2026-06-29 17:34:47 +02:00
L'électron rare 84de68cb12 docs(matrix): pitch+res mod targets design 2026-06-29 17:27:14 +02:00
L'électron rare 8daad4040b refactor(matrix): drop dead per-color mod API
CI build oscope-of / build-check (push) Has been cancelled
Remove 4 SC functions (matSetColorMod/Pose, matColorMod/PosePush),
2 web OSC handlers (/matrix/colormod /colorpose), and the test
blocks that called them. Superseded by per-voice system; OSCdefs
were already gone. Legacy field read paths and colorDef fields
preserved for preset migration.
2026-06-29 17:00:24 +02:00
L'électron rare 5063e5929a fix(matrix): finger/pinch OSC arg offset
/pose/finger and /pose/pinch carry a leading pid at msg[1]; hand is
msg[2] and finger is msg[3]. Previous code read pid as hand and hand as
finger, making 18/21 curated events unreachable.

Add OSCdef-level spy assertions in test_matrix.scd that would have
caught this: RED against old indices, GREEN against the fix.
2026-06-29 15:58:46 +02:00
L'électron rare 8db2c2a7a9 feat(presets): emit per-voice mods and poses
Add VOICE_MODS_DEMO/VOICE_POSES_DEMO dicts and emit_voicemods/
emit_voiceposes helpers to generate_presets.py. Every preset now
carries voiceMods:/voicePoses: keys (22-element arrays, default []).
techno_drive carries 3 demo bindings: acid bodyVitesse->cutoff 0.6,
lead rHandY->rev 0.4, kick danse/trigger pose.

Regenerate all 28 presets. Add validate_presets.scd harness:
28/28 load OK, demo bindings parse correctly (VALIDATE PASS).
2026-06-29 15:41:43 +02:00
L'électron rare 8aa41e2aff feat(web): live mod-source meters 2026-06-29 15:28:57 +02:00
L'électron rare d3a2a591ae feat(web): per-voice MOD list + pose dropdown
Replace the old per-colour single-mod row in the MOD/POSE tab with a
per-voice additive list (matVoiceMods) and a curated pose-event
dropdown (matVoicePoses), both independent of cdColor.

Add OSC handlers for /matrix/voicemod and /matrix/voicepose echoes.
Update openFor to request voicemod/get + voicepose/get on expand.
Extend colordefs-parse test stub to export all new matrix-state symbols.
2026-06-29 15:22:23 +02:00
L'électron rare 89ade5524c feat(web): mod source labels + per-voice state 2026-06-29 15:13:29 +02:00
L'électron rare 3ecef87f4b feat(pose): emit /pose/mouth from bridge
Add PoseSoundBridge.send_mouth(pid, mouth_open) mirroring send_kin
pattern; call it in ActionHeadPublisher._tick after send_kin using
the already-in-scope `mouth` scalar; add route to module docstring.
3 unit tests (address, VJ mirror, boundary values) all green.
2026-06-29 15:07:54 +02:00
L'électron rare 244d5dd944 test(matrix): cover voice mods/poses + sources 2026-06-29 15:00:58 +02:00
L'électron rare 21d5209047 feat(matrix): persist + migrate voice mods/poses
- ~matSave payload gains voiceMods/voicePoses keys (one per voice)
- ~matLoadFile restores new keys; collapses legacy per-colour mod/pose
  into voice lists (dedup) when new keys absent
- push ~matVoiceModPush/~matVoicePosePush for all voices after load
- round-trip test in test_matrix.scd (RED->GREEN)
2026-06-29 14:51:26 +02:00
L'électron rare c82b149741 feat(matrix): push live modvalues at 12 Hz 2026-06-29 14:45:09 +02:00
L'électron rare b954032165 feat(matrix): per-voice pose + finger/pinch events
Replace mat_colorpose/mat_colorpose_get OSCdefs with per-voice
mat_voicepose/mat_voicepose_get. Rewrite matPoseFire to iterate
matVoicePoses[name] using current bar color. Add matSetVoicePose,
matVoicePosePush, matEventFinger/Pinch mappers, mat_ev_finger/pinch
OSCdefs. Old color-pose functions left intact.
2026-06-29 14:39:01 +02:00
L'électron rare 0179e32837 feat(matrix): voicemod set/get OSC routes 2026-06-29 14:30:18 +02:00
L'électron rare 0c8ea8b104 feat(matrix): per-voice additive modulation pairs
Introduce ~matVoiceMods (IdentityDictionary voice→binding list) and
~matVoicePoses. Rewrite ~matModPairs to read ~matVoiceMods[name]
instead of spec[\mod], summing contributions per target additively.
Disallowed targets (not in ~matModTargets[name]) are silently dropped.
Test: pan-cancel, rev-depth, cutoff-gate — RED→GREEN confirmed.
2026-06-29 14:21:02 +02:00
L'électron rare 6964831036 feat(matrix): route new iphone mod sources 2026-06-29 14:01:50 +02:00
L'électron rare adeb98e569 docs(matrix): fix plan SC test protocol + paths 2026-06-29 13:58:08 +02:00
L'électron rare 73af4890b1 docs(matrix): per-voice mod/pose impl plan 2026-06-29 13:49:41 +02:00
L'électron rare ab265e4473 docs(matrix): mouthOpen now first-class source 2026-06-29 13:37:50 +02:00
L'électron rare fa7797d259 docs(matrix): per-voice mod/pose design spec 2026-06-29 13:36:27 +02:00
L'électron rare d646f4649d feat: 6 step patterns per colour on all voices
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 13:18:57 +02:00
L'électron rare 71926dec84 feat(matrix): per-step playhead /matrix/stephead
SC emits /matrix/stephead s (0-15) every 16th note via
~matStepRoutine (started/stopped with ~matPlay/~matStop).
Web highlights .seq-cell or .pr-cell[data-step] with
.playhead class; CSS adds white outline + brightness.
2026-06-29 13:13:40 +02:00
L'électron rare 80aa33a19c fix: push steps to surfaces on matrix load 2026-06-29 13:06:23 +02:00
L'électron rare 946b83d9ea fix(voiceeditor): colordefs stride 6→8, add res/rev
CI build oscope-of / build-check (push) Has been cancelled
SC ~matColorDefPush sends 8 fields per colour (stretch, octave,
amp, inst, cutoff, pan, res, rev). The JS receiver used stride 6,
so colours 2-6 parsed from shifted indices and res/rev were never
read — edits snapped back on every echo.

- Extract pure parseColorDefs(args) (stride 8, all 4 sentinels
  decoded: cutoff=-1, pan=-2, res=-3, rev=-4 → null); export it.
- Rewrite handler to use parseColorDefs; preserve mod/pose/steps.
- Fix stale CSS comment: 16x32 → 22x64.
- Add test/colordefs-parse.test.mjs (8 cases, guard against drift).
- node --test tests/*.mjs: 20/20 pass (was 12).
2026-06-29 12:37:56 +02:00
L'électron rare cde2882975 chore(voiceeditor): lowercase res/reverb labels
Synth/FX tab labels "RES"/"REVERB" -> "res"/"reverb" to match
all sibling labels (inst, stretch, oct, amp, cutoff, pan).

C5 headless tests: node --test 12/12, test_matrix TEST PASS,
presets 28/28, node --check 10/10 all green.
2026-06-29 12:24:50 +02:00
L'électron rare c0a360ff58 feat: synth/fx tab with res and reverb controls 2026-06-29 12:14:55 +02:00
L'électron rare 5aa9b31ec5 fix(voiceeditor): drum grid API + wheel rerender
- renderDrumGrid(vi, colour, body): explicit params, def guard
- wheel handler calls renderPattern so loud class stays in sync
- skip empty steps-mode-hdr for isDrumOnly voices
2026-06-29 12:10:18 +02:00
L'électron rare 1edeaf1e6e feat: adaptive pitch-roll and drum step modes
- stepMode map (per voice, persists for session) init from
  defaultStepMode(); melodic->pitch, drum/semi->drum
- renderStepsTab branches on stepMode[vi]: Pitch toggle shows
  a 15-row x 16-col piano-roll; Drum shows 3-level pads
- renderDrumGrid: click cycles off->soft(vel 0.6)->loud(vel 0.95);
  wheel fine-tunes vel; sends /matrix/step (degree 0 or -99)
- renderPitchRoll: one note per step, click places/moves/clears,
  wheel adjusts vel; sends /matrix/step (degree d or -99)
- Pitch|Drum toggle hidden for pure-drum voices (VOICE_CLASS=drum)
- CSS: .pianoroll/.pr-row/.pr-cell, .seq-cell.soft/.loud,
  .steps-mode-hdr/.steps-mode-btn; --pr-color/--row-color vars
- Guard added: renderStepsTab returns early if color def missing
2026-06-29 12:02:36 +02:00
L'électron rare e81a70de26 feat: tabbed voice editor
Split renderPattern into renderStepsTab, renderSynthFxTab,
renderModPoseTab behind a 3-tab bar. activeTab module state
resets to steps on open; colour selector stays above tabs.
Per-voice editor-row accent via borderLeftColor; mvoice-label
cursor moved to CSS.
2026-06-29 11:53:30 +02:00
L'électron rare 5470ae9ed5 feat: inline accordion voice editor
Replace fullscreen #colordef-modal with a .voice-editor-row that
expands inline under each voice in the matrix grid. One voice open
at a time; ▷ toggle rotates to ▼ when expanded; clicking label or
button calls toggle(vi, containerEl). Editor content (colour 1-6,
var row, steps, mod, pose, AUDITION) is identical, only location
changes. CSS: drop .cd-modal/.cd-panel, add .voice-editor-row /
.voice-expand-btn.
2026-06-29 11:43:04 +02:00
L'électron rare bc661aea5a refactor: extract matrix grid and voice editor
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 11:30:05 +02:00
L'électron rare aec2014b0a refactor: extract mixer transport scenes seq pads
Move five orthogonal concerns out of main.js into ES modules:
- mixer.js: per-voice fader/mute strips, /launch/vol sends
- transport.js: /sync/beat, /sync/rms, tempo/filter/quant DOM
- scenes.js: sceneMode, /scene/state, slot/prev/next wiring
- sequencer.js: rhythm+melody editors, avlive.seq persistence,
  /seq/* handlers, suppressEmit guard
- launchpad.js: armed/clipState, /armed/ prefix, pads, #clear

Each module exports init(); main.js calls all five in
DOMContentLoaded after loadMatState(). Matrix grid, colordef
modal, timeline, glow system, and generic buttons remain in main.js.
2026-06-29 11:22:10 +02:00
L'électron rare 8615472589 refactor: single colour palette via css vars
Install --m1..--m6 on :root from PALETTE in main.js at bootstrap.
Replace triplicated hex in mcell/cd-row/seq-row with var(--mN).
Remove local COLORS array from renderColorSel; use imported PALETTE.
matrix-state.js stays DOM-free.
2026-06-29 11:10:22 +02:00
L'électron rare b0357af715 refactor: extract matrix state and palette module 2026-06-29 11:01:27 +02:00
L'électron rare 3bab0d5e42 refactor: split osc transport into es module
Move control.js → js/main.js; extract WebSocket↔OSC bridge
into js/osc.js (send/on/onOpen/onClose/onPrefix exports).
Replace 250-line message switch with per-address on() and
onPrefix() registrations. Point index.html at module entry.
2026-06-29 10:49:42 +02:00
L'électron rare 7b35c19017 feat: per-colour res and rev in matrix engine 2026-06-29 10:33:52 +02:00
L'électron rare 3098593dd0 docs: matrix editor implementation plan
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 10:23:28 +02:00
L'électron rare 04ff9ceb22 docs: matrix editor refactor design spec
CI build oscope-of / build-check (push) Has been cancelled
Inline-accordion voice editor (tabs: steps / synth-fx / mod-pose),
adaptive pitch piano-roll vs drum step sequencer, synth+fx tab with
res/rev engine extension, ES-module split, single colour palette.
2026-06-29 10:18:58 +02:00
L'électron rare 8ebd028039 feat: matrix presets v3 - melodies + colour arcs
CI build oscope-of / build-check (push) Has been cancelled
Deep refactor of the 28 matrix presets for style fidelity:
- fix 6 invalid instrument assignments (silent lp_ fallback)
- per-style melodies via colorDefs steps on 7 mono voices
- exploit all 6 variation colours (build rolls, drop lift,
  half-time + octave-down break) instead of monochrome 1
- restore tribal_perc; validate kits against matInstChoices

Validated: 28/28 load via matLoadFile, all step patterns
stream clean on the per-color engine.
2026-06-29 09:58:39 +02:00
clement 929fd9aaf0 fix: colorpose list decode and migrate guard 2026-06-29 01:21:29 +02:00
clement 25bfdf0cb2 merge: reconcile per-color pattern with main
Context: main drifted in parallel (matrix v2: 22 voices x 64 bars,
28 presets, classic-machine instrument emulations) touching the same
files this branch rewrites (matrix.scd, control.js, test_matrix.scd,
launchpad.scd).

Resolution (semantic):
- matrix.scd auto-merged; engine (per-color ~matModPairs, pose fire,
  persistence + migration) composes with main's 22 voices via
  ~matVoices.size; SC test PASS, P:0 B:0.
- control.js: kept main's dynamic dimensions (MATRIX_VOICES.length,
  MATRIX_BARS=64); the per-color model (matColorDefs[vi][c].{mod,pose,
  steps}) wins, so matMod/matSteps/matPoses are removed (folded in);
  reintroduced 16/32 hardcodes replaced with the dynamic constants
  (16-step sequence arrays kept); matLoopEnd -> MATRIX_BARS-1;
  renderRhyGrid fixed to literal 16 steps. Color-centric modal,
  /matrix/colormod + /matrix/colorpose handlers, row-left live select
  retained.
- test_matrix.scd: unioned main's grid-migration tests with the
  per-color mod/pose/rev suite.

Validation: SC TEST PASS, node --check clean, glow 4/4, P:0 B:0,
zero matMod/matSteps/matPoses refs, zero voice-count-16 hardcodes.
2026-06-29 01:12:39 +02:00
clement f03b7bb3bd fix: guard colormod handlers and drop dead css
Add null guard to /matrix/colormod and /matrix/colorpose WS
handlers to prevent TypeError when legacy browser cache holds
null color slots. Remove dead CSS rules for .cd-tabs, .cd-tab,
.cd-tab.on, .cd-pane[hidden].
2026-06-29 00:56:51 +02:00
clement 84054714d3 feat: web color-centric pattern editor 2026-06-29 00:50:38 +02:00
clement 4cb6ce14c0 feat: persist and migrate per-color mod pose 2026-06-29 00:39:37 +02:00
clement dcb35772ae fix: restore rev mod target in matModPairs
The rev (reverb send) modulation target was dropped from ~matModPairs
when factoring capture effects. This branch now emits a 0..1 reverb
send scaled by mod depth, matching the previous engine behavior.
2026-06-29 00:30:21 +02:00
clement df04d8d051 feat: engine reads per-color mod and pose 2026-06-29 00:27:21 +02:00
clement b18cb7c514 fix: fresh color pose arrays and guards
Replace pose: [] with pose: Array.new to ensure fresh arrays per color.
Guard nil action in ~matSetColorPose with default \trigger.
Add test assertions for dedup, echo, and out-of-bounds safety.
2026-06-29 00:14:23 +02:00
clement 6cf09c21be feat: per-color mod and pose state 2026-06-29 00:06:28 +02:00
344 changed files with 33962 additions and 2739 deletions
+18 -2
View File
@@ -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
View File
@@ -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`.
+197 -37
View File
@@ -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()
+46
View File
@@ -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
+69
View File
@@ -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
+43
View File
@@ -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
+335
View File
@@ -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
View File
@@ -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
+522
View File
@@ -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
+73 -27
View File
@@ -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}
+246
View File
@@ -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])
+168 -34
View File
@@ -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):
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+13 -11
View File
@@ -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)
+2 -1
View File
@@ -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)
+9 -8
View File
@@ -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)
+17 -8
View File
@@ -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:
+6 -3
View File
@@ -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
View File
@@ -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
# --------------------------------------------------------------------------
+7 -3
View File
@@ -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
+20
View File
@@ -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
+150 -5
View File
@@ -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"
+60
View File
@@ -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
+105
View File
@@ -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
+432
View File
@@ -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
+265 -25
View File
@@ -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)
+772
View File
@@ -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)
+141
View File
@@ -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
+424
View File
@@ -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"
+217 -18
View File
@@ -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"
+36 -1
View File
@@ -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` (AQ).
- [ ] **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` (AK).
- [ ] **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` (AN).
- [ ] **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` (AO).
- [ ] **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` (AP).
- [ ] **Step 8: Commit.**
```bash
git add sound_algo/data_only/matrix.scd sound_algo/data_only/matrix_presets/test_global_actions.scd
git commit -m "feat(matrix): pinch hold-to-fire + progress"
```
---
### Task 3: Generator — emit `pinchHoldMs`
**Files:** Modify `generate_presets.py` (`to_sc`), `test_global_actions.py`; regenerate `*.matrix`.
- [ ] **Step 1: Test (failing).** In `test_global_actions.py`, add after the `EXPECT_SET` block:
```python
EXPECT_PHMS = "'pinchHoldMs': 300"
check(EXPECT_PHMS in sample, "to_sc output missing pinchHoldMs")
missing_phms = [os.path.basename(f) for f in files
if EXPECT_PHMS not in open(f, encoding="utf-8").read()]
check(not missing_phms, "pinchHoldMs missing in: %s" % missing_phms[:5])
```
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd sound_algo/data_only/matrix_presets && uv run python test_global_actions.py`
Expected: `FAIL: to_sc output missing pinchHoldMs`.
- [ ] **Step 3: Emit it.** In `to_sc`, change the tail so `globalSettings` ends with a comma and `pinchHoldMs` is the last field. Replace:
```python
"'globalSettings': ( 'transientBars': 1, "
"'breakdownKeep': [ \\kick, \\sub ] )\n)\n"
```
with:
```python
"'globalSettings': ( 'transientBars': 1, "
"'breakdownKeep': [ \\kick, \\sub ] ),\n"
"'pinchHoldMs': 300\n)\n"
```
- [ ] **Step 4: Regen (twice) + gates.**
`uv run python generate_presets.py` (twice). Then `uv run python test_global_actions.py` (`GLOBALACT-PY PASS`), `uv run python test_sections.py` (`TEST PASS`), `uv run python test_families.py` (`TEST PASS`), `/Applications/SuperCollider.app/Contents/MacOS/sclang test_global_actions.scd` (`GLOBALACT PASS`).
- [ ] **Step 5: Commit.**
```bash
git add sound_algo/data_only/matrix_presets/generate_presets.py sound_algo/data_only/matrix_presets/test_global_actions.py sound_algo/data_only/matrix_presets/*.matrix
git commit -m "feat(presets): emit default pinch hold ms"
```
---
### Task 4: Web — 2-column menu, hold slider, progress fill
**Files:** Modify `web_realart/public/control/js/matrix-state.js`, `js/morceau-panel.js`, `index.html`, `control.css`, `web_realart/test/matrix-state.test.mjs`.
**Interfaces:**
- Consumes: `GLOBAL_SLOTS` (8), the SC `/matrix/pinchhold` + `/matrix/pinchprogress` routes.
- Produces: `matPinchHoldMs` state + persistence; a hold slider; per-slot progress fill; 2-column MG/MD layout.
- [ ] **Step 1: State test (failing).** Append to `matrix-state.test.mjs`:
```javascript
import { matPinchHoldMs, setPinchHoldMs } from "../public/control/js/matrix-state.js";
test("default pinch hold ms + setter", () => {
assert.equal(matPinchHoldMs, 300);
setPinchHoldMs(450);
assert.equal(matPinchHoldMs, 450);
setPinchHoldMs(300);
});
```
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd web_realart && node --test test/matrix-state.test.mjs`
Expected: import failure.
- [ ] **Step 3: Add the state.** In `matrix-state.js`, after `matGlobalSettings`, add:
```javascript
export let matPinchHoldMs = 300;
export function setPinchHoldMs(v) { matPinchHoldMs = v; }
```
In `saveMatState`, add `phms: matPinchHoldMs` to the blob; in `loadMatState`, after the `gset` restore, add:
```javascript
if (Number.isFinite(raw.phms)) matPinchHoldMs = raw.phms;
```
- [ ] **Step 4: 2-column layout.** In `control.css`, change `.morceau-slots` to fill column-first so slots 0-3 (MG) form the left column and 4-7 (MD) the right. Replace the `.morceau-slots` rule with:
```css
.morceau-slots { display: grid; grid-auto-flow: column; grid-template-rows: repeat(4, auto);
grid-template-columns: 1fr 1fr; gap: 6px 12px; }
.morceau-slot { position: relative; }
.morceau-slot-fill { position: absolute; left: 0; bottom: 0; height: 2px; width: 0%;
background: #8c8; transition: width 60ms linear; pointer-events: none; }
.morceau-slot-fill.fired { background: #cc2; height: 100%; opacity: 0.25; }
```
- [ ] **Step 5: Progress fill + hold slider in `morceau-panel.js`.**
In the slot-render loop, append a fill element to each slot label and keep a reference:
```javascript
const fill = document.createElement("div");
fill.className = "morceau-slot-fill";
wrap.appendChild(fill);
fills[slot.key] = fill;
```
(declare `const fills = [];` next to `const selects = [];`). After the slots loop, add the progress handler:
```javascript
on("/matrix/pinchprogress", (args) => {
const slot = Math.round(Number(args[0]));
const prog = Number(args[1]) || 0;
const f = fills[slot];
if (!f) return;
f.style.width = (prog * 100) + "%";
f.classList.toggle("fired", prog >= 1);
});
```
Add the hold slider into the Réglages group (next to the transient-duration slider), wired to `/matrix/pinchhold`:
```javascript
const holdRow = document.createElement("label");
holdRow.className = "morceau-set-row";
const hSpan = document.createElement("span"); hSpan.textContent = "Maintien pince (ms)";
const hInp = document.createElement("input");
hInp.type = "range"; hInp.min = 50; hInp.max = 2000; hInp.step = 10; hInp.value = matPinchHoldMs;
const hOut = document.createElement("output"); hOut.textContent = matPinchHoldMs;
hInp.addEventListener("input", () => { hOut.textContent = hInp.value; });
hInp.addEventListener("change", () => { setPinchHoldMs(+hInp.value); send("/matrix/pinchhold", +hInp.value); saveMatState(); });
holdRow.append(hSpan, hInp, hOut);
sHost.appendChild(holdRow);
on("/matrix/pinchhold", (args) => {
const v = Math.round(Number(args[0]));
if (Number.isFinite(v)) { hInp.value = v; hOut.textContent = v; setPinchHoldMs(v); saveMatState(); }
});
onOpen(() => send("/matrix/pinchhold/get"));
send("/matrix/pinchhold/get");
```
Add `matPinchHoldMs, setPinchHoldMs` to the `matrix-state.js` import.
- [ ] **Step 6: Parse + suite.**
Run: `cd web_realart && node --check public/control/js/morceau-panel.js` (no output) and `node --test test/*.test.mjs` (all green).
- [ ] **Step 7: Commit.**
```bash
git add web_realart/public/control/js/matrix-state.js web_realart/public/control/js/morceau-panel.js web_realart/public/control/index.html web_realart/public/control/control.css web_realart/test/matrix-state.test.mjs
git commit -m "feat(control): pinch hold slider, progress, 2-col menu"
```
---
### Task 5: Deploy + live smoke (macm1)
**Files:** none.
- [ ] **Step 1:** On user OK: `git push origin main`; `ssh macm1 'cd ~/Documents/Projets/AV-Live && git pull --ff-only'`. Verify the decouple on macm1 (torch present): `ssh macm1 'cd ~/Documents/Projets/AV-Live/data_only_viz && .venv/bin/python -m pytest tests/test_action_head_finger_emit.py -q'` → all green. Then FULL launcher restart (`launch_concert.sh` relaunches the pose pipeline with `PINCH_ENABLE=1`). Hard-reload the web page.
- [ ] **Step 2:** Pinch-AND-HOLD a finger → the slot cell fills 0→1 and fires at the threshold; a quick pinch fires nothing; the "Maintien pince (ms)" slider changes the required hold live; the air-piano makes no sound; the menu shows MG (left) / MD (right) columns. Report to the user.
---
## Self-review
**Spec coverage:** decouple (committed 4bccbc1) + launcher → Task 1; pinch edges → Task 1; SC hold-to-fire + progress + hold route + save/load → Task 2; generator pinchHoldMs → Task 3; web 2-col + hold slider + progress → Task 4; smoke + decouple verify → Task 5. ✓
**Placeholder scan:** complete code; hold 300/clamp 50-2000, slot math, OSC routes concrete; no TBD.
**Type consistency:** `/pose/pinch [pid, hand, finger, state]` produced by `send_pinch` (Task 1) ↔ read `state=msg[4]` in `\mat_ev_pinch` (Task 2). `~matPinchHoldMs` (Float) set by `/matrix/pinchhold` (Task 2) ↔ web `matPinchHoldMs`/`setPinchHoldMs` + slider send (Task 4) — single-value contract. `/matrix/pinchprogress [slot, prog]` emitted in `~matPinchTick`/release (Task 2) ↔ web `on("/matrix/pinchprogress")` fill (Task 4). `pinchHoldMs` save field consistent in `~matSave`/`~matLoadFile` (Task 2) ↔ generator literal (Task 3). Slot 0..7 = `hand*4+(finger-1)` everywhere. Per-voice `~matPoseFire` still on engage only.
@@ -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` (AF, 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.30.5 (modest, musical). Pitch depth 0.30.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) | 1620 |
| HandLiftSolver | < 2 |
| Encode frame v2 + usbmux TCP (câble) | 25 |
| Décode Mac + filtre pose + calcul geste | 58 |
| OSC → SuperCollider | ~2 |
| **Total estimé** | **~3040 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.61.9,
curled ~0.81.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
}
}
+27 -3
View File
@@ -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, [])
}
}
+135
View File
@@ -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 ? ();
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