Add VADUserTurnStopStrategy for VAD-only realtime pipelines

Provides a dedicated stop strategy that triggers on VADUserStoppedSpeakingFrame
without requiring a turn analyzer or transcriptions. Complements the existing
VADTurnAnalyzerUserTurnStopStrategy and matches the one-per-modality convention
already used in user_start/.
This commit is contained in:
Mark Backman
2026-04-20 10:01:42 -04:00
parent 2e5e109bb6
commit 3077395ffc
6 changed files with 126 additions and 7 deletions

View File

@@ -7,7 +7,6 @@
"""Tests for VADTurnAnalyzerUserTurnStopStrategy."""
import unittest
from typing import Optional, Tuple
from unittest.mock import AsyncMock
import pytest
@@ -40,7 +39,7 @@ class MockTurnAnalyzer(BaseTurnAnalyzer):
super().__init__()
self._append_audio_state = EndOfTurnState.INCOMPLETE
self._analyze_state = EndOfTurnState.INCOMPLETE
self._prediction: Optional[MetricsData] = None
self._prediction: MetricsData | None = None
self._speech_triggered = False
@property
@@ -54,7 +53,7 @@ class MockTurnAnalyzer(BaseTurnAnalyzer):
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
return self._append_audio_state
async def analyze_end_of_turn(self) -> Tuple[EndOfTurnState, Optional[MetricsData]]:
async def analyze_end_of_turn(self) -> tuple[EndOfTurnState, MetricsData | None]:
return self._analyze_state, self._prediction
def update_vad_start_secs(self, vad_start_secs: float):

View File

@@ -0,0 +1,79 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for VADUserTurnStopStrategy."""
import unittest
from pipecat.frames.frames import (
InputAudioRawFrame,
TranscriptionFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.types import ProcessFrameResult
from pipecat.turns.user_stop.vad_user_turn_stop_strategy import VADUserTurnStopStrategy
from pipecat.utils.asyncio.task_manager import TaskManager
class TestVADUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.strategy = VADUserTurnStopStrategy()
self.task_manager = TaskManager()
await self.strategy.setup(self.task_manager)
self.turn_stopped_called = False
@self.strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
self.turn_stopped_called = True
async def asyncTearDown(self):
await self.strategy.cleanup()
async def test_vad_stop_triggers_user_turn_stopped(self):
"""VADUserStoppedSpeakingFrame should trigger user turn stopped."""
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert self.turn_stopped_called
async def test_vad_start_does_not_trigger(self):
"""VADUserStartedSpeakingFrame should not trigger user turn stopped."""
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
assert not self.turn_stopped_called
async def test_transcription_frames_ignored(self):
"""TranscriptionFrame should not trigger user turn stopped."""
await self.strategy.process_frame(
TranscriptionFrame(text="hello", user_id="user1", timestamp="now")
)
assert not self.turn_stopped_called
async def test_input_audio_frames_ignored(self):
"""InputAudioRawFrame should not trigger user turn stopped."""
await self.strategy.process_frame(
InputAudioRawFrame(audio=b"\x00" * 320, sample_rate=16000, num_channels=1)
)
assert not self.turn_stopped_called
async def test_process_frame_returns_continue(self):
"""process_frame should always return CONTINUE."""
result = await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert result == ProcessFrameResult.CONTINUE
result = await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
assert result == ProcessFrameResult.CONTINUE
async def test_reset_does_not_crash(self):
"""reset() should complete without error."""
await self.strategy.reset()
if __name__ == "__main__":
unittest.main()