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:
@@ -26,7 +26,7 @@ class AudioStateNode(Node):
|
||||
self.n_scope = int(min(2048, max(64, int(param('scope_samples', 256)))))
|
||||
self.scope_floor_db = float(param('scope_floor_db', -70.0))
|
||||
self.activity = Activity(spk_db=float(param('speaker_threshold_db', -55.0)),
|
||||
margin_db=float(param('mic_margin_db', 9.0)))
|
||||
margin_db=float(param('mic_margin_db', 7.0)))
|
||||
self.levels_pub = self.create_publisher(Float32MultiArray, '/audio/levels', 5)
|
||||
self.activity_pub = self.create_publisher(String, '/audio/activity', 5)
|
||||
self.scope_pub = {'mic': self.create_publisher(Float32MultiArray, '/audio/scope/mic', 2),
|
||||
|
||||
@@ -34,9 +34,10 @@ class Activity:
|
||||
"""Decides 'speaking', 'listening' or 'quiet' from the speaker monitor and microphone levels.
|
||||
|
||||
speaking: the speaker monitor is above spk_db for on_s (so a click is not speech); it lets go
|
||||
after hold_s of quiet.
|
||||
after speak_hold_s of quiet.
|
||||
listening: the microphone is above the adaptive threshold while not speaking, for on_s; it lets
|
||||
go after hold_s. The threshold is margin_db above a noise floor that drops quickly
|
||||
go after hold_s, long enough (1.2 s) to bridge the pauses between words, so a sentence
|
||||
reads as one listening stretch, not a flicker. The threshold is margin_db above a noise floor that drops quickly
|
||||
to new lows and rises slowly (so a long conversation does not become the floor).
|
||||
For its first calibrate_s the floor follows the microphone quickly in both directions (a room
|
||||
that is already noisy must not read as "listening"), and anything that stays 'listening' for
|
||||
@@ -44,8 +45,8 @@ class Activity:
|
||||
The microphone is ignored while speaking: it would hear the robot's own voice.
|
||||
"""
|
||||
|
||||
def __init__(self, spk_db: float = -55.0, margin_db: float = 9.0, min_mic_db: float = -62.0,
|
||||
on_s: float = 0.10, speak_on_s: float = 0.08, hold_s: float = 0.40, speak_hold_s: float = 0.25,
|
||||
def __init__(self, spk_db: float = -55.0, margin_db: float = 7.0, min_mic_db: float = -62.0,
|
||||
on_s: float = 0.08, speak_on_s: float = 0.08, hold_s: float = 1.2, speak_hold_s: float = 0.25,
|
||||
calibrate_s: float = 3.0, ambient_s: float = 8.0):
|
||||
self.spk_db, self.margin_db, self.min_mic_db = spk_db, margin_db, min_mic_db
|
||||
self.on_s, self.speak_on_s, self.hold_s, self.speak_hold_s = on_s, speak_on_s, hold_s, speak_hold_s
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user