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.
This commit is contained in:
L'électron rare
2026-06-30 15:46:13 +02:00
parent 2902f5c7cd
commit b3ec348378
5 changed files with 68 additions and 9 deletions
+6 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+21
View File
@@ -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
+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
+3 -4
View File
@@ -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.