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.
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).
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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).
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.
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.
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.
_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
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
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).
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.