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/.
214 lines
7.7 KiB
Python
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()
|