Rework audio idle detection with timestamp-based adaptive sleep

Replaces the per-frame asyncio.Event signaling with a monotonic
timestamp updated on each audio frame. The handler sleeps until the
next deadline (last_audio_time + timeout), recomputing on each wake-up
to account for audio arriving during sleep.

This avoids waking the handler on every audio frame (~50/s at 20ms
chunks), and guarantees detection latency is bounded by timeout rather
than 2 * timeout.

Also renames audio_starvation_timeout to audio_idle_timeout and
associated identifiers for consistency with existing pipecat naming
(user_idle_timeout, etc.).
This commit is contained in:
Aleix Conchillo Flaqué
2026-04-10 09:55:54 -07:00
parent cb2c1868b0
commit dcd21e7ff4
5 changed files with 54 additions and 49 deletions

View File

@@ -208,18 +208,18 @@ class TestVADController(unittest.IsolatedAsyncioTestCase):
self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams)
AUDIO_STARVATION_TIMEOUT = 0.1
AUDIO_IDLE_TIMEOUT = 0.1
class TestVADControllerStarvation(unittest.IsolatedAsyncioTestCase):
class TestVADControllerAudioIdle(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_audio_starvation_forces_speech_stop(self):
async def test_audio_idle_forces_speech_stop(self):
"""Test that on_speech_stopped fires when no audio arrives while SPEAKING."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer, audio_starvation_timeout=AUDIO_STARVATION_TIMEOUT)
controller = VADController(analyzer, audio_idle_timeout=AUDIO_IDLE_TIMEOUT)
speech_stopped = False
@@ -238,16 +238,16 @@ class TestVADControllerStarvation(unittest.IsolatedAsyncioTestCase):
await controller.process_frame(audio_frame)
self.assertFalse(speech_stopped)
# Stop sending audio, wait for starvation timeout
await asyncio.sleep(AUDIO_STARVATION_TIMEOUT + 0.1)
# Stop sending audio, wait for idle timeout
await asyncio.sleep(AUDIO_IDLE_TIMEOUT + 0.1)
self.assertTrue(speech_stopped)
await controller.cleanup()
async def test_audio_starvation_does_not_fire_when_quiet(self):
"""Test that starvation timeout does NOT fire when VAD is in QUIET state."""
async def test_audio_idle_does_not_fire_when_quiet(self):
"""Test that idle timeout does NOT fire when VAD is in QUIET state."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer, audio_starvation_timeout=AUDIO_STARVATION_TIMEOUT)
controller = VADController(analyzer, audio_idle_timeout=AUDIO_IDLE_TIMEOUT)
speech_stopped = False
@@ -260,8 +260,8 @@ class TestVADControllerStarvation(unittest.IsolatedAsyncioTestCase):
await controller.process_frame(start_frame)
await controller.setup(self.task_manager)
# Stay in QUIET state, wait past starvation timeout
await asyncio.sleep(AUDIO_STARVATION_TIMEOUT + 0.1)
# Stay in QUIET state, wait past idle timeout
await asyncio.sleep(AUDIO_IDLE_TIMEOUT + 0.1)
self.assertFalse(speech_stopped)
await controller.cleanup()