Add the agent heartbeat; retune listening so it stops flickering
The face now treats /hermes/state as the agent's heartbeat: when nothing has been heard for agent_timeout_s (10 s) it logs "agent gone" and sleeps, so "sleeping" really means "no agent connected". It also logs every state change, which is how a missed wake message was found: a one-off publish before discovery matches is lost under volatile QoS. The live log showed the face flickering between listening and idle about twice a second, because the detector let go after 0.4 s. Hold is now 1.2 s so pauses between words do not drop it, and the margin 7 dB over the adaptive floor (quiet room noise peaks sit about 3 dB over it), with tests for both. Confirmed by the owner on the real display: gaze directions, and a mouth that follows their live voice proportionally. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
@@ -20,3 +20,21 @@ def resolve_state(agent: str, audio: str) -> str:
|
||||
if agent == 'thinking':
|
||||
return 'thinking'
|
||||
return 'idle'
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
"""Whether the agent side has been heard from recently. The agent (hermes_mcp) republishes its
|
||||
state about once a second; when that stops, nobody is connected and the face should sleep.
|
||||
timeout_s <= 0 disables the check (always alive)."""
|
||||
|
||||
def __init__(self, timeout_s: float):
|
||||
self.timeout_s = timeout_s
|
||||
self._last = None
|
||||
|
||||
def beat(self, now: float) -> None:
|
||||
self._last = now
|
||||
|
||||
def alive(self, now: float) -> bool:
|
||||
if self.timeout_s <= 0:
|
||||
return True
|
||||
return self._last is not None and now - self._last <= self.timeout_s
|
||||
|
||||
@@ -7,6 +7,9 @@ Subscribes: /hermes/state (std_msgs/String): sleeping idle listening thinking l
|
||||
/camera/camera_info (sensor_msgs/CameraInfo): the image size for that, if published
|
||||
/audio/activity (std_msgs/String): speaking | listening | quiet, from hermes_audio
|
||||
/audio/scope/{mic,speaker} (std_msgs/Float32MultiArray): the real waveform for the mouth
|
||||
/hermes/state doubles as the agent's heartbeat: hermes_mcp republishes it about once a second, and
|
||||
when it has not been heard for `agent_timeout_s` (default 10, 0 disables) the face goes to sleep, so
|
||||
"sleeping" really means "no agent connected".
|
||||
Which state shows is decided by hermes_face.arbiter: sleeping (agent only) > speaking > listening >
|
||||
thinking > idle; room sound never wakes a sleeping face.
|
||||
The face starts in `initial_state` (default `sleeping`), so it runs with nothing else up; whichever
|
||||
@@ -21,7 +24,7 @@ from sensor_msgs.msg import CameraInfo, JointState
|
||||
from std_msgs.msg import Float32MultiArray, String
|
||||
from vision_msgs.msg import Detection2DArray
|
||||
|
||||
from .arbiter import resolve_state
|
||||
from .arbiter import Heartbeat, resolve_state
|
||||
from .gaze import Target, gaze_from_pixel, pick_target
|
||||
from .render import STATES, FaceRenderer
|
||||
|
||||
@@ -37,6 +40,8 @@ class FaceNode(Node):
|
||||
initial = param('initial_state', 'sleeping')
|
||||
self.agent_state = initial if initial in STATES else 'sleeping'
|
||||
self.audio_activity = 'quiet'
|
||||
self._logged_state = ''
|
||||
self.heartbeat = Heartbeat(float(param('agent_timeout_s', 10.0)))
|
||||
self.audio_drive = bool(param('audio_drive', True))
|
||||
self.follow = bool(param('gaze_follow', True))
|
||||
self.image_size = (float(param('camera_width', 1296)), float(param('camera_height', 972)))
|
||||
@@ -69,12 +74,14 @@ class FaceNode(Node):
|
||||
self._frames, self._stat_t = 0, self._last
|
||||
self.create_timer(1.0 / self.fps, self._tick)
|
||||
self.get_logger().info(f'face up: {output}, {self.fps:g} fps, state {self.renderer.state}')
|
||||
self._resolve(time.monotonic())
|
||||
|
||||
def _on_state(self, msg: String) -> None:
|
||||
if msg.data not in STATES:
|
||||
self.get_logger().warn(f'ignoring unknown state {msg.data!r}; known: {", ".join(STATES)}')
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.heartbeat.beat(now)
|
||||
if msg.data == 'looking': # a timed event: the renderer squints, flashes, then goes back
|
||||
self.renderer.set_state('looking', now)
|
||||
return
|
||||
@@ -86,8 +93,14 @@ class FaceNode(Node):
|
||||
self._resolve(time.monotonic())
|
||||
|
||||
def _resolve(self, now: float) -> None:
|
||||
if self.agent_state != 'sleeping' and not self.heartbeat.alive(now):
|
||||
self.get_logger().info(f'no /hermes/state for {self.heartbeat.timeout_s:g} s: agent gone, sleeping')
|
||||
self.agent_state = 'sleeping'
|
||||
if self.renderer.state != 'looking':
|
||||
self.renderer.set_state(resolve_state(self.agent_state, self.audio_activity if self.audio_drive else 'quiet'), now)
|
||||
if self.renderer.state != self._logged_state:
|
||||
self._logged_state = self.renderer.state
|
||||
self.get_logger().info(f'state: {self._logged_state} (agent={self.agent_state}, audio={self.audio_activity})')
|
||||
|
||||
def _on_joints(self, msg: JointState) -> None:
|
||||
pos = dict(zip(msg.name, msg.position))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from hermes_face.arbiter import resolve_state
|
||||
from hermes_face.arbiter import Heartbeat, resolve_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize('agent,audio,expected', [
|
||||
@@ -18,3 +18,17 @@ from hermes_face.arbiter import resolve_state
|
||||
])
|
||||
def test_priority(agent, audio, expected):
|
||||
assert resolve_state(agent, audio) == expected
|
||||
|
||||
|
||||
def test_heartbeat_expires_without_beats_and_recovers():
|
||||
hb = Heartbeat(10.0)
|
||||
assert not hb.alive(0.0) # never heard from: not alive
|
||||
hb.beat(5.0)
|
||||
assert hb.alive(5.0) and hb.alive(15.0)
|
||||
assert not hb.alive(15.1)
|
||||
hb.beat(20.0)
|
||||
assert hb.alive(25.0)
|
||||
|
||||
|
||||
def test_heartbeat_disabled_is_always_alive():
|
||||
assert Heartbeat(0).alive(1e9) and Heartbeat(-1).alive(0.0)
|
||||
|
||||
Reference in New Issue
Block a user