Files
pipecat/tests/test_vad_turn_analyzer_user_turn_stop_strategy.py
Mark Backman 3077395ffc 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/.
2026-04-20 10:10:58 -04:00

214 lines
7.7 KiB
Python

#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for VADTurnAnalyzerUserTurnStopStrategy."""
import unittest
from unittest.mock import AsyncMock
import pytest
from pipecat.audio.turn.base_turn_analyzer import (
BaseTurnAnalyzer,
BaseTurnParams,
EndOfTurnState,
)
from pipecat.frames.frames import (
InputAudioRawFrame,
MetricsFrame,
SpeechControlParamsFrame,
StartFrame,
TranscriptionFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import MetricsData
from pipecat.turns.user_stop.vad_turn_analyzer_user_turn_stop_strategy import (
VADTurnAnalyzerUserTurnStopStrategy,
)
from pipecat.utils.asyncio.task_manager import TaskManager
class MockTurnAnalyzer(BaseTurnAnalyzer):
"""Mock turn analyzer for testing."""
def __init__(self):
super().__init__()
self._append_audio_state = EndOfTurnState.INCOMPLETE
self._analyze_state = EndOfTurnState.INCOMPLETE
self._prediction: MetricsData | None = None
self._speech_triggered = False
@property
def speech_triggered(self) -> bool:
return self._speech_triggered
@property
def params(self) -> BaseTurnParams:
return BaseTurnParams()
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
return self._append_audio_state
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):
pass
def clear(self):
pass
class TestVADTurnAnalyzerUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.analyzer = MockTurnAnalyzer()
self.strategy = VADTurnAnalyzerUserTurnStopStrategy(turn_analyzer=self.analyzer)
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
self.pushed_frames = []
@self.strategy.event_handler("on_push_frame")
async def on_push_frame(strategy, frame, direction):
self.pushed_frames.append(frame)
self.broadcast_frames = []
@self.strategy.event_handler("on_broadcast_frame")
async def on_broadcast_frame(strategy, frame_cls, **kwargs):
self.broadcast_frames.append((frame_cls, kwargs))
async def asyncTearDown(self):
await self.strategy.cleanup()
async def test_vad_stop_complete_triggers_immediately(self):
"""VAD stop with COMPLETE should trigger user turn stopped immediately."""
self.analyzer._analyze_state = EndOfTurnState.COMPLETE
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert self.turn_stopped_called
async def test_vad_stop_incomplete_does_not_trigger(self):
"""VAD stop with INCOMPLETE should not trigger user turn stopped."""
self.analyzer._analyze_state = EndOfTurnState.INCOMPLETE
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert not self.turn_stopped_called
async def test_streaming_complete_via_append_audio_triggers(self):
"""Streaming COMPLETE from append_audio should trigger immediately."""
self.analyzer._append_audio_state = EndOfTurnState.COMPLETE
# analyze_end_of_turn is called after append_audio returns COMPLETE
self.analyzer._analyze_state = EndOfTurnState.COMPLETE
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
await self.strategy.process_frame(
InputAudioRawFrame(audio=b"\x00" * 320, sample_rate=16000, num_channels=1)
)
assert self.turn_stopped_called
async def test_streaming_incomplete_does_not_trigger(self):
"""Streaming INCOMPLETE from append_audio should not trigger."""
self.analyzer._append_audio_state = EndOfTurnState.INCOMPLETE
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_vad_start_resets_speaking_state(self):
"""VAD start should set speaking state to True."""
assert not self.strategy._vad_user_speaking
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
assert self.strategy._vad_user_speaking
async def test_vad_stop_resets_speaking_state(self):
"""VAD stop should set speaking state to False."""
self.analyzer._analyze_state = EndOfTurnState.INCOMPLETE
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
assert self.strategy._vad_user_speaking
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert not self.strategy._vad_user_speaking
async def test_reset_clears_state(self):
"""Reset should clear speaking state."""
self.strategy._vad_user_speaking = True
await self.strategy.reset()
assert not self.strategy._vad_user_speaking
async def test_metrics_frame_pushed_on_prediction(self):
"""MetricsFrame should be pushed when prediction result is available."""
prediction = MetricsData(processor="test_analyzer")
self.analyzer._analyze_state = EndOfTurnState.COMPLETE
self.analyzer._prediction = prediction
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert len(self.pushed_frames) == 1
assert isinstance(self.pushed_frames[0], MetricsFrame)
assert self.pushed_frames[0].data == [prediction]
async def test_no_metrics_frame_when_no_prediction(self):
"""No MetricsFrame should be pushed when prediction is None."""
self.analyzer._analyze_state = EndOfTurnState.COMPLETE
self.analyzer._prediction = None
await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
await self.strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.3))
assert len(self.pushed_frames) == 0
async def test_speech_control_params_broadcast_on_start(self):
"""SpeechControlParamsFrame should be broadcast on StartFrame."""
await self.strategy.process_frame(
StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
)
assert len(self.broadcast_frames) == 1
frame_cls, kwargs = self.broadcast_frames[0]
assert frame_cls is SpeechControlParamsFrame
async def test_transcription_frames_ignored(self):
"""TranscriptionFrame should not affect state or trigger turn stop."""
self.analyzer._analyze_state = EndOfTurnState.INCOMPLETE
await self.strategy.process_frame(
TranscriptionFrame(text="hello", user_id="user1", timestamp="now")
)
assert not self.turn_stopped_called
async def test_process_frame_returns_continue(self):
"""process_frame should always return CONTINUE."""
from pipecat.turns.types import ProcessFrameResult
result = await self.strategy.process_frame(VADUserStartedSpeakingFrame(start_secs=0.2))
assert result == ProcessFrameResult.CONTINUE
if __name__ == "__main__":
unittest.main()