diff --git a/data_only_viz/finger_strike.py b/data_only_viz/finger_strike.py index f59f9bd..1d41fd1 100644 --- a/data_only_viz/finger_strike.py +++ b/data_only_viz/finger_strike.py @@ -129,6 +129,7 @@ class FingerStrikeDetector: 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 class _PinchState: @@ -176,13 +177,16 @@ class PinchDetector: 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)) + events.append(PinchEvent(hand=slot, finger=i + 1, state=1)) for slot in range(len(self._state)): if slot not in present: for i in range(4): - self._state[slot][i].engaged = False + if self._state[slot][i].engaged: + self._state[slot][i].engaged = False + events.append(PinchEvent(hand=slot, finger=i + 1, state=0)) return events diff --git a/data_only_viz/pose_bridge.py b/data_only_viz/pose_bridge.py index c2173a3..7eafb79 100644 --- a/data_only_viz/pose_bridge.py +++ b/data_only_viz/pose_bridge.py @@ -398,8 +398,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: diff --git a/data_only_viz/tests/test_finger_strike.py b/data_only_viz/tests/test_finger_strike.py index 1278aae..8169cbb 100644 --- a/data_only_viz/tests/test_finger_strike.py +++ b/data_only_viz/tests/test_finger_strike.py @@ -123,3 +123,24 @@ def test_pinch_refractory_blocks(): b = det.step([_pinch_hand(*_PINCH)], 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)], 0.0) + engage = det.step([_pinch_hand(*_PINCH)], 0.1) # engage edge + release = det.step([_pinch_hand(*_OPEN)], 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)], 0.0) + engage = det.step([_pinch_hand(*_PINCH)], 0.1) # engage + release = det.step([], 0.2) # hand gone + assert len(engage) == 1 and engage[0].state == 1 + assert len(release) == 1 and release[0].state == 0 diff --git a/data_only_viz/tests/test_pose_bridge_finger.py b/data_only_viz/tests/test_pose_bridge_finger.py index e2f2651..f2b6651 100644 --- a/data_only_viz/tests/test_pose_bridge_finger.py +++ b/data_only_viz/tests/test_pose_bridge_finger.py @@ -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 diff --git a/launcher/concert/launch_concert.sh b/launcher/concert/launch_concert.sh index 79d419d..c1d4dc8 100755 --- a/launcher/concert/launch_concert.sh +++ b/launcher/concert/launch_concert.sh @@ -20,10 +20,9 @@ export PATH=/opt/homebrew/bin:$PATH # 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 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}" +# 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}" export POSE_FILTER=median+kalman+lookahead+ik+arkit_fuse # CONCERT_MIRROR=0 in the environment disables the video mirror.