fix VAD stuck in SPEAKING state when audio stops mid-speech

This commit is contained in:
Om Chauhan
2026-04-06 09:05:12 +05:30
committed by Aleix Conchillo Flaqué
parent dac88c0a47
commit cb2c1868b0
4 changed files with 155 additions and 5 deletions

View File

@@ -10,8 +10,11 @@ This module provides a controller that wraps a VADAnalyzer to track speech state
and emit events when speech starts, stops, or is actively detected. and emit events when speech starts, stops, or is actively detected.
""" """
import asyncio
import time import time
from typing import Type from typing import Optional, Type
from loguru import logger
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState
from pipecat.frames.frames import ( from pipecat.frames.frames import (
@@ -22,6 +25,7 @@ from pipecat.frames.frames import (
VADParamsUpdateFrame, VADParamsUpdateFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.utils.asyncio.task_manager import BaseTaskManager
from pipecat.utils.base_object import BaseObject from pipecat.utils.base_object import BaseObject
@@ -35,7 +39,8 @@ class VADController(BaseObject):
Event handlers available: Event handlers available:
- on_speech_started: Called when speech begins. - on_speech_started: Called when speech begins.
- on_speech_stopped: Called when speech ends. - on_speech_stopped: Called when speech ends, including forced stop on
audio starvation (no frames received while speaking).
- on_speech_activity: Called periodically while speech is detected. - on_speech_activity: Called periodically while speech is detected.
- on_push_frame: Called when the controller wants to push a frame. - on_push_frame: Called when the controller wants to push a frame.
- on_broadcast_frame: Called when the controller wants to broadcast a frame. - on_broadcast_frame: Called when the controller wants to broadcast a frame.
@@ -63,13 +68,23 @@ class VADController(BaseObject):
... ...
""" """
def __init__(self, vad_analyzer: VADAnalyzer, *, speech_activity_period: float = 0.2): def __init__(
self,
vad_analyzer: VADAnalyzer,
*,
speech_activity_period: float = 0.2,
audio_starvation_timeout: float = 1.0,
):
"""Initialize the VAD controller. """Initialize the VAD controller.
Args: Args:
vad_analyzer: The `VADAnalyzer` instance for processing audio. vad_analyzer: The `VADAnalyzer` instance for processing audio.
speech_activity_period: Minimum interval in seconds between speech_activity_period: Minimum interval in seconds between
`on_speech_activity` events. Defaults to 0.2. `on_speech_activity` events. Defaults to 0.2.
audio_starvation_timeout: Timeout in seconds to force speech stop
when no audio frames are received while in SPEAKING state.
This handles cases like mic mute mid-speech.
Set to 0 to disable. Defaults to 1.0.
""" """
super().__init__() super().__init__()
self._vad_analyzer = vad_analyzer self._vad_analyzer = vad_analyzer
@@ -81,12 +96,33 @@ class VADController(BaseObject):
# be greater than the audio chunks to have any effect). # be greater than the audio chunks to have any effect).
self._speech_activity_period = speech_activity_period self._speech_activity_period = speech_activity_period
# Audio starvation detection: force speech stop when no audio arrives
# while in SPEAKING state (e.g. user mutes mic mid-speech).
self._audio_starvation_timeout = audio_starvation_timeout
self._task_manager: Optional[BaseTaskManager] = None
self._audio_received_event = asyncio.Event()
self._starvation_task: Optional[asyncio.Task] = None
self._register_event_handler("on_speech_started", sync=True) self._register_event_handler("on_speech_started", sync=True)
self._register_event_handler("on_speech_stopped", sync=True) self._register_event_handler("on_speech_stopped", sync=True)
self._register_event_handler("on_speech_activity", sync=True) self._register_event_handler("on_speech_activity", sync=True)
self._register_event_handler("on_push_frame", sync=True) self._register_event_handler("on_push_frame", sync=True)
self._register_event_handler("on_broadcast_frame", sync=True) self._register_event_handler("on_broadcast_frame", sync=True)
async def setup(self, task_manager: BaseTaskManager):
"""Initialize the controller with the given task manager.
Args:
task_manager: The task manager to be associated with this instance.
"""
self._task_manager = task_manager
self._audio_received_event.clear()
if self._audio_starvation_timeout > 0 and not self._starvation_task:
self._starvation_task = self._task_manager.create_task(
self._audio_starvation_handler(),
f"{self}::_audio_starvation_handler",
)
async def process_frame(self, frame: Frame): async def process_frame(self, frame: Frame):
"""Process a frame and handle VAD-related events. """Process a frame and handle VAD-related events.
@@ -116,6 +152,10 @@ class VADController(BaseObject):
It waits for all currently executing event handler tasks to finish It waits for all currently executing event handler tasks to finish
before returning. before returning.
""" """
await super().cleanup()
if self._starvation_task and self._task_manager:
await self._task_manager.cancel_task(self._starvation_task)
self._starvation_task = None
if self._vad_analyzer: if self._vad_analyzer:
await self._vad_analyzer.cleanup() await self._vad_analyzer.cleanup()
@@ -128,6 +168,8 @@ class VADController(BaseObject):
Args: Args:
frame: Audio frame to process. frame: Audio frame to process.
""" """
self._audio_received_event.set()
self._vad_state = await self._handle_vad(frame.audio, self._vad_state) self._vad_state = await self._handle_vad(frame.audio, self._vad_state)
if self._vad_state == VADState.SPEAKING: if self._vad_state == VADState.SPEAKING:
@@ -149,6 +191,26 @@ class VADController(BaseObject):
vad_state = new_vad_state vad_state = new_vad_state
return vad_state return vad_state
async def _audio_starvation_handler(self):
"""Monitor for audio starvation while in SPEAKING state.
When no audio frames arrive for `audio_starvation_timeout` seconds
(e.g. user mutes mic mid-speech), forces a transition to QUIET and
emits `on_speech_stopped`.
"""
while True:
try:
await asyncio.wait_for(
self._audio_received_event.wait(),
timeout=self._audio_starvation_timeout,
)
self._audio_received_event.clear()
except asyncio.TimeoutError:
if self._vad_state == VADState.SPEAKING:
logger.warning(f"{self}: no audio received while speaking, forcing speech stop")
self._vad_state = VADState.QUIET
await self._call_event_handler("on_speech_stopped")
async def _maybe_speech_activity(self): async def _maybe_speech_activity(self):
"""Handle user speaking frame.""" """Handle user speaking frame."""
diff_time = time.time() - self._speech_activity_time diff_time = time.time() - self._speech_activity_time

View File

@@ -107,6 +107,9 @@ class LLMUserAggregatorParams:
has been idle (not speaking) for this duration. Set to 0 to disable has been idle (not speaking) for this duration. Set to 0 to disable
idle detection. idle detection.
vad_analyzer: Voice Activity Detection analyzer instance. vad_analyzer: Voice Activity Detection analyzer instance.
audio_starvation_timeout: Timeout in seconds to force speech stop when
no audio frames are received while in SPEAKING state (e.g. user mutes
mic mid-speech). Set to 0 to disable. Defaults to 1.0.
filter_incomplete_user_turns: Whether to filter out incomplete user turns. filter_incomplete_user_turns: Whether to filter out incomplete user turns.
When enabled, the LLM outputs a turn completion marker at the start of When enabled, the LLM outputs a turn completion marker at the start of
each response: ✓ (complete), ○ (incomplete short), or ◐ (incomplete long). each response: ✓ (complete), ○ (incomplete short), or ◐ (incomplete long).
@@ -121,6 +124,7 @@ class LLMUserAggregatorParams:
user_turn_stop_timeout: float = 5.0 user_turn_stop_timeout: float = 5.0
user_idle_timeout: float = 0 user_idle_timeout: float = 0
vad_analyzer: Optional[VADAnalyzer] = None vad_analyzer: Optional[VADAnalyzer] = None
audio_starvation_timeout: float = 1.0
filter_incomplete_user_turns: bool = False filter_incomplete_user_turns: bool = False
user_turn_completion_config: Optional[UserTurnCompletionConfig] = None user_turn_completion_config: Optional[UserTurnCompletionConfig] = None
@@ -467,7 +471,10 @@ class LLMUserAggregator(LLMContextAggregator):
# VAD controller # VAD controller
self._vad_controller: Optional[VADController] = None self._vad_controller: Optional[VADController] = None
if self._params.vad_analyzer: if self._params.vad_analyzer:
self._vad_controller = VADController(self._params.vad_analyzer) self._vad_controller = VADController(
self._params.vad_analyzer,
audio_starvation_timeout=self._params.audio_starvation_timeout,
)
self._vad_controller.add_event_handler("on_speech_started", self._on_vad_speech_started) self._vad_controller.add_event_handler("on_speech_started", self._on_vad_speech_started)
self._vad_controller.add_event_handler("on_speech_stopped", self._on_vad_speech_stopped) self._vad_controller.add_event_handler("on_speech_stopped", self._on_vad_speech_stopped)
self._vad_controller.add_event_handler( self._vad_controller.add_event_handler(
@@ -553,6 +560,9 @@ class LLMUserAggregator(LLMContextAggregator):
return aggregation return aggregation
async def _start(self, frame: StartFrame): async def _start(self, frame: StartFrame):
if self._vad_controller:
await self._vad_controller.setup(self.task_manager)
await self._user_turn_controller.setup(self.task_manager) await self._user_turn_controller.setup(self.task_manager)
await self._user_idle_controller.setup(self.task_manager) await self._user_idle_controller.setup(self.task_manager)
@@ -584,6 +594,8 @@ class LLMUserAggregator(LLMContextAggregator):
await self._cleanup() await self._cleanup()
async def _cleanup(self): async def _cleanup(self):
if self._vad_controller:
await self._vad_controller.cleanup()
await self._user_turn_controller.cleanup() await self._user_turn_controller.cleanup()
await self._user_idle_controller.cleanup() await self._user_idle_controller.cleanup()

View File

@@ -18,6 +18,7 @@ from pipecat.audio.vad.vad_analyzer import VADAnalyzer
from pipecat.audio.vad.vad_controller import VADController from pipecat.audio.vad.vad_controller import VADController
from pipecat.frames.frames import ( from pipecat.frames.frames import (
Frame, Frame,
StartFrame,
UserSpeakingFrame, UserSpeakingFrame,
VADUserStartedSpeakingFrame, VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame, VADUserStoppedSpeakingFrame,
@@ -45,6 +46,7 @@ class VADProcessor(FrameProcessor):
*, *,
vad_analyzer: VADAnalyzer, vad_analyzer: VADAnalyzer,
speech_activity_period: float = 0.2, speech_activity_period: float = 0.2,
audio_starvation_timeout: float = 1.0,
**kwargs, **kwargs,
): ):
"""Initialize the VAD processor. """Initialize the VAD processor.
@@ -53,11 +55,16 @@ class VADProcessor(FrameProcessor):
vad_analyzer: The VADAnalyzer instance for processing audio. vad_analyzer: The VADAnalyzer instance for processing audio.
speech_activity_period: Minimum interval in seconds between speech_activity_period: Minimum interval in seconds between
UserSpeakingFrame pushes. Defaults to 0.2. UserSpeakingFrame pushes. Defaults to 0.2.
audio_starvation_timeout: Timeout in seconds to force speech stop
when no audio frames are received while in SPEAKING state.
Set to 0 to disable. Defaults to 1.0.
**kwargs: Additional arguments passed to parent class. **kwargs: Additional arguments passed to parent class.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._vad_controller = VADController( self._vad_controller = VADController(
vad_analyzer, speech_activity_period=speech_activity_period vad_analyzer,
speech_activity_period=speech_activity_period,
audio_starvation_timeout=audio_starvation_timeout,
) )
# Push VAD frames when speech events are detected # Push VAD frames when speech events are detected
@@ -90,6 +97,11 @@ class VADProcessor(FrameProcessor):
async def on_broadcast_frame(_controller, frame_cls: Type[Frame], **kwargs): async def on_broadcast_frame(_controller, frame_cls: Type[Frame], **kwargs):
await self.broadcast_frame(frame_cls, **kwargs) await self.broadcast_frame(frame_cls, **kwargs)
async def cleanup(self):
"""Clean up VAD controller resources."""
await super().cleanup()
await self._vad_controller.cleanup()
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process a frame through VAD and forward it. """Process a frame through VAD and forward it.
@@ -104,5 +116,8 @@ class VADProcessor(FrameProcessor):
# 2. Audio flows through immediately while VAD detection happens after # 2. Audio flows through immediately while VAD detection happens after
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
if isinstance(frame, StartFrame):
await self._vad_controller.setup(self.task_manager)
# Let the VAD controller handle the frame # Let the VAD controller handle the frame
await self._vad_controller.process_frame(frame) await self._vad_controller.process_frame(frame)

View File

@@ -4,6 +4,7 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import asyncio
import unittest import unittest
from typing import List from typing import List
@@ -11,6 +12,7 @@ from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams, VADState
from pipecat.audio.vad.vad_controller import VADController from pipecat.audio.vad.vad_controller import VADController
from pipecat.frames.frames import Frame, InputAudioRawFrame, SpeechControlParamsFrame, StartFrame from pipecat.frames.frames import Frame, InputAudioRawFrame, SpeechControlParamsFrame, StartFrame
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
class MockVADAnalyzer(VADAnalyzer): class MockVADAnalyzer(VADAnalyzer):
@@ -206,5 +208,64 @@ class TestVADController(unittest.IsolatedAsyncioTestCase):
self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams) self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams)
AUDIO_STARVATION_TIMEOUT = 0.1
class TestVADControllerStarvation(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):
"""Test that on_speech_stopped fires when no audio arrives while SPEAKING."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer, audio_starvation_timeout=AUDIO_STARVATION_TIMEOUT)
speech_stopped = False
@controller.event_handler("on_speech_stopped")
async def on_speech_stopped(_controller):
nonlocal speech_stopped
speech_stopped = True
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
await controller.setup(self.task_manager)
# Enter SPEAKING state
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
analyzer.set_next_state(VADState.SPEAKING)
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)
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."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer, audio_starvation_timeout=AUDIO_STARVATION_TIMEOUT)
speech_stopped = False
@controller.event_handler("on_speech_stopped")
async def on_speech_stopped(_controller):
nonlocal speech_stopped
speech_stopped = True
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
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)
self.assertFalse(speech_stopped)
await controller.cleanup()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()