mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
test(voice): drive silence detection tests with an explicit clock
test_silence_callback_fires_after_speech_then_silence and test_custom_threshold_and_duration space their audio frames with time.sleep(0.06) and check the result against 50 ms thresholds. That leaves a 10 ms margin, which only survives if the clock is finer-grained than the margin — and on Windows it isn't. time.monotonic() there is GetTickCount64() with 15.625 ms resolution until CPython 3.13 moved it to QueryPerformanceCounter(), so a sleep that really lasts 62.5 ms measures as 46.9 ms often enough to matter, landing under the threshold. Depending on which sleep got clipped, either the speech-confirm gate misses or the silence timer never matures — which is why the same flake shows up on two different asserts. Both tests now advance a hand-driven clock instead of sleeping, so the arithmetic is exact on every platform and neither test waits on real time. The fixture patches the `time` name inside tools.voice_mode rather than time.monotonic itself: voice_mode.time IS the stdlib module, so patching through it would swap the clock out from under every other importer for the duration of the test. Measured on Windows 11 / Python 3.11.9, 20 runs of each test: 6/20 and 3/20 failed before, 0/30 after. The same machine on Python 3.13, where monotonic() is QueryPerformanceCounter(), never failed either test — that comparison is what pinned the clock resolution as the cause rather than the detection logic. Linux CI never sees this: clock_gettime is nanosecond-resolution there.
This commit is contained in:
parent
98fea1323b
commit
309ed7ec65
1 changed files with 58 additions and 9 deletions
|
|
@ -68,6 +68,54 @@ def mock_sd(monkeypatch):
|
|||
return mock
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
"""Stand-in for the ``time`` module with a monotonic clock the test drives.
|
||||
|
||||
Silence detection compares ``time.monotonic()`` deltas against thresholds
|
||||
of a few dozen milliseconds. Driving those deltas with real ``sleep()``
|
||||
calls only works when the platform clock is finer-grained than the margin
|
||||
the test leaves: ``time.monotonic()`` is ``GetTickCount64()`` (15.625 ms
|
||||
resolution) on Windows until CPython 3.13 moved it to
|
||||
``QueryPerformanceCounter()``, so a 60 ms sleep can legitimately measure
|
||||
as 46 ms and land under a 50 ms threshold. Advancing an explicit clock
|
||||
keeps the arithmetic exact on every platform.
|
||||
|
||||
Everything other than ``monotonic`` delegates to the real module, so
|
||||
``time.sleep``/``time.strftime`` in the code under test keep working.
|
||||
"""
|
||||
|
||||
def __init__(self, real_time, start: float = 1000.0) -> None:
|
||||
self._real = real_time
|
||||
self._now = start
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self._now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self._now += seconds
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_clock(monkeypatch):
|
||||
"""Give voice_mode a hand-driven clock.
|
||||
|
||||
Patches the name ``time`` inside ``tools.voice_mode`` rather than
|
||||
``time.monotonic`` itself -- ``voice_mode.time`` *is* the stdlib module,
|
||||
so setting the attribute on it would swap the clock out from under every
|
||||
other importer for the duration of the test.
|
||||
"""
|
||||
import time as real_time
|
||||
|
||||
import tools.voice_mode as voice_mode
|
||||
|
||||
clock = _FakeTime(real_time)
|
||||
monkeypatch.setattr(voice_mode, "time", clock)
|
||||
return clock
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# detect_audio_environment — WSL / SSH / Docker detection
|
||||
# ============================================================================
|
||||
|
|
@ -1427,7 +1475,7 @@ class TestPlayBeep:
|
|||
# ============================================================================
|
||||
|
||||
class TestSilenceDetection:
|
||||
def test_silence_callback_fires_after_speech_then_silence(self, mock_sd):
|
||||
def test_silence_callback_fires_after_speech_then_silence(self, mock_sd, fake_clock):
|
||||
np = pytest.importorskip("numpy")
|
||||
import threading
|
||||
|
||||
|
|
@ -1456,7 +1504,7 @@ class TestSilenceDetection:
|
|||
# Simulate sustained speech (multiple loud chunks to exceed min_speech_duration)
|
||||
loud_frame = np.full((1600, 1), 5000, dtype="int16")
|
||||
callback(loud_frame, 1600, None, None)
|
||||
time.sleep(0.06)
|
||||
fake_clock.advance(0.06)
|
||||
callback(loud_frame, 1600, None, None)
|
||||
assert recorder._has_spoken is True
|
||||
|
||||
|
|
@ -1464,12 +1512,13 @@ class TestSilenceDetection:
|
|||
silent_frame = np.zeros((1600, 1), dtype="int16")
|
||||
callback(silent_frame, 1600, None, None)
|
||||
|
||||
# Wait a bit past the silence duration, then send another silent frame
|
||||
time.sleep(0.06)
|
||||
# Move past the silence duration, then send another silent frame
|
||||
fake_clock.advance(0.06)
|
||||
callback(silent_frame, 1600, None, None)
|
||||
|
||||
# The callback should have been fired
|
||||
assert fired.wait(timeout=1.0) is True
|
||||
# The callback should have been fired (it runs on a real thread, so
|
||||
# this wait is the one place real time is still involved)
|
||||
assert fired.wait(timeout=5.0) is True
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
|
|
@ -1850,7 +1899,7 @@ class TestAudioLevelIndicator:
|
|||
class TestConfigurableSilenceParams:
|
||||
"""Verify that silence detection params can be configured."""
|
||||
|
||||
def test_custom_threshold_and_duration(self, mock_sd):
|
||||
def test_custom_threshold_and_duration(self, mock_sd, fake_clock):
|
||||
np = pytest.importorskip("numpy")
|
||||
|
||||
mock_stream = MagicMock()
|
||||
|
|
@ -1874,7 +1923,7 @@ class TestConfigurableSilenceParams:
|
|||
moderate = np.full((1600, 1), 1000, dtype="int16")
|
||||
for _ in range(5):
|
||||
callback(moderate, 1600, None, None)
|
||||
time.sleep(0.02)
|
||||
fake_clock.advance(0.02)
|
||||
|
||||
assert recorder._has_spoken is False
|
||||
assert fired.wait(timeout=0.2) is False
|
||||
|
|
@ -1882,7 +1931,7 @@ class TestConfigurableSilenceParams:
|
|||
# Now send really loud audio (above 5000 threshold)
|
||||
very_loud = np.full((1600, 1), 8000, dtype="int16")
|
||||
callback(very_loud, 1600, None, None)
|
||||
time.sleep(0.06)
|
||||
fake_clock.advance(0.06)
|
||||
callback(very_loud, 1600, None, None)
|
||||
assert recorder._has_spoken is True
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue