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:
2026-10-04 23:43:14 -07:00
co-authored by Claude Sonnet 5.5
parent 79920ff6bc
commit 5d1ad1eff7
7 changed files with 95 additions and 15 deletions
+26 -4
View File
@@ -42,9 +42,9 @@ def test_listens_to_a_voice_and_lets_go_after_the_hold():
assert a.state == 'quiet'
t = run_activity(a, -35, -100, 0.5, t)
assert a.state == 'listening'
run_activity(a, -62, -100, 0.2, t)
assert a.state == 'listening' # still holding
run_activity(a, -62, -100, 1.0, t + 0.2)
run_activity(a, -62, -100, 0.8, t)
assert a.state == 'listening' # still holding, through a pause between words
run_activity(a, -62, -100, 1.0, t + 0.8)
assert a.state == 'quiet'
@@ -74,10 +74,32 @@ def test_a_room_that_is_already_noisy_calibrates_instead_of_listening_forever():
assert a.state == 'quiet' and a.mic_threshold_db > -40
t = run_activity(a, -25, -100, 0.5, 5.0) # a voice 18 dB above it
assert a.state == 'listening'
run_activity(a, -43, -100, 1.0, t)
run_activity(a, -43, -100, 2.0, t)
assert a.state == 'quiet'
def test_pauses_between_words_do_not_flicker():
a = Activity()
t = run_activity(a, -43, -100, 5)
states = []
for _ in range(6): # six words, 0.3 s each with 0.3 s gaps
for level, secs in ((-25, 0.3), (-43, 0.3)):
for _ in range(int(secs / 0.05)):
t += 0.05
states.append(a.update(level, -100, t, 0.05))
assert states[8:].count('quiet') == 0 # once it starts listening it stays there
def test_quiet_room_noise_peaks_stay_below_the_threshold():
a = Activity()
t = run_activity(a, -43, -100, 5)
flicker = []
for i in range(200):
t += 0.05
flicker.append(a.update(-43 + (3 if i % 7 == 0 else 0), -100, t, 0.05)) # peaks 3 dB over the floor
assert set(flicker) == {'quiet'}
def test_a_new_steady_noise_is_absorbed_after_a_while():
a = Activity()
t = run_activity(a, -60, -100, 5) # quiet room