Merge branch 'main' into fix/heartbeat-monitor-configurable

This commit is contained in:
OmercohenAviv
2026-03-28 11:57:23 +03:00
874 changed files with 100918 additions and 26768 deletions

View File

@@ -0,0 +1 @@

192
tests/genesys/conftest.py Normal file
View File

@@ -0,0 +1,192 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Pytest fixtures for Genesys AudioHook serializer tests.
These fixtures provide sample AudioHook protocol messages for testing
the GenesysAudioHookSerializer. They are scoped to this directory only.
"""
import pytest
@pytest.fixture
def sample_open_message():
"""Sample AudioHook open message from Genesys."""
return {
"version": "2",
"type": "open",
"seq": 1,
"id": "test-session-123",
"parameters": {
"conversationId": "conv-456",
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
},
"media": [
{
"type": "audio",
"format": "PCMU",
"channels": ["external"],
"rate": 8000,
}
],
},
}
@pytest.fixture
def sample_open_message_with_input_variables():
"""Sample AudioHook open message with custom inputVariables from Genesys."""
return {
"version": "2",
"type": "open",
"seq": 1,
"id": "test-session-123",
"parameters": {
"conversationId": "conv-456",
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
},
"media": [
{
"type": "audio",
"format": "PCMU",
"channels": ["external"],
"rate": 8000,
}
],
"inputVariables": {
"customer_id": "cust-789",
"queue_name": "billing",
"priority": "high",
"language": "es-ES",
},
},
}
@pytest.fixture
def sample_ping_message():
"""Sample AudioHook ping message."""
return {
"version": "2",
"type": "ping",
"seq": 5,
"id": "test-session-123",
"position": "PT10.5S",
}
@pytest.fixture
def sample_close_message():
"""Sample AudioHook close message from Genesys."""
return {
"version": "2",
"type": "close",
"seq": 10,
"id": "test-session-123",
"position": "PT30.0S",
"parameters": {
"reason": "disconnect",
},
}
@pytest.fixture
def sample_pause_message():
"""Sample AudioHook pause message."""
return {
"version": "2",
"type": "pause",
"seq": 7,
"id": "test-session-123",
"position": "PT15.0S",
"parameters": {
"reason": "hold",
},
}
@pytest.fixture
def sample_update_message():
"""Sample AudioHook update message."""
return {
"version": "2",
"type": "update",
"seq": 8,
"id": "test-session-123",
"position": "PT20.0S",
"parameters": {
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
"name": "John Doe",
},
},
}
@pytest.fixture
def sample_error_message():
"""Sample AudioHook error message."""
return {
"version": "2",
"type": "error",
"seq": 9,
"id": "test-session-123",
"parameters": {
"code": 500,
"message": "Internal server error",
},
}
@pytest.fixture
def sample_dtmf_message():
"""Sample AudioHook DTMF message."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "5",
},
}
@pytest.fixture
def sample_dtmf_star_message():
"""Sample AudioHook DTMF message with star key."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "*",
},
}
@pytest.fixture
def sample_dtmf_hash_message():
"""Sample AudioHook DTMF message with hash key."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "#",
},
}

View File

@@ -0,0 +1,376 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for the Genesys AudioHook serializer."""
import json
import pytest
from pipecat.frames.frames import InputDTMFFrame, OutputTransportMessageUrgentFrame
from pipecat.serializers.genesys import AudioHookChannel, GenesysAudioHookSerializer
class TestGenesysAudioHookSerializer:
"""Tests for GenesysAudioHookSerializer."""
# ==================== Initialization Tests ====================
def test_create_serializer_default_params(self):
"""Test creating serializer with default parameters."""
serializer = GenesysAudioHookSerializer()
# session_id is auto-generated as UUID
assert serializer.session_id != ""
assert len(serializer.session_id) == 36 # UUID format
assert serializer.is_open is False
assert serializer.is_paused is False
def test_create_serializer_with_custom_params(self):
"""Test creating serializer with custom parameters."""
params = GenesysAudioHookSerializer.InputParams(
channel=AudioHookChannel.BOTH,
sample_rate=16000,
supported_languages=["es-ES", "en-US"],
selected_language="es-ES",
start_paused=True,
)
serializer = GenesysAudioHookSerializer(params=params)
assert serializer.session_id != ""
# ==================== Response Creation Tests ====================
def test_create_opened_response(self):
"""Test creating an opened response message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_opened_response()
assert msg["type"] == "opened"
assert msg["version"] == "2"
assert msg["id"] == serializer.session_id
assert "parameters" in msg
assert serializer.is_open is True
def test_create_opened_response_with_languages(self):
"""Test creating an opened response with language options."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_opened_response(
supported_languages=["es", "en", "fr"],
selected_language="es",
)
assert msg["parameters"]["supportedLanguages"] == ["es", "en", "fr"]
assert msg["parameters"]["selectedLanguage"] == "es"
def test_create_pong_response(self):
"""Test creating a pong response message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_pong_response()
assert msg["type"] == "pong"
assert msg["id"] == serializer.session_id
assert msg["parameters"] == {}
def test_create_closed_response(self):
"""Test creating a closed response message."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
msg = serializer.create_closed_response()
assert msg["type"] == "closed"
assert serializer.is_open is False
assert msg["parameters"] == {} # Empty parameters when no output_variables
def test_create_closed_response_with_output_variables(self):
"""Test creating a closed response with custom output variables."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
msg = serializer.create_closed_response(
output_variables={
"intent": "billing_inquiry",
"customer_verified": True,
"summary": "Customer asked about their bill",
}
)
assert msg["type"] == "closed"
assert msg["parameters"]["outputVariables"]["intent"] == "billing_inquiry"
assert msg["parameters"]["outputVariables"]["customer_verified"] is True
assert msg["parameters"]["outputVariables"]["summary"] == "Customer asked about their bill"
def test_create_resumed_response(self):
"""Test creating a resumed response message."""
serializer = GenesysAudioHookSerializer()
serializer._is_paused = True
msg = serializer.create_resumed_response()
assert msg["type"] == "resumed"
assert serializer.is_paused is False
def test_create_disconnect_message(self):
"""Test creating a disconnect message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_disconnect_message(
reason="completed",
action="transfer",
)
assert msg["type"] == "disconnect"
assert msg["parameters"]["reason"] == "completed"
assert msg["parameters"]["outputVariables"]["action"] == "transfer"
def test_create_disconnect_message_with_output_variables(self):
"""Test creating a disconnect message with custom output variables."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_disconnect_message(
reason="completed",
action="finished",
output_variables={"result": "success", "code": "123"},
)
assert msg["parameters"]["outputVariables"]["result"] == "success"
assert msg["parameters"]["outputVariables"]["code"] == "123"
def test_create_error_message(self):
"""Test creating an error message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_error_message(
code=500,
message="Internal error",
retryable=True,
)
assert msg["type"] == "error"
assert msg["parameters"]["code"] == 500
assert msg["parameters"]["message"] == "Internal error"
assert msg["parameters"]["retryable"] is True
# ==================== Message Handling Tests ====================
@pytest.mark.asyncio
async def test_handle_open_message(self, sample_open_message):
"""Test handling an open message returns opened frame."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_open_message))
# Now returns OutputTransportMessageUrgentFrame with opened response
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "opened"
assert serializer.session_id == "test-session-123"
assert serializer.conversation_id == "conv-456"
@pytest.mark.asyncio
async def test_handle_open_message_extracts_participant(self, sample_open_message):
"""Test that open message extracts participant info."""
serializer = GenesysAudioHookSerializer()
await serializer.deserialize(json.dumps(sample_open_message))
assert serializer.participant is not None
assert serializer.participant["ani"] == "+1234567890"
assert serializer.participant["dnis"] == "+0987654321"
@pytest.mark.asyncio
async def test_handle_open_message_uses_params(self, sample_open_message):
"""Test that open message uses InputParams for response."""
params = GenesysAudioHookSerializer.InputParams(
supported_languages=["es-ES", "en-US"],
selected_language="es-ES",
start_paused=True,
)
serializer = GenesysAudioHookSerializer(params=params)
result = await serializer.deserialize(json.dumps(sample_open_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["parameters"]["supportedLanguages"] == ["es-ES", "en-US"]
assert result.message["parameters"]["selectedLanguage"] == "es-ES"
assert result.message["parameters"]["startPaused"] is True
@pytest.mark.asyncio
async def test_handle_open_message_extracts_input_variables(
self, sample_open_message_with_input_variables
):
"""Test that open message extracts inputVariables from Genesys."""
serializer = GenesysAudioHookSerializer()
await serializer.deserialize(json.dumps(sample_open_message_with_input_variables))
assert serializer.input_variables is not None
assert serializer.input_variables["customer_id"] == "cust-789"
assert serializer.input_variables["queue_name"] == "billing"
assert serializer.input_variables["priority"] == "high"
assert serializer.input_variables["language"] == "es-ES"
@pytest.mark.asyncio
async def test_handle_ping_message(self, sample_ping_message):
"""Test handling a ping message returns pong frame."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_ping_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "pong"
@pytest.mark.asyncio
async def test_handle_close_message(self, sample_close_message):
"""Test handling a close message returns closed frame."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
result = await serializer.deserialize(json.dumps(sample_close_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "closed"
assert serializer.is_open is False
@pytest.mark.asyncio
async def test_handle_close_message_includes_output_variables(self, sample_close_message):
"""Test that close response includes output variables when set."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
# Set output variables before close
serializer.set_output_variables(
{"intent": "support", "resolved": True, "transfer_to": "agent_queue"}
)
result = await serializer.deserialize(json.dumps(sample_close_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "closed"
assert result.message["parameters"]["outputVariables"]["intent"] == "support"
assert result.message["parameters"]["outputVariables"]["resolved"] is True
assert result.message["parameters"]["outputVariables"]["transfer_to"] == "agent_queue"
# ==================== Output Variables Tests ====================
def test_set_output_variables(self):
"""Test setting output variables."""
serializer = GenesysAudioHookSerializer()
assert serializer.output_variables is None
serializer.set_output_variables({"intent": "billing", "score": 0.95})
assert serializer.output_variables is not None
assert serializer.output_variables["intent"] == "billing"
assert serializer.output_variables["score"] == 0.95
def test_set_output_variables_overwrites(self):
"""Test that setting output variables overwrites previous values."""
serializer = GenesysAudioHookSerializer()
serializer.set_output_variables({"first": "value"})
serializer.set_output_variables({"second": "value"})
assert "first" not in serializer.output_variables
assert serializer.output_variables["second"] == "value"
@pytest.mark.asyncio
async def test_handle_pause_message(self, sample_pause_message):
"""Test handling a pause message."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
result = await serializer.deserialize(json.dumps(sample_pause_message))
assert result is None # Pause is handled internally
assert serializer.is_paused is True
@pytest.mark.asyncio
async def test_handle_update_message(self, sample_update_message):
"""Test handling an update message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_update_message))
assert result is None # Update is handled internally
assert serializer.participant is not None
assert serializer.participant["name"] == "John Doe"
@pytest.mark.asyncio
async def test_handle_error_message(self, sample_error_message):
"""Test handling an error message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_error_message))
assert result is None # Error is logged but returns None
# ==================== DTMF Tests ====================
@pytest.mark.asyncio
async def test_handle_dtmf_digit(self, sample_dtmf_message):
"""Test handling a DTMF digit message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "5"
@pytest.mark.asyncio
async def test_handle_dtmf_star(self, sample_dtmf_star_message):
"""Test handling a DTMF star (*) message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_star_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "*"
@pytest.mark.asyncio
async def test_handle_dtmf_hash(self, sample_dtmf_hash_message):
"""Test handling a DTMF hash (#) message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_hash_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "#"
@pytest.mark.asyncio
async def test_handle_dtmf_empty_digit(self):
"""Test handling a DTMF message without digit."""
serializer = GenesysAudioHookSerializer()
dtmf_msg = {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"parameters": {},
}
result = await serializer.deserialize(json.dumps(dtmf_msg))
assert result is None # No digit provided
# ==================== Sequence Number Tests ====================
def test_sequence_numbers_increment(self):
"""Test that sequence numbers increment correctly."""
serializer = GenesysAudioHookSerializer()
response1 = serializer.create_pong_response()
response2 = serializer.create_pong_response()
response3 = serializer.create_pong_response()
assert response1["seq"] == 1
assert response2["seq"] == 2
assert response3["seq"] == 3

View File

@@ -1,11 +1,10 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import os
from unittest.mock import AsyncMock
import pytest
from dotenv import load_dotenv
@@ -89,7 +88,7 @@ async def test_unified_function_calling_openai():
@pytest.mark.skipif(os.getenv("GOOGLE_API_KEY") is None, reason="GOOGLE_API_KEY is not set")
@pytest.mark.asyncio
async def test_unified_function_calling_gemini():
llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash-001")
llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"))
# This will fail if an exception is raised
await _test_llm_function_calling(llm)
@@ -97,8 +96,6 @@ async def test_unified_function_calling_gemini():
@pytest.mark.skipif(os.getenv("ANTHROPIC_API_KEY") is None, reason="ANTHROPIC_API_KEY is not set")
@pytest.mark.asyncio
async def test_unified_function_calling_anthropic():
llm = AnthropicLLMService(
api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-5-sonnet-20240620"
)
llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY"))
# This will fail if an exception is raised
await _test_llm_function_calling(llm)

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -74,3 +74,7 @@ class TestGatedAggregator(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
if __name__ == "__main__":
unittest.main()

831
tests/test_aic_filter.py Normal file
View File

@@ -0,0 +1,831 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import time
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
# Check if aic_sdk is available
aic_sdk: Any
try:
import aic_sdk
HAS_AIC_SDK = True
except ImportError:
aic_sdk = None
HAS_AIC_SDK = False
# Module path for patching
AIC_FILTER_MODULE = "pipecat.audio.filters.aic_filter"
def _model_manager_ref_count(manager, key: str) -> int:
"""Test helper: return reference count for a cache key (reads internal cache)."""
with manager._lock:
entry = manager._cache.get(key)
return entry[1] if entry else 0
class MockProcessor:
"""A lightweight mock for AIC ProcessorAsync that mimics real behavior."""
def __init__(self):
self.processor_ctx = MockProcessorContext()
self.vad_ctx = MockVadContext()
def get_processor_context(self):
return self.processor_ctx
def get_vad_context(self):
return self.vad_ctx
async def process_async(self, audio_array):
# Return a copy of the input (simulating passthrough)
return audio_array.copy()
class MockProcessorContext:
"""A lightweight mock for AIC ProcessorContext."""
def __init__(self):
self.parameters_set: list[tuple] = []
self.reset_called = False
self._output_delay = 0
def get_output_delay(self):
return self._output_delay
def set_parameter(self, param, value):
self.parameters_set.append((param, value))
def reset(self):
self.reset_called = True
class UnsupportedEnhancementProcessorContext(MockProcessorContext):
"""Processor context mock that rejects EnhancementLevel updates."""
def __init__(self, enhancement_parameter, error_type):
super().__init__()
self._enhancement_parameter = enhancement_parameter
self._error_type = error_type
self.enhancement_attempts = 0
def set_parameter(self, param, value):
if param == self._enhancement_parameter:
self.enhancement_attempts += 1
raise self._error_type("EnhancementLevel out of range")
super().set_parameter(param, value)
class MockVadContext:
"""A lightweight mock for AIC VadContext."""
def __init__(self, speech_detected: bool = False):
self.speech_detected = speech_detected
self.parameters_set: list[tuple] = []
def is_speech_detected(self) -> bool:
return self.speech_detected
def set_parameter(self, param, value):
self.parameters_set.append((param, value))
class MockModel:
"""A lightweight mock for AIC Model."""
def __init__(self, model_id: str = "test-model"):
self._model_id = model_id
self._optimal_num_frames = 160
self._optimal_sample_rate = 16000
def get_optimal_num_frames(self, sample_rate: int):
"""Return optimal number of frames for the given sample rate."""
return self._optimal_num_frames
def get_id(self):
return self._model_id
def get_optimal_sample_rate(self):
return self._optimal_sample_rate
@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed")
class TestAICFilter(unittest.IsolatedAsyncioTestCase):
"""Test suite for AICFilter audio filter using real aic_sdk types."""
@classmethod
def setUpClass(cls):
"""Import AICFilter after confirming aic_sdk is available."""
from pipecat.audio.filters.aic_filter import AICFilter, AICModelManager
from pipecat.frames.frames import FilterEnableFrame
cls.AICFilter = AICFilter
cls.AICModelManager = AICModelManager
cls.FilterEnableFrame = FilterEnableFrame
def setUp(self):
"""Set up test fixtures before each test method."""
self.mock_model = MockModel()
self.mock_processor = MockProcessor()
def _create_filter_with_mocks(self, **kwargs):
"""Create an AICFilter with mocked SDK components."""
filter_kwargs = {
"license_key": "test-key",
"model_id": "test-model",
}
filter_kwargs.update(kwargs)
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
return self.AICFilter(**filter_kwargs)
async def _start_filter_with_mocks(self, filter_instance, sample_rate=16000):
"""Start a filter with mocked SDK components."""
cache_key = "test-cache-key"
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, cache_key))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(sample_rate)
async def test_initialization_requires_model_id_or_path(self):
"""Test filter initialization fails without model_id or model_path."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
with self.assertRaises(ValueError) as context:
self.AICFilter(license_key="test-key")
self.assertIn("model_id", str(context.exception))
self.assertIn("model_path", str(context.exception))
async def test_initialization_with_model_id(self):
"""Test filter initialization with model_id."""
filter_instance = self._create_filter_with_mocks()
self.assertEqual(filter_instance._license_key, "test-key")
self.assertEqual(filter_instance._model_id, "test-model")
self.assertIsNone(filter_instance._model_path)
self.assertIsNone(filter_instance._enhancement_level)
self.assertFalse(filter_instance._bypass)
async def test_initialization_with_model_path(self):
"""Test filter initialization with model_path."""
model_path = Path("/tmp/test.aicmodel")
filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path)
self.assertEqual(filter_instance._model_path, model_path)
self.assertIsNone(filter_instance._model_id)
async def test_initialization_with_custom_download_dir(self):
"""Test filter initialization with custom model_download_dir."""
download_dir = Path("/custom/cache")
filter_instance = self._create_filter_with_mocks(model_download_dir=download_dir)
self.assertEqual(filter_instance._model_download_dir, download_dir)
async def test_initialization_with_valid_enhancement_level(self):
"""Test filter initialization with a valid enhancement_level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.75)
self.assertEqual(filter_instance._enhancement_level, 0.75)
async def test_initialization_with_none_enhancement_level(self):
"""Test filter initialization with enhancement_level set to None."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
self.assertIsNone(filter_instance._enhancement_level)
async def test_initialization_invalid_enhancement_level_raises(self):
"""Test initialization rejects enhancement_level outside 0.0..1.0."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
with self.assertRaises(ValueError) as context:
self.AICFilter(
license_key="test-key",
model_id="test-model",
enhancement_level=1.5,
)
self.assertIn("enhancement_level", str(context.exception))
async def test_start_with_model_path(self):
"""Test starting filter with a local model path."""
model_path = Path("/tmp/test.aicmodel")
filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path)
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(
return_value=(self.mock_model, "path:/tmp/test.aicmodel")
)
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_manager_cls.acquire.assert_called_once()
call_kw = mock_manager_cls.acquire.call_args[1]
self.assertEqual(call_kw["model_path"], model_path)
self.assertIsNone(call_kw["model_id"])
self.assertTrue(filter_instance._aic_ready)
self.assertEqual(filter_instance._sample_rate, 16000)
self.assertEqual(filter_instance._frames_per_block, 160)
async def test_start_with_model_id_downloads(self):
"""Test starting filter with model_id uses manager (download happens in manager)."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(
return_value=(self.mock_model, "id:test-model:/custom/cache")
)
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_manager_cls.acquire.assert_called_once()
call_kw = mock_manager_cls.acquire.call_args[1]
self.assertEqual(call_kw["model_id"], "test-model")
self.assertTrue(filter_instance._aic_ready)
async def test_start_creates_processor(self):
"""Test that start creates processor with correct config."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(
f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor
) as mock_processor_cls,
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-cache-key"))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_config_cls.optimal.assert_called_once()
mock_processor_cls.assert_called_once()
self.assertIsNotNone(filter_instance._processor_ctx)
self.assertIsNotNone(filter_instance._vad_ctx)
async def test_start_applies_initial_bypass_parameter(self):
"""Test that start applies bypass parameter."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertTrue(len(bypass_params) > 0)
self.assertEqual(bypass_params[-1][1], 0.0)
async def test_start_applies_enhancement_level_when_supported(self):
"""Test that start applies enhancement_level when supported by model."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertTrue(len(enhancement_params) > 0)
self.assertEqual(enhancement_params[-1][1], 0.65)
async def test_start_ignores_enhancement_level_when_unsupported(self):
"""Test unsupported enhancement_level logs warning and keeps filter ready."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
unsupported_ctx = UnsupportedEnhancementProcessorContext(
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
)
self.mock_processor.processor_ctx = unsupported_ctx
await self._start_filter_with_mocks(filter_instance)
self.assertTrue(filter_instance._aic_ready)
self.assertIsNone(filter_instance._enhancement_level)
self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_start_does_not_set_enhancement_level_when_none(self):
"""Test start does not attempt enhancement_level when not configured."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
with patch(f"{AIC_FILTER_MODULE}.logger.debug") as mock_debug:
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertEqual(enhancement_params, [])
self.assertTrue(
any("default behavior" in str(call.args[0]) for call in mock_debug.call_args_list)
)
async def test_stop_cleans_up_resources(self):
"""Test that stop properly cleans up resources and releases model reference."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
cache_key = filter_instance._model_cache_key
with patch(f"{AIC_FILTER_MODULE}.AICModelManager.release") as mock_release:
await filter_instance.stop()
mock_release.assert_called_once_with(cache_key)
self.assertTrue(self.mock_processor.processor_ctx.reset_called)
self.assertIsNone(filter_instance._processor)
self.assertIsNone(filter_instance._processor_ctx)
self.assertIsNone(filter_instance._vad_ctx)
self.assertIsNone(filter_instance._model)
self.assertIsNone(filter_instance._model_cache_key)
self.assertFalse(filter_instance._aic_ready)
async def test_stop_without_start(self):
"""Test that stop can be called safely without start."""
filter_instance = self._create_filter_with_mocks()
# Should not raise
await filter_instance.stop()
async def test_model_manager_reference_count(self):
"""Test that AICModelManager reference count increments and decrements correctly."""
model_path = Path("/tmp/refcount-test.aicmodel")
mock_model = MockModel()
manager = self.AICModelManager
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.from_file.return_value = mock_model
# Acquire first reference
model1, key = await manager.acquire(model_path=model_path)
self.assertEqual(model1, mock_model)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
# Acquire second reference (same key, cached)
model2, key2 = await manager.acquire(model_path=model_path)
self.assertIs(model2, model1)
self.assertEqual(key2, key)
self.assertEqual(_model_manager_ref_count(manager, key), 2)
# Release one reference
manager.release(key)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
# Release last reference (model evicted from cache)
manager.release(key)
self.assertEqual(_model_manager_ref_count(manager, key), 0)
async def test_model_manager_concurrent_load_deduplication(self):
"""Test that concurrent acquire calls for the same key share a single load task."""
model_path = Path("/tmp/concurrent-load-test.aicmodel")
mock_model = MockModel()
manager = self.AICModelManager
load_count = 0
def from_file_once(path):
nonlocal load_count
load_count += 1
time.sleep(0.02) # yield so other acquire callers can hit _loading and await same task
return mock_model
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.from_file.side_effect = from_file_once
# Start several acquire calls concurrently before any completes
results = await asyncio.gather(
manager.acquire(model_path=model_path),
manager.acquire(model_path=model_path),
manager.acquire(model_path=model_path),
)
self.assertEqual(
load_count, 1, "Model.from_file should be called once for concurrent callers"
)
model1, key1 = results[0]
model2, key2 = results[1]
model3, key3 = results[2]
self.assertIs(model1, mock_model)
self.assertIs(model2, mock_model)
self.assertIs(model3, mock_model)
self.assertEqual(key1, key2)
self.assertEqual(key2, key3)
self.assertEqual(_model_manager_ref_count(manager, key1), 3)
# Release all references
manager.release(key1)
manager.release(key1)
manager.release(key1)
self.assertEqual(_model_manager_ref_count(manager, key1), 0)
async def test_load_model_from_file_invalid_args_raises(self):
"""Test _load_model_from_file defensive else: raises ValueError."""
manager = self.AICModelManager
with self.assertRaises(ValueError) as ctx:
await manager._load_model_from_file(
"key",
model_path=None,
model_id=None,
model_download_dir=None,
)
self.assertIn("Unexpected", str(ctx.exception))
async def test_model_manager_acquire_by_model_id_hits_download_path(self):
"""Test acquire with model_id runs download path in _load_model_from_file."""
model_id = "test-model-id"
model_download_dir = Path("/tmp/aic-downloads")
mock_model = MockModel()
manager = self.AICModelManager
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.download_async = AsyncMock(
return_value="/tmp/aic-downloads/model.aicmodel"
)
mock_model_cls.from_file.return_value = mock_model
model, key = await manager.acquire(
model_id=model_id,
model_download_dir=model_download_dir,
)
mock_model_cls.download_async.assert_called_once()
mock_model_cls.from_file.assert_called_once_with("/tmp/aic-downloads/model.aicmodel")
self.assertIs(model, mock_model)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
manager.release(key)
def test_get_cache_key_invalid_raises(self):
"""Test _get_cache_key raises ValueError for invalid args."""
with self.assertRaises(ValueError) as ctx:
self.AICModelManager._get_cache_key(model_path=None, model_id=None)
self.assertIn("model_path", str(ctx.exception))
with self.assertRaises(ValueError) as ctx2:
self.AICModelManager._get_cache_key(
model_path=None,
model_id="x",
model_download_dir=None,
)
self.assertIn("model_download_dir", str(ctx2.exception))
async def test_start_processor_init_failure(self):
"""Test start() when ProcessorAsync raises: exception logged, _aic_ready False."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(
f"{AIC_FILTER_MODULE}.ProcessorAsync",
side_effect=RuntimeError("SDK init failed"),
),
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key"))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
self.assertIsNone(filter_instance._processor)
self.assertFalse(filter_instance._aic_ready)
async def test_start_skips_unsupported_enhancement_level_after_first_attempt(self):
"""Test unsupported enhancement_level is attempted once and then skipped."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.9)
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
unsupported_ctx = UnsupportedEnhancementProcessorContext(
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
)
self.mock_processor.processor_ctx = unsupported_ctx
await self._start_filter_with_mocks(filter_instance)
await filter_instance.stop()
await self._start_filter_with_mocks(filter_instance)
self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_process_frame_disable_sets_bypass(self):
"""Test disable frame toggles bypass."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance)
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertTrue(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 1.0)
async def test_process_frame_enable_restores_configured_enhancement(self):
"""Test enable frame restores configured enhancement level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance)
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
await filter_instance.process_frame(self.FilterEnableFrame(enable=True))
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertFalse(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 0.0)
async def test_filter_when_not_ready(self):
"""Test that filter returns audio unchanged when not ready."""
filter_instance = self._create_filter_with_mocks()
# Don't call start()
input_audio = b"\x00\x01\x02\x03"
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(output_audio, input_audio)
async def test_filter_with_incomplete_frame(self):
"""Test filtering audio with incomplete frame data."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for less than one frame (100 samples = 200 bytes)
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Should return empty bytes since no complete frame
self.assertEqual(output_audio, b"")
async def test_filter_with_complete_frame(self):
"""Test filtering audio with exactly one complete frame."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for exactly one frame (160 samples = 320 bytes)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertIsInstance(output_audio, bytes)
self.assertEqual(len(output_audio), len(input_audio))
async def test_filter_with_multiple_frames(self):
"""Test filtering audio with multiple complete frames."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for 3 complete frames (480 samples = 960 bytes)
samples = np.random.randint(-32768, 32767, size=480, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(len(output_audio), len(input_audio))
async def test_filter_with_buffering(self):
"""Test that filter properly buffers incomplete frames."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# First call: Send 100 samples (incomplete frame)
samples1 = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio1 = samples1.tobytes()
output_audio1 = await filter_instance.filter(input_audio1)
self.assertEqual(output_audio1, b"")
self.assertEqual(len(filter_instance._audio_buffer), 200)
# Second call: Send 60 more samples (now we have 160 total = 1 complete frame)
samples2 = np.random.randint(-32768, 32767, size=60, dtype=np.int16)
input_audio2 = samples2.tobytes()
output_audio2 = await filter_instance.filter(input_audio2)
self.assertEqual(len(output_audio2), 320)
self.assertEqual(len(filter_instance._audio_buffer), 0)
async def test_filter_with_partial_buffering(self):
"""Test that filter keeps remainder in buffer after processing."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Send 250 samples (1 complete frame + 90 samples remainder)
samples = np.random.randint(-32768, 32767, size=250, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(len(output_audio), 320) # 1 frame
self.assertEqual(len(filter_instance._audio_buffer), 180) # 90 samples * 2 bytes
async def test_get_vad_context_before_start(self):
"""Test that get_vad_context raises before start."""
filter_instance = self._create_filter_with_mocks()
with self.assertRaises(RuntimeError) as context:
filter_instance.get_vad_context()
self.assertIn("not initialized", str(context.exception))
async def test_get_vad_context_after_start(self):
"""Test that get_vad_context returns context after start."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
vad_ctx = filter_instance.get_vad_context()
self.assertEqual(vad_ctx, self.mock_processor.vad_ctx)
async def test_create_vad_analyzer(self):
"""Test create_vad_analyzer returns analyzer with factory."""
filter_instance = self._create_filter_with_mocks()
analyzer = filter_instance.create_vad_analyzer()
self.assertIsNotNone(analyzer)
# Factory should be set
self.assertIsNotNone(analyzer._vad_context_factory)
async def test_create_vad_analyzer_with_params(self):
"""Test create_vad_analyzer with custom parameters."""
filter_instance = self._create_filter_with_mocks()
analyzer = filter_instance.create_vad_analyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
self.assertEqual(analyzer._pending_speech_hold_duration, 0.1)
self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05)
self.assertEqual(analyzer._pending_sensitivity, 8.0)
async def test_multiple_start_stop_cycles(self):
"""Test multiple start/stop cycles."""
filter_instance = self._create_filter_with_mocks()
for sample_rate in [16000, 24000, 48000]:
# Create fresh mock processor for each cycle
self.mock_processor = MockProcessor()
await self._start_filter_with_mocks(filter_instance, sample_rate)
self.assertTrue(filter_instance._aic_ready)
self.assertEqual(filter_instance._sample_rate, sample_rate)
await filter_instance.stop()
self.assertFalse(filter_instance._aic_ready)
async def test_concurrent_filter_calls(self):
"""Test that concurrent filter calls are handled safely."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def filter_audio():
return await filter_instance.filter(input_audio)
tasks = [filter_audio() for _ in range(10)]
results = await asyncio.gather(*tasks)
self.assertEqual(len(results), 10)
for result in results:
self.assertIsInstance(result, bytes)
async def test_concurrent_filter_no_buffer_resize_error(self):
"""Regression: concurrent filter() must not raise BufferError.
When process_async yields to the event loop, a second filter() call
runs and calls _audio_buffer.extend(). If the first call still holds
a memoryview on the bytearray, extend() raises:
BufferError: Existing exports of data: object cannot be re-sized
The fix snapshots the needed data into immutable bytes and trims the
buffer *before* any await, so no memoryview is held across yield
points.
"""
filter_instance = self._create_filter_with_mocks()
# Make process_async yield to the event loop so concurrent filter()
# calls can interleave and attempt _audio_buffer.extend().
async def yielding_process_async(audio_array):
await asyncio.sleep(0)
return audio_array.copy()
self.mock_processor.process_async = yielding_process_async
await self._start_filter_with_mocks(filter_instance)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def filter_audio():
return await filter_instance.filter(input_audio)
# 20 concurrent calls to reliably trigger the interleaving.
tasks = [filter_audio() for _ in range(20)]
results = await asyncio.gather(*tasks)
for result in results:
self.assertIsInstance(result, bytes)
async def test_stop_during_filter_no_buffer_resize_error(self):
"""Regression: stop() during filter() must not raise BufferError.
When filter() holds a memoryview on _audio_buffer across an await
(process_async), a concurrent stop() that calls
_audio_buffer.clear() raises:
BufferError: Existing exports of data: object cannot be re-sized
The fix removes the memoryview by snapshotting data into immutable
bytes before any await.
"""
filter_instance = self._create_filter_with_mocks()
# Gate so stop() waits until filter() is inside process_async.
processing_started = asyncio.Event()
async def yielding_process_async(audio_array):
processing_started.set()
await asyncio.sleep(0) # yield — stop() runs here
return audio_array.copy()
self.mock_processor.process_async = yielding_process_async
await self._start_filter_with_mocks(filter_instance)
# Exactly one complete frame so the loop runs once.
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def stop_after_filter_enters_process_async():
await processing_started.wait()
await filter_instance.stop()
# filter() enters process_async → yields → stop() calls clear()
filter_result, _ = await asyncio.gather(
filter_instance.filter(input_audio),
stop_after_filter_enters_process_async(),
)
self.assertIsInstance(filter_result, bytes)
async def test_buffer_cleared_on_stop(self):
"""Test that audio buffer is cleared when stopping."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Add incomplete frame to buffer
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
await filter_instance.filter(input_audio)
# Verify buffer has data
self.assertGreater(len(filter_instance._audio_buffer), 0)
# Stop should clear buffer
await filter_instance.stop()
self.assertEqual(len(filter_instance._audio_buffer), 0)
async def test_set_sdk_id_called_on_init(self):
"""Test that set_sdk_id is called during initialization."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id") as mock_set_sdk_id:
self.AICFilter(license_key="test-key", model_id="test-model")
mock_set_sdk_id.assert_called_once_with(6)
if __name__ == "__main__":
unittest.main()

322
tests/test_aic_vad.py Normal file
View File

@@ -0,0 +1,322 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
# Check if aic_sdk is available
try:
import aic_sdk
HAS_AIC_SDK = True
except ImportError:
HAS_AIC_SDK = False
@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed")
class TestAICVADAnalyzer(unittest.IsolatedAsyncioTestCase):
"""Test suite for AICVADAnalyzer using real aic_sdk."""
@classmethod
def setUpClass(cls):
"""Import AICVADAnalyzer after confirming aic_sdk is available."""
from pipecat.audio.vad.aic_vad import AICVADAnalyzer
cls.AICVADAnalyzer = AICVADAnalyzer
def test_initialization_without_factory(self):
"""Test analyzer initialization without a factory."""
analyzer = self.AICVADAnalyzer()
self.assertIsNone(analyzer._vad_context_factory)
self.assertIsNone(analyzer._vad_ctx)
# Fixed params should be set
self.assertEqual(analyzer._params.confidence, 0.5)
self.assertEqual(analyzer._params.start_secs, 0.0)
self.assertEqual(analyzer._params.stop_secs, 0.0)
self.assertEqual(analyzer._params.min_volume, 0.0)
def test_initialization_with_factory(self):
"""Test analyzer initialization with a factory."""
# Create a mock VAD context for testing
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
self.assertIsNotNone(analyzer._vad_context_factory)
def test_initialization_with_vad_params(self):
"""Test analyzer initialization with VAD parameters."""
analyzer = self.AICVADAnalyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
self.assertEqual(analyzer._pending_speech_hold_duration, 0.1)
self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05)
self.assertEqual(analyzer._pending_sensitivity, 8.0)
def test_bind_vad_context_factory(self):
"""Test binding a factory post-construction."""
mock_vad_ctx = MockVadContext()
analyzer = self.AICVADAnalyzer()
factory = lambda: mock_vad_ctx
analyzer.bind_vad_context_factory(factory)
self.assertEqual(analyzer._vad_context_factory, factory)
# Should have attempted to initialize
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_bind_vad_context_factory_applies_params(self):
"""Test that binding factory applies pending VAD params."""
mock_vad_ctx = MockVadContext()
analyzer = self.AICVADAnalyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
factory = lambda: mock_vad_ctx
analyzer.bind_vad_context_factory(factory)
# Verify parameters were applied
self.assertIn(
(aic_sdk.VadParameter.SpeechHoldDuration, 0.1),
mock_vad_ctx.parameters_set,
)
self.assertIn(
(aic_sdk.VadParameter.MinimumSpeechDuration, 0.05),
mock_vad_ctx.parameters_set,
)
self.assertIn(
(aic_sdk.VadParameter.Sensitivity, 8.0),
mock_vad_ctx.parameters_set,
)
def test_set_sample_rate(self):
"""Test setting sample rate."""
analyzer = self.AICVADAnalyzer()
analyzer.set_sample_rate(16000)
self.assertEqual(analyzer._sample_rate, 16000)
def test_set_sample_rate_with_init_sample_rate(self):
"""Test that init_sample_rate takes precedence."""
# Create analyzer and manually set _init_sample_rate
analyzer = self.AICVADAnalyzer()
analyzer._init_sample_rate = 48000
analyzer.set_sample_rate(16000)
# init_sample_rate should take precedence
self.assertEqual(analyzer._sample_rate, 48000)
def test_set_sample_rate_triggers_context_init(self):
"""Test that set_sample_rate attempts context initialization."""
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_num_frames_required_with_sample_rate(self):
"""Test num_frames_required returns correct value."""
analyzer = self.AICVADAnalyzer()
analyzer.set_sample_rate(16000)
frames = analyzer.num_frames_required()
# 10ms at 16kHz = 160 frames
self.assertEqual(frames, 160)
def test_num_frames_required_different_sample_rates(self):
"""Test num_frames_required for different sample rates."""
analyzer = self.AICVADAnalyzer()
test_cases = [
(8000, 80), # 10ms at 8kHz
(16000, 160), # 10ms at 16kHz
(24000, 240), # 10ms at 24kHz
(48000, 480), # 10ms at 48kHz
]
for sample_rate, expected_frames in test_cases:
analyzer.set_sample_rate(sample_rate)
frames = analyzer.num_frames_required()
self.assertEqual(frames, expected_frames, f"Failed for {sample_rate}Hz")
def test_num_frames_required_no_sample_rate(self):
"""Test num_frames_required returns default when no sample rate."""
analyzer = self.AICVADAnalyzer()
frames = analyzer.num_frames_required()
# Default is 160
self.assertEqual(frames, 160)
def test_voice_confidence_no_context(self):
"""Test voice_confidence returns 0.0 when no context."""
analyzer = self.AICVADAnalyzer()
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_voice_confidence_speech_detected(self):
"""Test voice_confidence returns 1.0 when speech detected."""
mock_vad_ctx = MockVadContext(speech_detected=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 1.0)
def test_voice_confidence_no_speech(self):
"""Test voice_confidence returns 0.0 when no speech."""
mock_vad_ctx = MockVadContext(speech_detected=False)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_voice_confidence_handles_exception(self):
"""Test voice_confidence handles exceptions gracefully."""
mock_vad_ctx = MockVadContext(raise_on_detect=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_lazy_initialization(self):
"""Test that VAD context is lazily initialized."""
call_count = 0
mock_vad_ctx = MockVadContext()
def counting_factory():
nonlocal call_count
call_count += 1
return mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=counting_factory)
# Factory not called yet
self.assertEqual(call_count, 0)
# First call to voice_confidence triggers initialization
analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(call_count, 1)
# Subsequent calls don't re-initialize
analyzer.voice_confidence(b"\x00" * 320)
analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(call_count, 1)
def test_deferred_initialization_on_factory_failure(self):
"""Test that initialization is deferred when factory fails."""
call_count = 0
mock_vad_ctx = MockVadContext(speech_detected=True)
def failing_then_succeeding_factory():
nonlocal call_count
call_count += 1
if call_count < 3:
raise RuntimeError("Not ready yet")
return mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=failing_then_succeeding_factory)
# First two calls fail, should return 0.0
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0)
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0)
# Third call succeeds
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 1.0)
def test_apply_vad_params_deferred_on_failure(self):
"""Test that VAD param application handles exceptions."""
mock_vad_ctx = MockVadContext(raise_on_set_param=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(
vad_context_factory=factory,
speech_hold_duration=0.1,
)
# Should not raise, just log debug message
analyzer.bind_vad_context_factory(factory)
# Context should still be set despite param failure
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_apply_vad_params_only_set_values(self):
"""Test that only specified VAD params are applied."""
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(
vad_context_factory=factory,
speech_hold_duration=0.1,
# minimum_speech_duration and sensitivity not set
)
analyzer.bind_vad_context_factory(factory)
# Only SpeechHoldDuration should be set
self.assertEqual(len(mock_vad_ctx.parameters_set), 1)
self.assertIn(
(aic_sdk.VadParameter.SpeechHoldDuration, 0.1),
mock_vad_ctx.parameters_set,
)
def test_fixed_vad_params(self):
"""Test that VAD uses fixed parameters."""
analyzer = self.AICVADAnalyzer()
# These are the fixed params for AIC VAD
self.assertEqual(analyzer._params.confidence, 0.5)
self.assertEqual(analyzer._params.start_secs, 0.0)
self.assertEqual(analyzer._params.stop_secs, 0.0)
self.assertEqual(analyzer._params.min_volume, 0.0)
class MockVadContext:
"""A lightweight mock for AIC VadContext that mimics real behavior."""
def __init__(
self,
speech_detected: bool = False,
raise_on_detect: bool = False,
raise_on_set_param: bool = False,
):
self.speech_detected = speech_detected
self.raise_on_detect = raise_on_detect
self.raise_on_set_param = raise_on_set_param
self.parameters_set: list[tuple] = []
def is_speech_detected(self) -> bool:
if self.raise_on_detect:
raise RuntimeError("VAD error")
return self.speech_detected
def set_parameter(self, param, value):
if self.raise_on_set_param:
raise RuntimeError("Param error")
self.parameters_set.append((param, value))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -8,8 +8,19 @@ import asyncio
import struct
import unittest
from pipecat.frames.frames import InputAudioRawFrame, OutputAudioRawFrame, StartFrame
from pipecat.clocks.system_clock import SystemClock
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InputAudioRawFrame,
OutputAudioRawFrame,
StartFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor
from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
class _PassthroughResampler:
@@ -19,13 +30,48 @@ class _PassthroughResampler:
return audio
async def _make_processor(*, buffer_size: int = 0) -> AudioBufferProcessor:
"""Create and start a processor ready to record.
Calls setup() and sends a StartFrame through the public process_frame path so that
the processor is fully initialised (task manager set, sample rate configured,
__started flag set) without needing a full pipeline.
"""
processor = AudioBufferProcessor(sample_rate=16000, num_channels=2, buffer_size=buffer_size)
processor._input_resampler = _PassthroughResampler()
processor._output_resampler = _PassthroughResampler()
loop = asyncio.get_event_loop()
task_manager = TaskManager()
task_manager.setup(TaskManagerParams(loop=loop))
await processor.setup(FrameProcessorSetup(clock=SystemClock(), task_manager=task_manager))
await processor.process_frame(
StartFrame(audio_out_sample_rate=16000), FrameDirection.DOWNSTREAM
)
await processor.start_recording()
return processor
async def _capture_track_audio(processor: AudioBufferProcessor) -> tuple[bytes, bytes]:
"""Flush the processor and return (user_track, bot_track) from on_track_audio_data."""
captured = {}
event = asyncio.Event()
async def on_track_audio_data(_, user, bot, sample_rate, num_channels):
captured["user"] = user
captured["bot"] = bot
event.set()
processor.add_event_handler("on_track_audio_data", on_track_audio_data)
await processor.stop_recording()
await asyncio.wait_for(event.wait(), timeout=1)
return captured["user"], captured["bot"]
class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.processor = AudioBufferProcessor(sample_rate=16000, num_channels=2, buffer_size=4)
self.processor._input_resampler = _PassthroughResampler()
self.processor._output_resampler = _PassthroughResampler()
self.processor._update_sample_rate(StartFrame(audio_out_sample_rate=16000))
await self.processor.start_recording()
self.processor = await _make_processor(buffer_size=4)
async def asyncTearDown(self):
if getattr(self.processor, "_recording", False):
@@ -52,7 +98,7 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase):
self.processor.add_event_handler("on_track_audio_data", on_track_audio_data)
frame = InputAudioRawFrame(audio=user_audio, sample_rate=16000, num_channels=1)
await self.processor._process_recording(frame)
await self.processor.process_frame(frame, FrameDirection.DOWNSTREAM)
await asyncio.wait_for(audio_event.wait(), timeout=1)
await asyncio.wait_for(track_event.wait(), timeout=1)
@@ -94,7 +140,7 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase):
self.processor.add_event_handler("on_track_audio_data", on_track_audio_data)
frame = OutputAudioRawFrame(audio=bot_audio, sample_rate=16000, num_channels=1)
await self.processor._process_recording(frame)
await self.processor.process_frame(frame, FrameDirection.DOWNSTREAM)
await asyncio.wait_for(audio_event.wait(), timeout=1)
await asyncio.wait_for(track_event.wait(), timeout=1)
@@ -115,3 +161,168 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase):
self.assertEqual(merged_audio[6:8], bot_audio[2:4])
self.assertEqual(len(self.processor._user_audio_buffer), 0)
self.assertEqual(len(self.processor._bot_audio_buffer), 0)
class TestSilenceInjectionGuards(unittest.IsolatedAsyncioTestCase):
"""Tests that silence is not injected mid-utterance (fix for crackling artifacts).
Each test verifies the audio alignment in the flushed tracks to confirm that
silence is only added by _align_track_buffers at flush time (end of the buffer),
never injected mid-stream while the affected track is actively producing audio.
"""
async def test_no_silence_injected_into_bot_buffer_while_bot_speaking(self):
"""Bot audio must appear at the start of the bot track, not after mid-stream silence.
Timeline:
1. User sends 4 bytes (bot not speaking → normal sync, no-op since bot is at 0)
2. Bot starts speaking
3. User sends 4 more bytes (bot speaking → sync skipped; bot stays at 0)
4. Bot sends 4 bytes of known audio
Expected final bot track (8 bytes total after _align_track_buffers at flush):
[bot_audio][silence_padding] ← audio first, silence only at the end
With the bug the bot track would be:
[silence_injected_mid_stream][bot_audio] ← silence inserted before the audio
"""
p = await _make_processor()
bot_audio = b"\xaa\xbb\xcc\xdd"
await p.process_frame(
InputAudioRawFrame(audio=b"\x01\x02\x03\x04", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(BotStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
InputAudioRawFrame(audio=b"\x05\x06\x07\x08", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(
OutputAudioRawFrame(audio=bot_audio, sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
_, bot_track = await _capture_track_audio(p)
await p.cleanup()
# Audio must appear at the beginning of the bot track (not after injected silence).
self.assertEqual(bot_track[:4], bot_audio)
self.assertEqual(bot_track[4:], b"\x00" * 4)
async def test_no_silence_injected_into_user_buffer_while_user_speaking(self):
"""User audio must appear at the start of the user track, not after mid-stream silence.
Timeline:
1. Bot sends 4 bytes (user not speaking → normal sync, no-op since user is at 0)
2. User starts speaking
3. Bot sends 4 more bytes (user speaking → sync skipped; user stays at 0)
4. User sends 4 bytes of known audio
Expected final user track (8 bytes total after _align_track_buffers at flush):
[user_audio][silence_padding] ← audio first, silence only at the end
With the bug the user track would be:
[silence_injected_mid_stream][user_audio]
"""
p = await _make_processor()
user_audio = b"\xaa\xbb\xcc\xdd"
await p.process_frame(
OutputAudioRawFrame(audio=b"\x01\x02\x03\x04", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(UserStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
OutputAudioRawFrame(audio=b"\x05\x06\x07\x08", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(
InputAudioRawFrame(audio=user_audio, sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
user_track, _ = await _capture_track_audio(p)
await p.cleanup()
self.assertEqual(user_track[:4], user_audio)
self.assertEqual(user_track[4:], b"\x00" * 4)
async def test_silence_resumes_into_bot_buffer_after_bot_stops_speaking(self):
"""After bot stops speaking, the bot buffer is synced again on user audio arrival.
Timeline:
1. User sends 4 bytes (user=4, bot=0)
2. Bot starts speaking
3. User sends 4 more bytes (sync skipped; user=8, bot=0)
4. Bot stops speaking
5. User sends 4 more bytes (sync resumes; bot gets 8 bytes silence, user=12)
Expected final bot track (12 bytes): 8 bytes silence then no more audio (bot never
sent audio, _align_track_buffers pads bot to 12).
The key assertion: bot has 8 bytes of silence at positions 0-7, confirming that
the sync at step 5 did inject 8 bytes (positions 0-7 of the bot buffer).
"""
p = await _make_processor()
await p.process_frame(
InputAudioRawFrame(audio=b"\x01\x02\x03\x04", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(BotStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
InputAudioRawFrame(audio=b"\x05\x06\x07\x08", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(BotStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
InputAudioRawFrame(audio=b"\x09\x0a\x0b\x0c", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
_, bot_track = await _capture_track_audio(p)
await p.cleanup()
# The sync at step 5 targets len(user)=8, so bot must have 8 bytes of silence
# written before user's third chunk was added.
self.assertEqual(bot_track[:8], b"\x00" * 8)
async def test_silence_resumes_into_user_buffer_after_user_stops_speaking(self):
"""After user stops speaking, the user buffer is synced again on bot audio arrival.
Timeline:
1. Bot sends 4 bytes (user=0, bot=4)
2. User starts speaking
3. Bot sends 4 more bytes (sync skipped; user=0, bot=8)
4. User stops speaking
5. Bot sends 4 more bytes (sync resumes; user gets 8 bytes silence, bot=12)
Expected: user track has 8 bytes of silence at positions 0-7.
"""
p = await _make_processor()
await p.process_frame(
OutputAudioRawFrame(audio=b"\x01\x02\x03\x04", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(UserStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
OutputAudioRawFrame(audio=b"\x05\x06\x07\x08", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
await p.process_frame(UserStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM)
await p.process_frame(
OutputAudioRawFrame(audio=b"\x09\x0a\x0b\x0c", sample_rate=16000, num_channels=1),
FrameDirection.DOWNSTREAM,
)
user_track, _ = await _capture_track_audio(p)
await p.cleanup()
self.assertEqual(user_track[:8], b"\x00" * 8)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
FunctionCallResultProperties,
InterimTranscriptionFrame,
InterruptionFrame,
InterruptionTaskFrame,
LLMContextAssistantTimestampFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
@@ -567,7 +566,7 @@ class BaseTestUserContextAggregator:
SleepFrame(),
UserStoppedSpeakingFrame(),
]
expected_up_frames = [InterruptionTaskFrame]
expected_up_frames = [InterruptionFrame]
expected_down_frames = [
BotStartedSpeakingFrame,
UserStartedSpeakingFrame,
@@ -1055,3 +1054,7 @@ class TestLLMAssistantAggregator(
0,
"Hello Pipecat. Here's some code: ```python\nprint('Hello, World!')\n``` ```javascript\nconsole.log('Hello, World!');\n``` And some more: ```html\n<div>Hello, World!</div>\n``` Hope that helps!",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,735 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallFromLLM,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InterimTranscriptionFrame,
InterruptionFrame,
LLMContextAssistantTimestampFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
LLMMessagesUpdateFrame,
LLMRunFrame,
LLMTextFrame,
LLMThoughtEndFrame,
LLMThoughtStartFrame,
LLMThoughtTextFrame,
StartFrame,
TranscriptionFrame,
TranslationFrame,
UserMuteStartedFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
AssistantThoughtMessage,
AssistantTurnStoppedMessage,
LLMAssistantAggregator,
LLMUserAggregator,
LLMUserAggregatorParams,
)
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_mute import (
FirstSpeechUserMuteStrategy,
FunctionCallUserMuteStrategy,
MuteUntilFirstBotCompleteUserMuteStrategy,
)
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
USER_TURN_STOP_TIMEOUT = 0.2
TRANSCRIPTION_TIMEOUT = 0.1
class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
async def test_llm_run(self):
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [LLMRunFrame()]
expected_down_frames = [LLMContextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_llm_messages_append(self):
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
LLMMessagesAppendFrame(
messages=[
{
"role": "user",
"content": "Hi there!",
}
]
)
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
assert context.messages[0]["content"] == "Hi there!"
async def test_llm_messages_append_run(self):
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
LLMMessagesAppendFrame(
messages=[
{
"role": "user",
"content": "Hi there!",
}
],
run_llm=True,
)
]
expected_down_frames = [LLMContextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
assert context.messages[0]["content"] == "Hi there!"
async def test_llm_messages_update(self):
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
LLMMessagesUpdateFrame(
messages=[
{
"role": "user",
"content": "Hi there!",
}
]
)
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
assert context.messages[0]["content"] == "Hi there!"
async def test_llm_messages_update_run(self):
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
LLMMessagesUpdateFrame(
messages=[
{
"role": "user",
"content": "Hi there!",
}
],
run_llm=True,
)
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
assert context.messages[0]["content"] == "Hi there!"
async def test_llm_messages_update_does_not_inject_turn_completion_into_context(self):
context = LLMContext()
params = LLMUserAggregatorParams(filter_incomplete_user_turns=True)
pipeline = Pipeline([LLMUserAggregator(context, params=params)])
new_messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
]
frames_to_send = [LLMMessagesUpdateFrame(messages=new_messages)]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
# Turn completion instructions are now set via system_instruction on the
# LLM service, not injected into context messages.
assert len(context.messages) == 2
assert context.messages[0]["content"] == "You are a helpful assistant."
assert context.messages[1]["content"] == "Hello!"
async def test_default_user_turn_strategies(self):
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
],
),
),
)
should_start = None
should_stop = None
stop_message = None
@user_aggregator.event_handler("on_user_turn_started")
async def on_user_turn_started(aggregator, strategy):
nonlocal should_start
should_start = True
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
nonlocal should_stop, stop_message
should_stop = True
stop_message = message
pipeline = Pipeline([user_aggregator])
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
]
expected_down_frames = [
VADUserStartedSpeakingFrame,
UserStartedSpeakingFrame,
InterruptionFrame,
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
LLMContextFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertEqual(stop_message.content, "Hello!")
async def test_user_turn_stop_timeout_no_transcription(self):
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT),
)
should_start = None
should_stop = None
timeout = None
@user_aggregator.event_handler("on_user_turn_started")
async def on_user_turn_started(aggregator, strategy):
nonlocal should_start
should_start = True
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
nonlocal should_stop
should_stop = True
@user_aggregator.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(aggregator):
nonlocal timeout
timeout = True
pipeline = Pipeline([user_aggregator])
frames_to_send = [
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT + 0.1),
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_user_turn_stop_timeout_transcription(self):
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
),
)
should_start = None
should_stop = None
stop_message = None
timeout = None
@user_aggregator.event_handler("on_user_turn_started")
async def on_user_turn_started(aggregator, strategy):
nonlocal should_start
should_start = True
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
nonlocal should_stop, stop_message
should_stop = True
stop_message = message
@user_aggregator.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(aggregator):
nonlocal timeout
timeout = True
pipeline = Pipeline([user_aggregator])
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
# The transcription strategy should kick-in before the user turn end timeout.
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertEqual(stop_message.content, "Hello!")
self.assertFalse(timeout)
async def test_user_mute_strategies(self):
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_mute_strategies=[
FirstSpeechUserMuteStrategy(),
FunctionCallUserMuteStrategy(),
]
),
)
user_turn = False
@user_aggregator.event_handler("on_user_turn_started")
async def on_user_turn_started(aggregator, strategy):
nonlocal user_turn
user_turn = True
pipeline = Pipeline([user_aggregator])
frames_to_send = [
# Bot is speaking, user should be muted.
BotStartedSpeakingFrame(),
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
BotStoppedSpeakingFrame(),
# Function call is executing, user should be muted.
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name="fn_1", tool_call_id="1", arguments={}, context=None
)
]
),
SleepFrame(),
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
FunctionCallResultFrame(
function_name="fn_1", tool_call_id="1", arguments={}, result={}
),
SleepFrame(),
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
# The user mute strategies should have muted the user.
self.assertFalse(user_turn)
async def test_pending_transcription_emitted_on_end_frame(self):
"""Pending user transcription should be emitted when EndFrame arrives."""
context = LLMContext()
user_aggregator = LLMUserAggregator(context)
stop_messages = []
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
stop_messages.append((strategy, message))
pipeline = Pipeline([user_aggregator])
# Start turn and send transcription, but don't trigger normal turn stop
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# No VADUserStoppedSpeakingFrame - turn doesn't stop normally
# EndFrame will be sent by run_test, triggering emission
]
await run_test(pipeline, frames_to_send=frames_to_send)
# The pending transcription should be emitted on EndFrame
self.assertEqual(len(stop_messages), 1)
strategy, message = stop_messages[0]
self.assertIsNone(strategy) # strategy is None for end/cancel
self.assertEqual(message.content, "Hello!")
async def test_start_frame_before_mute_event(self):
"""StartFrame must reach downstream before mute events are broadcast.
With MuteUntilFirstBotCompleteUserMuteStrategy, the mute logic should
not run on control frames (StartFrame, EndFrame, CancelFrame). This
ensures StartFrame reaches downstream processors before
UserMuteStartedFrame is broadcast.
The default TurnAnalyzerUserTurnStopStrategy broadcasts a
SpeechControlParamsFrame when it processes StartFrame, which gets
re-queued to the aggregator. That non-control frame legitimately
triggers the mute state change, so UserMuteStartedFrame follows
StartFrame — but crucially, after it.
"""
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_mute_strategies=[MuteUntilFirstBotCompleteUserMuteStrategy()],
),
)
pipeline = Pipeline([user_aggregator])
# run_test internally sends StartFrame via PipelineRunner. With
# ignore_start=False we can verify ordering: StartFrame must arrive
# before UserMuteStartedFrame. Before the fix, UserMuteStartedFrame
# was broadcast before StartFrame reached downstream processors.
(down_frames, _) = await run_test(
pipeline,
frames_to_send=[],
expected_down_frames=[StartFrame, UserMuteStartedFrame],
ignore_start=False,
)
async def test_interim_transcription_not_pushed_downstream(self):
"""InterimTranscriptionFrame should be consumed and not pushed downstream."""
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
InterimTranscriptionFrame(text="Hel", user_id="", timestamp="now"),
InterimTranscriptionFrame(text="Hello", user_id="", timestamp="now"),
]
# The interim transcription triggers a user turn start via the default
# TranscriptionUserTurnStartStrategy, so we expect turn-related frames
# but NOT the InterimTranscriptionFrame itself.
expected_down_frames = [
UserStartedSpeakingFrame,
InterruptionFrame,
]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertFalse(any(isinstance(f, InterimTranscriptionFrame) for f in down_frames))
async def test_translation_not_pushed_downstream(self):
"""TranslationFrame should be consumed and not pushed downstream."""
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
TranslationFrame(text="Hola!", user_id="", timestamp="now", language="es"),
]
# No downstream frames expected — translations are consumed.
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=[],
)
class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase):
async def test_empty(self):
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
should_start = None
should_stop = None
stop_message = None
@aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(aggregator):
nonlocal should_start
should_start = True
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
nonlocal should_stop, stop_message
should_stop = True
stop_message = message
frames_to_send = [LLMFullResponseStartFrame(), LLMFullResponseEndFrame()]
await run_test(aggregator, frames_to_send=frames_to_send)
self.assertTrue(should_start)
self.assertIsNone(should_stop)
self.assertIsNone(stop_message)
async def test_simple(self):
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
should_start = None
should_stop = None
stop_message = None
@aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(aggregator):
nonlocal should_start
should_start = True
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
nonlocal should_stop, stop_message
should_stop = True
stop_message = message
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame("Hello from Pipecat!"),
LLMFullResponseEndFrame(),
]
expected_down_frames = [LLMContextFrame, LLMContextAssistantTimestampFrame]
await run_test(
aggregator,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertEqual(stop_message.content, "Hello from Pipecat!")
async def test_multiple(self):
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
should_start = None
should_stop = None
stop_message = None
@aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(aggregator):
nonlocal should_start
should_start = True
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
nonlocal should_stop, stop_message
should_stop = True
stop_message = message
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame("Hello "),
LLMTextFrame("from "),
LLMTextFrame("Pipecat!"),
LLMFullResponseEndFrame(),
]
expected_down_frames = [LLMContextFrame, LLMContextAssistantTimestampFrame]
await run_test(
aggregator,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertEqual(stop_message.content, "Hello from Pipecat!")
async def test_interruption(self):
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
should_start = 0
should_stop = 0
stop_messages = []
@aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(aggregator):
nonlocal should_start
should_start += 1
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
nonlocal should_stop, stop_messages
should_stop += 1
stop_messages.append(message)
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame("Hello "),
SleepFrame(),
InterruptionFrame(),
LLMFullResponseStartFrame(),
LLMTextFrame("Hello "),
LLMTextFrame("there!"),
LLMFullResponseEndFrame(),
]
expected_down_frames = [
LLMContextFrame,
LLMContextAssistantTimestampFrame,
InterruptionFrame,
LLMContextFrame,
LLMContextAssistantTimestampFrame,
]
await run_test(
aggregator,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertEqual(should_start, 2)
self.assertEqual(should_stop, 2)
self.assertEqual(stop_messages[0].content, "Hello")
self.assertEqual(stop_messages[1].content, "Hello there!")
async def test_thought(self):
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
thought_message = None
@aggregator.event_handler("on_assistant_thought")
async def on_assistant_thought(aggregator, message: AssistantThoughtMessage):
nonlocal thought_message
thought_message = message
frames_to_send = [
LLMFullResponseStartFrame(),
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="I'm thinking!"),
LLMThoughtEndFrame(),
LLMFullResponseEndFrame(),
]
await run_test(aggregator, frames_to_send=frames_to_send)
self.assertEqual(thought_message.content, "I'm thinking!")
async def test_pending_text_emitted_on_end_frame(self):
"""Pending assistant text should be emitted when EndFrame arrives."""
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
stop_messages = []
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
# Start response and send text, but don't send LLMFullResponseEndFrame
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame("Hello from Pipecat!"),
# No LLMFullResponseEndFrame - response doesn't end normally
# EndFrame will be sent by run_test, triggering emission
]
await run_test(aggregator, frames_to_send=frames_to_send)
# The pending text should be emitted on EndFrame
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
async def test_turn_completion_markers_stripped_from_transcript(self):
"""Turn completion markers should be stripped from assistant transcript."""
from pipecat.turns.user_turn_completion_mixin import (
USER_TURN_COMPLETE_MARKER,
USER_TURN_INCOMPLETE_SHORT_MARKER,
)
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
stop_messages = []
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
# Send text with a turn completion marker
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame(f"{USER_TURN_COMPLETE_MARKER} Hello from Pipecat!"),
LLMFullResponseEndFrame(),
]
await run_test(aggregator, frames_to_send=frames_to_send)
# The marker should be stripped from the transcript
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
# Test incomplete markers are also stripped
stop_messages.clear()
context2 = LLMContext()
aggregator2 = LLMAssistantAggregator(context2)
@aggregator2.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped2(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame(USER_TURN_INCOMPLETE_SHORT_MARKER),
LLMFullResponseEndFrame(),
]
await run_test(aggregator2, frames_to_send=frames_to_send)
# The incomplete marker should be stripped (resulting in empty content)
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "")
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -90,3 +90,7 @@ class TestDailyTransport(unittest.IsolatedAsyncioTestCase):
camera.write_frame.assert_called_with(b"test")
mic.write_frames.assert_called()
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import io
from unittest.mock import AsyncMock, MagicMock
import pytest
from loguru import logger
from pipecat.services.deepgram.stt import DeepgramSTTService, _derive_deepgram_urls
@pytest.mark.parametrize(
"base_url, expected_ws, expected_http",
[
# Secure schemes
("wss://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
("https://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
# Insecure schemes (air-gapped deployments)
("ws://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"),
("http://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"),
# Bare hostname defaults to secure
("mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
# With port
("ws://localhost:8080", "ws://localhost:8080", "http://localhost:8080"),
("wss://localhost:443", "wss://localhost:443", "https://localhost:443"),
("localhost:8080", "wss://localhost:8080", "https://localhost:8080"),
# With path
("wss://host/v1/listen", "wss://host/v1/listen", "https://host/v1/listen"),
("http://host/v1/listen", "ws://host/v1/listen", "http://host/v1/listen"),
],
)
def test_derive_deepgram_urls(base_url, expected_ws, expected_http):
ws_url, http_url = _derive_deepgram_urls(base_url)
assert ws_url == expected_ws
assert http_url == expected_http
def test_derive_deepgram_urls_unknown_scheme_warns():
sink = io.StringIO()
handler_id = logger.add(sink, format="{message}")
try:
ws_url, http_url = _derive_deepgram_urls("ftp://mydeepgram.com")
# Falls back to secure
assert ws_url == "wss://mydeepgram.com"
assert http_url == "https://mydeepgram.com"
assert "Unrecognized scheme" in sink.getvalue()
finally:
logger.remove(handler_id)
@pytest.mark.asyncio
async def test_run_stt_send_media_exception_clears_connection():
"""send_media() failure should log a warning and clear self._connection."""
service = DeepgramSTTService.__new__(DeepgramSTTService)
service._name = "DeepgramSTTService"
mock_connection = MagicMock()
mock_connection.send_media = AsyncMock(side_effect=Exception("websocket closed"))
service._connection = mock_connection
sink = io.StringIO()
handler_id = logger.add(sink, format="{message}")
try:
async for _ in service.run_stt(b"\x00" * 160):
pass
assert service._connection is None
assert "send_media failed" in sink.getvalue()
finally:
logger.remove(handler_id)
@pytest.mark.asyncio
async def test_run_stt_skips_send_when_connection_is_none():
"""When self._connection is None, run_stt should silently skip."""
service = DeepgramSTTService.__new__(DeepgramSTTService)
service._connection = None
# Should not raise
async for _ in service.run_stt(b"\x00" * 160):
pass
assert service._connection is None

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from typing import Optional, TypedDict, Union
@@ -5,11 +11,6 @@ from typing import Optional, TypedDict, Union
from pipecat.adapters.schemas.direct_function import DirectFunctionWrapper
from pipecat.services.llm_service import FunctionCallParams
# Copyright (c) 2025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
class TestDirectFunction(unittest.TestCase):
def test_name_is_set_from_function(self):

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -240,3 +240,7 @@ class TestDTMFAggregator(unittest.IsolatedAsyncioTestCase):
]
self.assertEqual(len(transcription_frames), 1)
self.assertEqual(transcription_frames[0].text, "DTMF: 0123456789*#")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,206 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from unittest.mock import AsyncMock, PropertyMock
from starlette.websockets import WebSocketState
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketCallbacks,
FastAPIWebsocketClient,
_WebSocketMessageIterator,
)
class TestWebSocketMessageIterator(unittest.IsolatedAsyncioTestCase):
async def test_yields_binary_message(self):
mock_websocket = AsyncMock()
mock_websocket.receive.side_effect = [
{"type": "websocket.receive", "bytes": b"binary data", "text": None},
{"type": "websocket.disconnect"},
]
iterator = _WebSocketMessageIterator(mock_websocket)
messages = [msg async for msg in iterator]
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0], b"binary data")
async def test_yields_text_message(self):
mock_websocket = AsyncMock()
mock_websocket.receive.side_effect = [
{"type": "websocket.receive", "bytes": None, "text": "text data"},
{"type": "websocket.disconnect"},
]
iterator = _WebSocketMessageIterator(mock_websocket)
messages = [msg async for msg in iterator]
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0], "text data")
async def test_yields_mixed_messages(self):
mock_websocket = AsyncMock()
mock_websocket.receive.side_effect = [
{"type": "websocket.receive", "bytes": b"binary", "text": None},
{"type": "websocket.receive", "bytes": None, "text": "text"},
{"type": "websocket.receive", "bytes": b"more binary", "text": None},
{"type": "websocket.disconnect"},
]
iterator = _WebSocketMessageIterator(mock_websocket)
messages = [msg async for msg in iterator]
self.assertEqual(len(messages), 3)
self.assertEqual(messages[0], b"binary")
self.assertEqual(messages[1], "text")
self.assertEqual(messages[2], b"more binary")
async def test_stops_on_disconnect(self):
mock_websocket = AsyncMock()
mock_websocket.receive.side_effect = [
{"type": "websocket.disconnect"},
]
iterator = _WebSocketMessageIterator(mock_websocket)
messages = [msg async for msg in iterator]
self.assertEqual(len(messages), 0)
class TestSendDisconnectRace(unittest.IsolatedAsyncioTestCase):
"""Tests for the race condition in issue #3912.
When the remote side disconnects while send() is in flight, send() should
not set _closing = True, because that flag means "we initiated the close."
Setting it from send() prevents the receive loop from firing
on_client_disconnected, which can cause the pipeline to hang.
"""
def _make_client(self, mock_ws):
callbacks = FastAPIWebsocketCallbacks(
on_client_connected=AsyncMock(),
on_client_disconnected=AsyncMock(),
on_session_timeout=AsyncMock(),
)
client = FastAPIWebsocketClient(mock_ws, callbacks)
return client, callbacks
async def test_send_disconnect_does_not_set_closing(self):
"""send() should not set _closing when the remote side disconnects."""
mock_ws = AsyncMock()
type(mock_ws).client_state = PropertyMock(return_value=WebSocketState.CONNECTED)
type(mock_ws).application_state = PropertyMock(return_value=WebSocketState.DISCONNECTED)
mock_ws.send_bytes.side_effect = Exception("connection closed")
client, _ = self._make_client(mock_ws)
await client.send(b"audio data")
self.assertFalse(client.is_closing)
async def test_send_suppressed_after_disconnect(self):
"""After a failed send, _can_send() returns False via application_state.
Simulates real Starlette behavior: application_state starts CONNECTED,
transitions to DISCONNECTED when send_bytes raises (Starlette does this
internally on OSError before re-raising as WebSocketDisconnect).
"""
mock_ws = AsyncMock()
type(mock_ws).client_state = PropertyMock(return_value=WebSocketState.CONNECTED)
# application_state transitions from CONNECTED → DISCONNECTED on send failure
app_state = {"state": WebSocketState.CONNECTED}
type(mock_ws).application_state = PropertyMock(side_effect=lambda: app_state["state"])
def fail_and_transition(data):
app_state["state"] = WebSocketState.DISCONNECTED
raise Exception("connection closed")
mock_ws.send_bytes.side_effect = fail_and_transition
client, _ = self._make_client(mock_ws)
# First send: _can_send() passes (app_state CONNECTED), send_bytes raises,
# Starlette sets app_state to DISCONNECTED
await client.send(b"audio data")
# Second send: _can_send() returns False (app_state now DISCONNECTED)
await client.send(b"more audio")
# send_bytes was only called once (the first attempt)
mock_ws.send_bytes.assert_called_once()
async def test_disconnect_callback_fires_when_send_races_receive(self):
"""Regression test for issue #3912.
The receive loop is blocked waiting for the next message. Meanwhile,
send() is called and hits an exception because the remote side closed.
Then the receive loop unblocks and sees the disconnect.
on_client_disconnected must still fire, because the remote side
initiated the close — not us.
"""
send_done = asyncio.Event()
mock_ws = AsyncMock()
type(mock_ws).client_state = PropertyMock(return_value=WebSocketState.CONNECTED)
type(mock_ws).application_state = PropertyMock(return_value=WebSocketState.DISCONNECTED)
mock_ws.send_bytes.side_effect = Exception("connection closed")
# receive() blocks until send has completed, then returns disconnect.
# This enforces the exact ordering that causes the bug.
async def mock_receive():
await send_done.wait()
return {"type": "websocket.disconnect"}
mock_ws.receive = mock_receive
client, callbacks = self._make_client(mock_ws)
# Simulate the _receive_messages logic from FastAPIWebsocketInputTransport
async def receive_loop():
try:
async for _ in _WebSocketMessageIterator(mock_ws):
pass
except Exception:
pass
if not client.is_closing:
await client.trigger_client_disconnected()
recv_task = asyncio.create_task(receive_loop())
# Let the receive loop start and block on receive()
await asyncio.sleep(0)
# send() races — hits exception but does NOT set _closing
await client.send(b"audio data")
self.assertFalse(client.is_closing)
# Unblock the receive loop — it sees the disconnect
send_done.set()
await recv_task
# The callback fires because _closing was not poisoned by send()
callbacks.on_client_disconnected.assert_called_once()
async def test_send_text_disconnect_does_not_set_closing(self):
"""Same as test_send_disconnect_does_not_set_closing but with text data."""
mock_ws = AsyncMock()
type(mock_ws).client_state = PropertyMock(return_value=WebSocketState.CONNECTED)
type(mock_ws).application_state = PropertyMock(return_value=WebSocketState.DISCONNECTED)
mock_ws.send_text.side_effect = Exception("connection closed")
client, _ = self._make_client(mock_ws)
await client.send("text data")
self.assertFalse(client.is_closing)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -14,10 +14,12 @@ from pipecat.frames.frames import (
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.filters.frame_filter import FrameFilter
from pipecat.processors.filters.function_filter import FunctionFilter
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@@ -93,6 +95,98 @@ class TestFunctionFilter(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
async def test_no_direction_filters_both_directions(self):
"""When direction is None, frames in both directions are filtered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=None: filter applies in both directions. The downstream
# TextFrame is blocked and the upstream TextFrame pushed by
# UpstreamPusher is also blocked.
filter = FunctionFilter(filter=block_text, direction=None)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [
TextFrame(text="Hello!"),
UserStartedSpeakingFrame(),
]
expected_down_frames = [UserStartedSpeakingFrame]
expected_up_frames = []
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
async def test_downstream_direction_passes_upstream(self):
"""When direction is DOWNSTREAM, upstream frames pass through unfiltered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=DOWNSTREAM: filter only applies downstream, so the
# upstream TextFrame pushed by UpstreamPusher passes through.
filter = FunctionFilter(filter=block_text)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [UserStartedSpeakingFrame()]
expected_down_frames = [UserStartedSpeakingFrame]
expected_up_frames = [TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
async def test_upstream_direction_passes_downstream(self):
"""When direction is UPSTREAM, downstream frames pass through unfiltered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=UPSTREAM: filter only applies upstream, so the
# downstream TextFrame passes through but the upstream TextFrame
# pushed by UpstreamPusher is blocked.
filter = FunctionFilter(filter=block_text, direction=FrameDirection.UPSTREAM)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [TextFrame(text="Hello!"), UserStartedSpeakingFrame()]
expected_down_frames = [UserStartedSpeakingFrame, TextFrame]
expected_up_frames = []
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
async def test_no_wake_word(self):
@@ -118,3 +212,7 @@ class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
assert received_down[-1].text == "Phrase 1"
if __name__ == "__main__":
unittest.main()

View File

@@ -1,25 +1,43 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from dataclasses import dataclass, field
from typing import List
from pipecat.frames.frames import (
DataFrame,
EndFrame,
Frame,
InterruptionFrame,
OutputTransportMessageUrgentFrame,
StopFrame,
SystemFrame,
TextFrame,
UninterruptibleFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.processors.frame_processor import (
FrameDirection,
FrameProcessor,
)
from pipecat.tests.utils import SleepFrame, run_test
@dataclass
class BroadcastTestFrame(DataFrame):
"""Test frame with init fields for broadcast testing."""
text: str = ""
value: int = 0
items: List[str] = field(default_factory=list)
class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
async def test_before_after_events(self):
identity = IdentityFilter()
@@ -65,44 +83,351 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
assert before_push_called
assert after_push_called
async def test_interruption_and_wait(self):
class DelayFrameProcessor(FrameProcessor):
"""This processors just gives time to the event loop to change
between tasks. Otherwise things happen to fast."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await asyncio.sleep(0.1)
await self.push_frame(frame, direction)
async def test_broadcast_interruption(self):
"""Test that broadcast_interruption() pushes InterruptionFrame both
directions and allows subsequent code to run."""
class InterruptFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.push_interruption_task_frame_and_wait()
await self.broadcast_interruption()
await self.push_frame(OutputTransportMessageUrgentFrame(message=frame.text))
else:
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayFrameProcessor(), InterruptFrameProcessor()])
pipeline = Pipeline([InterruptFrameProcessor()])
frames_to_send = [
# Just a random interruption to make sure we don't clear anything
# before the actual `InterruptionTaskFrame` interruption.
InterruptionFrame(),
# This will generate an `InterruptionTaskFrame` and will wait for an
# `InterruptionFrame`.
TextFrame(text="Hello from Pipecat!"),
# Just give time for everything to complete.
SleepFrame(sleep=0.5),
EndFrame(),
]
expected_down_frames = [
InterruptionFrame,
InterruptionFrame,
OutputTransportMessageUrgentFrame,
EndFrame,
]
expected_up_frames = [
InterruptionFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
async def test_interruptible_frames(self):
@dataclass
class TestInterruptibleFrame(DataFrame):
text: str
class DelayTestFrameProcessor(FrameProcessor):
"""This processor just delays processing frames so we have time to
try to interrupt them.
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not isinstance(frame, SystemFrame):
# Sleep more than SleepFrame default.
await asyncio.sleep(0.4)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayTestFrameProcessor()])
frames_to_send = [
TestInterruptibleFrame(text="Hello from Pipecat!"),
# Make sure we hit the DelayTestFrameProcessor first.
SleepFrame(),
# Just a random interruption. This should cause the interruption of
# TestInterruptibleFrame.
InterruptionFrame(),
]
expected_down_frames = [InterruptionFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_uninterruptible_frames(self):
@dataclass
class TestUninterruptibleFrame(DataFrame, UninterruptibleFrame):
text: str
class DelayTestFrameProcessor(FrameProcessor):
"""This processor just delays processing non-InterruptionFrame so we
have time to try to interrupt them.
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not isinstance(frame, SystemFrame):
# Sleep more than SleepFrame default.
await asyncio.sleep(0.4)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayTestFrameProcessor()])
frames_to_send = [
TestUninterruptibleFrame(text="Hello from Pipecat!"),
# Make sure we hit the DelayTestFrameProcessor first.
SleepFrame(),
# Just a random interruption. This should not cause the interruption
# of TestUninterruptibleFrame.
InterruptionFrame(),
]
expected_down_frames = [
InterruptionFrame,
TestUninterruptibleFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_broadcast_frame(self):
"""Test that broadcast_frame creates two separate frames with fresh IDs."""
downstream_frames: List[Frame] = []
upstream_frames: List[Frame] = []
class BroadcastTestProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.broadcast_frame(
BroadcastTestFrame, text="hello", value=42, items=["a", "b"]
)
else:
await self.push_frame(frame, direction)
class CaptureProcessor(FrameProcessor):
def __init__(self, capture_list: List[Frame], direction: FrameDirection):
super().__init__()
self._capture_list = capture_list
self._capture_direction = direction
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame):
self._capture_list.append(frame)
await self.push_frame(frame, direction)
up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM)
broadcaster = BroadcastTestProcessor()
down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM)
pipeline = Pipeline([up_capture, broadcaster, down_capture])
frames_to_send = [TextFrame(text="trigger")]
expected_down_frames = [BroadcastTestFrame]
expected_up_frames = [BroadcastTestFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
# Verify we got one frame in each direction
self.assertEqual(len(downstream_frames), 1)
self.assertEqual(len(upstream_frames), 1)
down_frame = downstream_frames[0]
up_frame = upstream_frames[0]
# Verify the frames have different IDs (they are separate instances)
self.assertNotEqual(down_frame.id, up_frame.id)
# Verify the frames have the correct field values
self.assertEqual(down_frame.text, "hello")
self.assertEqual(down_frame.value, 42)
self.assertEqual(down_frame.items, ["a", "b"])
self.assertEqual(up_frame.text, "hello")
self.assertEqual(up_frame.value, 42)
self.assertEqual(up_frame.items, ["a", "b"])
# Verify the items lists are shared references (no deep copy)
self.assertIs(down_frame.items, up_frame.items)
async def test_broadcast_frame_instance(self):
"""Test that broadcast_frame_instance shallow-copies all fields except id and name."""
downstream_frames: List[Frame] = []
upstream_frames: List[Frame] = []
original_frame: List[Frame] = []
class BroadcastInstanceTestProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BroadcastTestFrame):
# Set some non-init fields on the frame
frame.pts = 12345
frame.metadata = {"key": "value", "nested": {"a": 1}}
original_frame.append(frame)
await self.broadcast_frame_instance(frame)
else:
await self.push_frame(frame, direction)
class CaptureProcessor(FrameProcessor):
def __init__(self, capture_list: List[Frame], direction: FrameDirection):
super().__init__()
self._capture_list = capture_list
self._capture_direction = direction
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame):
self._capture_list.append(frame)
await self.push_frame(frame, direction)
up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM)
broadcaster = BroadcastInstanceTestProcessor()
down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM)
pipeline = Pipeline([up_capture, broadcaster, down_capture])
# Create a frame with mutable fields to test shallow copying
test_frame = BroadcastTestFrame(text="test", value=99, items=["x", "y", "z"])
frames_to_send = [test_frame]
expected_down_frames = [BroadcastTestFrame]
expected_up_frames = [BroadcastTestFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
# Verify we got one frame in each direction
self.assertEqual(len(downstream_frames), 1)
self.assertEqual(len(upstream_frames), 1)
self.assertEqual(len(original_frame), 1)
orig = original_frame[0]
down_frame = downstream_frames[0]
up_frame = upstream_frames[0]
# Verify the frames have different IDs and names (fresh values)
self.assertNotEqual(down_frame.id, orig.id)
self.assertNotEqual(up_frame.id, orig.id)
self.assertNotEqual(down_frame.id, up_frame.id)
self.assertNotEqual(down_frame.name, orig.name)
self.assertNotEqual(up_frame.name, orig.name)
# Verify init fields are copied correctly
self.assertEqual(down_frame.text, "test")
self.assertEqual(down_frame.value, 99)
self.assertEqual(down_frame.items, ["x", "y", "z"])
self.assertEqual(up_frame.text, "test")
self.assertEqual(up_frame.value, 99)
self.assertEqual(up_frame.items, ["x", "y", "z"])
# Verify non-init fields (except id/name) are copied
self.assertEqual(down_frame.pts, 12345)
self.assertEqual(down_frame.metadata, {"key": "value", "nested": {"a": 1}})
self.assertEqual(up_frame.pts, 12345)
self.assertEqual(up_frame.metadata, {"key": "value", "nested": {"a": 1}})
# Verify mutable fields are shallow-copied (shared references)
self.assertIs(down_frame.items, orig.items)
self.assertIs(up_frame.items, orig.items)
self.assertIs(down_frame.metadata, orig.metadata)
self.assertIs(up_frame.metadata, orig.metadata)
async def test_terminal_frames_survive_interruption(self):
"""Test that EndFrame survives interruption (it is uninterruptible).
This test simulates issue #3524 where an InterruptionFrame during slow
processing would cause terminal frames to be lost, freezing the pipeline.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption.
When processing a TextFrame, it sleeps and then pushes an
InterruptionFrame to simulate what happens when interruption occurs
while a terminal frame is in the queue.
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow EndFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the EndFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify EndFrame was received by our capture processor (survived interruption)
# Note: run_test filters EndFrame from expected_down_frames when send_end_frame=True,
# but our capture processor sees it before that filtering.
end_frames = [f for f in received_frames if isinstance(f, EndFrame)]
self.assertEqual(len(end_frames), 1, "EndFrame should survive interruption")
async def test_stop_frame_survives_interruption(self):
"""Test that StopFrame survives interruption (it is uninterruptible).
Similar to test_terminal_frames_survive_interruption but specifically
for StopFrame.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow StopFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the StopFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
StopFrame(),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
StopFrame,
]
await run_test(
pipeline,
@@ -110,3 +435,45 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
send_end_frame=False,
)
# Verify StopFrame was received (survived interruption)
stop_frames = [f for f in received_frames if isinstance(f, StopFrame)]
self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption")
async def test_broadcast_interruption_allows_subsequent_code(self):
"""Test that broadcast_interruption() returns immediately, allowing the
caller to run code afterwards (e.g. push an urgent frame)."""
code_after_ran = False
class InterruptOnTextProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
nonlocal code_after_ran
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.broadcast_interruption()
code_after_ran = True
await self.push_frame(OutputTransportMessageUrgentFrame(message="done"))
else:
await self.push_frame(frame, direction)
pipeline = Pipeline([InterruptOnTextProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
]
expected_down_frames = [
InterruptionFrame,
OutputTransportMessageUrgentFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(code_after_ran, "Code after broadcast_interruption() should execute")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 20242025, Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -204,3 +204,7 @@ class TestFunctionAdapters(unittest.TestCase):
}
]
assert AWSBedrockLLMAdapter().to_provider_tools_format(self.tools_def) == expected
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for Google LLM OpenAI Beta service."""
import asyncio
import warnings
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
try:
from pipecat.services.google.openai.llm import GoogleLLMOpenAIBetaService
google_available = True
except Exception:
google_available = False
@pytest.mark.asyncio
@pytest.mark.skipif(not google_available, reason="Google dependencies not installed")
async def test_google_llm_openai_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3639.
"""
with patch.object(GoogleLLMOpenAIBetaService, "create_client"):
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
service = GoogleLLMOpenAIBetaService(api_key="test-key", model="test-model")
service._client = AsyncMock()
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
nonlocal stream_closed
stream_closed = True
return False
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
raise asyncio.CancelledError()
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = OpenAILLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
assert stream_closed, "Stream should be closed even when CancelledError occurs"

View File

@@ -0,0 +1,55 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
import pipecat.services.google.utils
from pipecat.services.google.utils import update_google_client_http_options
MOCKED_VERSION = "0.0.0-test"
pipecat.services.google.utils.pipecat_version = lambda: MOCKED_VERSION
class TestGoogleUtils(unittest.TestCase):
def test_update_google_client_http_options_none(self):
options = update_google_client_http_options(None)
self.assertEqual(options, {"headers": {"x-goog-api-client": f"pipecat/{MOCKED_VERSION}"}})
def test_update_google_client_http_options_dict_empty(self):
options = update_google_client_http_options({})
self.assertEqual(options, {"headers": {"x-goog-api-client": f"pipecat/{MOCKED_VERSION}"}})
def test_update_google_client_http_options_dict_existing_headers(self):
initial_options = {"headers": {"Authorization": "Bearer token"}}
options = update_google_client_http_options(initial_options)
self.assertEqual(options["headers"]["Authorization"], "Bearer token")
self.assertEqual(options["headers"]["x-goog-api-client"], f"pipecat/{MOCKED_VERSION}")
def test_update_google_client_http_options_object(self):
class HttpOptions:
def __init__(self):
self.headers = None
http_options = HttpOptions()
updated_options = update_google_client_http_options(http_options)
self.assertEqual(
updated_options.headers, {"x-goog-api-client": f"pipecat/{MOCKED_VERSION}"}
)
def test_update_google_client_http_options_object_existing_headers(self):
class HttpOptions:
def __init__(self):
self.headers = {"Authorization": "Bearer token"}
http_options = HttpOptions()
updated_options = update_google_client_http_options(http_options)
self.assertEqual(updated_options.headers["Authorization"], "Bearer token")
self.assertEqual(updated_options.headers["x-goog-api-client"], f"pipecat/{MOCKED_VERSION}")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -9,7 +9,7 @@ import unittest
from pipecat.audio.interruptions.min_words_interruption_strategy import MinWordsInterruptionStrategy
class TestInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
async def test_min_words(self):
strategy = MinWordsInterruptionStrategy(min_words=2)
await strategy.append_text("Hello")
@@ -22,3 +22,7 @@ class TestInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
self.assertEqual(await strategy.should_interrupt(), False)
await strategy.append_text(" How are you?")
self.assertEqual(await strategy.should_interrupt(), True)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -10,6 +10,8 @@ from unittest.mock import AsyncMock
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.extensions.ivr.ivr_navigator import IVRProcessor
from pipecat.frames.frames import (
AggregatedTextFrame,
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame,
LLMTextFrame,
OutputDTMFUrgentFrame,
@@ -334,10 +336,12 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
LLMTextFrame(text="Hello, I'm trying to reach billing."),
LLMFullResponseEndFrame(),
]
expected_down_frames = [
LLMTextFrame, # Should pass through unchanged
AggregatedTextFrame, # LLMTextFrames aggregrated and converted to AggregatedTextFrame
LLMFullResponseEndFrame,
]
expected_up_frames = [
@@ -350,3 +354,7 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,200 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for Krisp SDK Manager (singleton with reference counting)."""
import sys
from unittest.mock import MagicMock, patch
import pytest
# Mock package version check before importing pipecat
# This allows tests to run in development mode without installed package
_version_patcher = patch("importlib.metadata.version", return_value="0.0.0-dev")
_version_patcher.start()
# Mock krisp_audio module BEFORE any pipecat imports
# This allows tests to run without krisp_audio installed
mock_krisp_audio = MagicMock()
mock_krisp_audio.SamplingRate.Sr8000Hz = 8000
mock_krisp_audio.SamplingRate.Sr16000Hz = 16000
mock_krisp_audio.SamplingRate.Sr24000Hz = 24000
mock_krisp_audio.SamplingRate.Sr32000Hz = 32000
mock_krisp_audio.SamplingRate.Sr44100Hz = 44100
mock_krisp_audio.SamplingRate.Sr48000Hz = 48000
mock_krisp_audio.FrameDuration.Fd10ms = "10ms"
mock_krisp_audio.FrameDuration.Fd15ms = "15ms"
mock_krisp_audio.FrameDuration.Fd20ms = "20ms"
mock_krisp_audio.FrameDuration.Fd30ms = "30ms"
mock_krisp_audio.FrameDuration.Fd32ms = "32ms"
mock_krisp_audio.LogLevel.Off = 0
# Mock getVersion to return a version object
mock_version = MagicMock()
mock_version.major = 1
mock_version.minor = 0
mock_version.patch = 0
mock_krisp_audio.getVersion.return_value = mock_version
# Install the mock in sys.modules before importing
sys.modules["krisp_audio"] = mock_krisp_audio
# Mock pipecat_ai_krisp package
mock_pipecat_krisp = MagicMock()
sys.modules["pipecat_ai_krisp"] = mock_pipecat_krisp
sys.modules["pipecat_ai_krisp.audio"] = MagicMock()
sys.modules["pipecat_ai_krisp.audio.krisp_processor"] = MagicMock()
# Now we can safely import
from pipecat.audio.krisp_instance import (
KRISP_SAMPLE_RATES,
KrispVivaSDKManager,
int_to_krisp_sample_rate,
)
class TestKrispVivaSDKManager:
"""Tests for KrispVivaSDKManager singleton."""
def setup_method(self):
"""Reset mocks and SDK state before each test."""
mock_krisp_audio.reset_mock()
mock_krisp_audio.getVersion.return_value = mock_version
# Reset the SDK manager state for clean tests
# We access internal state to ensure tests are isolated
with KrispVivaSDKManager._lock:
# Release any leftover references from previous tests
while KrispVivaSDKManager._reference_count > 0:
KrispVivaSDKManager._reference_count -= 1
KrispVivaSDKManager._initialized = False
def test_reference_counting(self):
"""Test that SDK manager properly tracks references."""
# Initial state
initial_count = KrispVivaSDKManager.get_reference_count()
assert initial_count == 0
# Acquire first reference
KrispVivaSDKManager.acquire()
assert KrispVivaSDKManager.get_reference_count() == initial_count + 1
assert KrispVivaSDKManager.is_initialized()
# Verify globalInit was called
mock_krisp_audio.globalInit.assert_called_once()
# Acquire second reference
KrispVivaSDKManager.acquire()
assert KrispVivaSDKManager.get_reference_count() == initial_count + 2
assert KrispVivaSDKManager.is_initialized()
# globalInit should NOT be called again
assert mock_krisp_audio.globalInit.call_count == 1
# Release first reference
KrispVivaSDKManager.release()
assert KrispVivaSDKManager.get_reference_count() == initial_count + 1
assert KrispVivaSDKManager.is_initialized()
# globalDestroy should NOT be called yet
mock_krisp_audio.globalDestroy.assert_not_called()
# Release second reference
KrispVivaSDKManager.release()
assert KrispVivaSDKManager.get_reference_count() == initial_count
# globalDestroy should be called now
mock_krisp_audio.globalDestroy.assert_called_once()
def test_multiple_acquire_release_cycles(self):
"""Test multiple acquire/release cycles."""
initial_count = KrispVivaSDKManager.get_reference_count()
for i in range(3):
KrispVivaSDKManager.acquire()
assert KrispVivaSDKManager.get_reference_count() > initial_count
assert KrispVivaSDKManager.is_initialized()
KrispVivaSDKManager.release()
assert KrispVivaSDKManager.get_reference_count() == initial_count
# Verify globalInit/globalDestroy were called for each cycle
assert mock_krisp_audio.globalInit.call_count == 3
assert mock_krisp_audio.globalDestroy.call_count == 3
def test_sdk_initialization_failure(self):
"""Test that SDK initialization failures are handled properly."""
mock_krisp_audio.globalInit.side_effect = Exception("SDK init failed")
with pytest.raises(Exception, match="SDK init failed"):
KrispVivaSDKManager.acquire()
# Verify SDK is not initialized after failure
assert not KrispVivaSDKManager.is_initialized()
assert KrispVivaSDKManager.get_reference_count() == 0
# Reset the side effect for other tests
mock_krisp_audio.globalInit.side_effect = None
def test_release_without_acquire(self):
"""Test that release without acquire is safe."""
initial_count = KrispVivaSDKManager.get_reference_count()
# Release without acquire should be safe (no-op)
KrispVivaSDKManager.release()
assert KrispVivaSDKManager.get_reference_count() == initial_count
mock_krisp_audio.globalDestroy.assert_not_called()
def test_is_initialized_state(self):
"""Test is_initialized state transitions."""
# Initially not initialized
assert not KrispVivaSDKManager.is_initialized()
# After acquire, should be initialized
KrispVivaSDKManager.acquire()
assert KrispVivaSDKManager.is_initialized()
# After release, should not be initialized
KrispVivaSDKManager.release()
assert not KrispVivaSDKManager.is_initialized()
class TestSampleRateConversion:
"""Tests for sample rate conversion utilities."""
def test_supported_sample_rates(self):
"""Test conversion of all supported sample rates."""
for rate_hz, krisp_enum in KRISP_SAMPLE_RATES.items():
result = int_to_krisp_sample_rate(rate_hz)
assert result == krisp_enum
def test_unsupported_sample_rate(self):
"""Test that unsupported rates raise ValueError."""
with pytest.raises(ValueError, match="Unsupported sample rate"):
int_to_krisp_sample_rate(22050) # Not supported
with pytest.raises(ValueError, match="Unsupported sample rate"):
int_to_krisp_sample_rate(96000) # Not supported
def test_sample_rate_error_message(self):
"""Test that error message includes helpful information."""
try:
int_to_krisp_sample_rate(11025)
except ValueError as e:
assert "11025" in str(e)
assert "Supported rates" in str(e)
# Should list at least some supported rates
assert "16000" in str(e)
def test_all_krisp_sample_rates_defined(self):
"""Test that all expected sample rates are in KRISP_SAMPLE_RATES."""
expected_rates = [8000, 16000, 24000, 32000, 44100, 48000]
for rate in expected_rates:
assert rate in KRISP_SAMPLE_RATES
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,777 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
# Mock package version check before importing pipecat
# This allows tests to run in development mode without installed package
_version_patcher = patch("importlib.metadata.version", return_value="0.0.0-dev")
_version_patcher.start()
# Mock krisp_audio module BEFORE any pipecat imports
# This allows tests to run without krisp_audio installed
mock_krisp_audio = MagicMock()
mock_krisp_audio.SamplingRate.Sr8000Hz = 8000
mock_krisp_audio.SamplingRate.Sr16000Hz = 16000
mock_krisp_audio.SamplingRate.Sr24000Hz = 24000
mock_krisp_audio.SamplingRate.Sr32000Hz = 32000
mock_krisp_audio.SamplingRate.Sr44100Hz = 44100
mock_krisp_audio.SamplingRate.Sr48000Hz = 48000
mock_krisp_audio.FrameDuration.Fd10ms = "10ms"
mock_krisp_audio.FrameDuration.Fd15ms = "15ms"
mock_krisp_audio.FrameDuration.Fd20ms = "20ms"
mock_krisp_audio.FrameDuration.Fd30ms = "30ms"
mock_krisp_audio.FrameDuration.Fd32ms = "32ms"
# Install the mock in sys.modules before importing
sys.modules["krisp_audio"] = mock_krisp_audio
# Mock pipecat_ai_krisp package
mock_pipecat_krisp = MagicMock()
sys.modules["pipecat_ai_krisp"] = mock_pipecat_krisp
sys.modules["pipecat_ai_krisp.audio"] = MagicMock()
sys.modules["pipecat_ai_krisp.audio.krisp_processor"] = MagicMock()
# Now we can safely import
from pipecat.audio.filters.krisp_viva_filter import KrispVivaFilter
from pipecat.frames.frames import FilterEnableFrame
class TestKrispVivaFilter(unittest.IsolatedAsyncioTestCase):
"""Test suite for KrispVivaFilter audio filter."""
def setUp(self):
"""Set up test fixtures before each test method."""
# Create a temporary .kef model file for testing
self.temp_model_file = tempfile.NamedTemporaryFile(suffix=".kef", delete=False)
self.temp_model_file.write(b"dummy model data")
self.temp_model_file.close()
self.model_path = self.temp_model_file.name
# Use the global mock_krisp_audio that was set up before imports
self.mock_krisp_audio = mock_krisp_audio
# Reset all mocks to clear call counts from previous tests
self.mock_krisp_audio.reset_mock()
self.mock_krisp_audio.ModelInfo.reset_mock()
self.mock_krisp_audio.NcSessionConfig.reset_mock()
self.mock_krisp_audio.NcInt16.reset_mock()
# Mock ModelInfo
self.mock_model_info = MagicMock()
self.mock_krisp_audio.ModelInfo.return_value = self.mock_model_info
# Mock NcSessionConfig
self.mock_nc_cfg = MagicMock()
self.mock_krisp_audio.NcSessionConfig.return_value = self.mock_nc_cfg
# Mock session
self.mock_session = MagicMock()
self.mock_session.process = MagicMock(side_effect=lambda x, level: x)
self.mock_krisp_audio.NcInt16.create.return_value = self.mock_session
# Patch krisp_audio in the module
self.sample_rates_patch = patch(
"pipecat.audio.filters.krisp_viva_filter.krisp_audio", self.mock_krisp_audio
)
self.sample_rates_patch.start()
# Patch KrispVivaSDKManager
self.sdk_manager_patcher = patch(
"pipecat.audio.filters.krisp_viva_filter.KrispVivaSDKManager"
)
self.mock_sdk_manager = self.sdk_manager_patcher.start()
self.mock_sdk_manager.acquire = MagicMock()
self.mock_sdk_manager.release = MagicMock()
def tearDown(self):
"""Clean up test fixtures after each test method."""
# Stop all patchers
self.sample_rates_patch.stop()
self.sdk_manager_patcher.stop()
# Remove temporary model file
if os.path.exists(self.model_path):
os.unlink(self.model_path)
async def test_initialization_with_model_path(self):
"""Test filter initialization with explicit model path."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Verify SDK was NOT acquired during initialization (happens in start())
self.mock_sdk_manager.acquire.assert_not_called()
# Verify filter attributes
self.assertEqual(filter_instance._model_path, self.model_path)
self.assertTrue(filter_instance._filtering) # Filtering starts enabled
self.assertEqual(filter_instance._noise_suppression_level, 100)
self.assertIsNotNone(filter_instance._audio_buffer)
async def test_initialization_with_env_variable(self):
"""Test filter initialization using KRISP_VIVA_FILTER_MODEL_PATH environment variable."""
with patch.dict(os.environ, {"KRISP_VIVA_FILTER_MODEL_PATH": self.model_path}):
filter_instance = KrispVivaFilter()
# Verify SDK was NOT acquired during initialization (happens in start())
self.mock_sdk_manager.acquire.assert_not_called()
self.assertEqual(filter_instance._model_path, self.model_path)
async def test_initialization_without_model_path(self):
"""Test filter initialization fails without model path."""
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ValueError) as context:
KrispVivaFilter()
self.assertIn("Model path", str(context.exception))
# SDK acquire not called during initialization (happens in start())
# But release() is called in exception handler even though acquire() wasn't called
self.mock_sdk_manager.acquire.assert_not_called()
self.mock_sdk_manager.release.assert_called_once()
async def test_initialization_with_invalid_extension(self):
"""Test filter initialization fails with non-.kef file."""
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
tmp.write(b"dummy")
tmp_path = tmp.name
try:
with self.assertRaises(Exception) as context:
KrispVivaFilter(model_path=tmp_path)
self.assertIn(".kef extension", str(context.exception))
# SDK acquire not called during initialization (happens in start())
# But release() is called in exception handler even though acquire() wasn't called
self.mock_sdk_manager.acquire.assert_not_called()
self.mock_sdk_manager.release.assert_called_once()
finally:
os.unlink(tmp_path)
async def test_initialization_with_nonexistent_file(self):
"""Test filter initialization fails with non-existent model file."""
with self.assertRaises(FileNotFoundError):
KrispVivaFilter(model_path="/nonexistent/path/model.kef")
# SDK acquire not called during initialization (happens in start())
# But release() is called in exception handler even though acquire() wasn't called
self.mock_sdk_manager.acquire.assert_not_called()
self.mock_sdk_manager.release.assert_called_once()
async def test_initialization_with_custom_noise_level(self):
"""Test filter initialization with custom noise suppression level."""
filter_instance = KrispVivaFilter(model_path=self.model_path, noise_suppression_level=50)
self.assertEqual(filter_instance._noise_suppression_level, 50)
async def test_initialization_with_default_noise_level(self):
"""Test filter initialization with default noise suppression level."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
self.assertEqual(filter_instance._noise_suppression_level, 100)
async def test_start_with_supported_sample_rate(self):
"""Test starting filter with a supported sample rate."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Verify SDK was acquired during start()
self.mock_sdk_manager.acquire.assert_called_once()
# Verify session was created
self.assertIsNotNone(filter_instance._session)
self.assertEqual(filter_instance._current_sample_rate, 16000)
self.assertEqual(filter_instance._samples_per_frame, 160) # 16000 * 10ms / 1000
# Verify NcSessionConfig was created and configured
# Note: Called once in start() (no preload session anymore)
self.assertEqual(self.mock_krisp_audio.NcSessionConfig.call_count, 1)
# Verify frame duration was set (hardcoded to 10ms in filter)
self.assertEqual(self.mock_nc_cfg.inputFrameDuration, "10ms")
# inputSampleRate and outputSampleRate are now set to the enum value
from pipecat.audio.krisp_instance import int_to_krisp_sample_rate
expected_sample_rate = int_to_krisp_sample_rate(16000)
self.assertEqual(self.mock_nc_cfg.inputSampleRate, expected_sample_rate)
self.assertEqual(self.mock_nc_cfg.outputSampleRate, expected_sample_rate)
async def test_start_with_unsupported_sample_rate(self):
"""Test starting filter with an unsupported sample rate raises RuntimeError."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
with self.assertRaises(RuntimeError) as context:
await filter_instance.start(12000) # Unsupported sample rate
self.assertIn("Unsupported sample rate", str(context.exception))
async def test_start_multiple_sample_rates(self):
"""Test starting filter with multiple different sample rates."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
for sample_rate in [8000, 16000, 24000, 32000, 44100, 48000]:
# Reset mock config for each iteration to verify frame duration is always set
mock_nc_cfg = MagicMock()
self.mock_krisp_audio.NcSessionConfig.return_value = mock_nc_cfg
await filter_instance.start(sample_rate)
self.assertEqual(filter_instance._current_sample_rate, sample_rate)
expected_samples = int((sample_rate * 10) / 1000)
self.assertEqual(filter_instance._samples_per_frame, expected_samples)
# Verify frame duration is always set to 10ms (hardcoded in filter)
self.assertEqual(mock_nc_cfg.inputFrameDuration, "10ms")
async def test_stop(self):
"""Test stopping the filter."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
await filter_instance.stop()
# Verify session was cleared
self.assertIsNone(filter_instance._session)
async def test_process_frame_enable(self):
"""Test processing FilterEnableFrame to enable filtering."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Disable filtering first
filter_instance._filtering = False
enable_frame = FilterEnableFrame(enable=True)
await filter_instance.process_frame(enable_frame)
self.assertTrue(filter_instance._filtering)
async def test_process_frame_disable(self):
"""Test processing FilterEnableFrame to disable filtering."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# After start, filtering should be enabled
self.assertTrue(filter_instance._filtering)
disable_frame = FilterEnableFrame(enable=False)
await filter_instance.process_frame(disable_frame)
self.assertFalse(filter_instance._filtering)
async def test_filter_when_disabled(self):
"""Test that filter returns audio unchanged when filtering is disabled."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Disable filtering
filter_instance._filtering = False
input_audio = b"\x00\x01\x02\x03\x04\x05"
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(output_audio, input_audio)
async def test_filter_with_complete_frame(self):
"""Test filtering audio with exactly one complete frame."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create audio data for exactly one 10ms frame (160 samples = 320 bytes)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Verify audio was processed
self.assertIsInstance(output_audio, bytes)
self.assertEqual(len(output_audio), len(input_audio))
# Verify session.process was called
self.mock_session.process.assert_called()
async def test_filter_with_multiple_frames(self):
"""Test filtering audio with multiple complete frames."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create audio data for 3 complete 10ms frames (480 samples = 960 bytes)
samples = np.random.randint(-32768, 32767, size=480, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Verify audio was processed
self.assertIsInstance(output_audio, bytes)
self.assertEqual(len(output_audio), len(input_audio))
# Verify session.process was called 3 times
self.assertEqual(self.mock_session.process.call_count, 3)
async def test_filter_with_incomplete_frame(self):
"""Test filtering audio with incomplete frame data."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create audio data for less than one frame (100 samples = 200 bytes)
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Should return empty bytes since no complete frame
self.assertEqual(output_audio, b"")
# Verify session.process was NOT called
self.mock_session.process.assert_not_called()
async def test_filter_with_buffering(self):
"""Test that filter properly buffers incomplete frames."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# First call: Send 100 samples (incomplete frame)
samples1 = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio1 = samples1.tobytes()
output_audio1 = await filter_instance.filter(input_audio1)
# Should buffer and return empty
self.assertEqual(output_audio1, b"")
self.assertEqual(len(filter_instance._audio_buffer), 200)
# Second call: Send 60 more samples (now we have 160 total = 1 complete frame)
samples2 = np.random.randint(-32768, 32767, size=60, dtype=np.int16)
input_audio2 = samples2.tobytes()
output_audio2 = await filter_instance.filter(input_audio2)
# Should process one frame and return 320 bytes
self.assertEqual(len(output_audio2), 320)
self.assertEqual(len(filter_instance._audio_buffer), 0)
self.mock_session.process.assert_called_once()
async def test_filter_with_partial_buffering(self):
"""Test that filter keeps remainder in buffer after processing."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Send 250 samples (1 complete frame + 90 samples remainder)
samples = np.random.randint(-32768, 32767, size=250, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Should process one frame (320 bytes)
self.assertEqual(len(output_audio), 320)
# Should keep remainder (90 samples = 180 bytes) in buffer
self.assertEqual(len(filter_instance._audio_buffer), 180)
self.mock_session.process.assert_called_once()
async def test_filter_error_handling(self):
"""Test that filter handles processing errors gracefully."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Make session.process raise an exception
self.mock_session.process.side_effect = Exception("Processing error")
# Create audio data for one complete frame
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
# Should return original audio on error
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(output_audio, input_audio)
async def test_filter_different_sample_rates(self):
"""Test filtering with different sample rates."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
test_cases = [
(8000, 80), # 8kHz: 80 samples per 10ms frame
(16000, 160), # 16kHz: 160 samples per 10ms frame
(48000, 480), # 48kHz: 480 samples per 10ms frame
]
for sample_rate, expected_samples in test_cases:
await filter_instance.start(sample_rate)
# Create audio data for exactly one frame
samples = np.random.randint(-32768, 32767, size=expected_samples, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Verify correct processing
self.assertEqual(len(output_audio), len(input_audio))
async def test_stop_releases_sdk(self):
"""Test that stop() properly releases SDK reference."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Stop the filter
await filter_instance.stop()
# Verify SDK was released
self.mock_sdk_manager.release.assert_called_once()
async def test_int_to_sample_rate_conversion(self):
"""Test sample rate conversion using the shared utility function."""
from pipecat.audio.krisp_instance import KRISP_SAMPLE_RATES, int_to_krisp_sample_rate
# Test valid sample rates - verify they return the correct enum values
for rate in [8000, 16000, 24000, 32000, 44100, 48000]:
result = int_to_krisp_sample_rate(rate)
# Check that result is from the KRISP_SAMPLE_RATES dict
self.assertEqual(result, KRISP_SAMPLE_RATES[rate])
# Test invalid sample rate
with self.assertRaises(ValueError) as context:
int_to_krisp_sample_rate(12000)
self.assertIn("Unsupported sample rate", str(context.exception))
async def test_noise_suppression_level_applied(self):
"""Test that noise suppression level is passed to processing."""
filter_instance = KrispVivaFilter(model_path=self.model_path, noise_suppression_level=75)
await filter_instance.start(16000)
# Create audio data for one frame
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
await filter_instance.filter(input_audio)
# Verify noise suppression level was passed to process()
call_args = self.mock_session.process.call_args
self.assertEqual(call_args[0][1], 75) # Second argument should be the level
async def test_start_acquires_sdk(self):
"""Test that start() acquires SDK reference and creates session."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Verify no session exists before start
self.assertIsNone(filter_instance._session)
# Start the filter
await filter_instance.start(16000)
# Verify SDK was acquired
self.mock_sdk_manager.acquire.assert_called_once()
# Verify session was created
self.assertIsNotNone(filter_instance._session)
# Verify NcSessionConfig was created and frame duration was set
self.mock_krisp_audio.NcSessionConfig.assert_called_once()
# Verify frame duration was set to 10ms (hardcoded in filter)
self.assertEqual(self.mock_nc_cfg.inputFrameDuration, "10ms")
async def test_filter_preserves_audio_data_integrity(self):
"""Test that filter processing preserves data integrity."""
# Make mock session return the same data
self.mock_session.process.side_effect = lambda x, level: x.copy()
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create deterministic audio data
samples = np.arange(160, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Verify output matches input (since mock returns same data)
output_samples = np.frombuffer(output_audio, dtype=np.int16)
np.testing.assert_array_equal(output_samples, samples)
# ==================== Concurrency & Thread Safety Tests ====================
async def test_concurrent_filter_calls(self):
"""Test that concurrent filter calls are handled safely."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create audio data for one frame
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
# Create multiple concurrent filter calls
async def filter_audio():
return await filter_instance.filter(input_audio)
# Run 10 concurrent filter operations
tasks = [filter_audio() for _ in range(10)]
results = await asyncio.gather(*tasks)
# Verify all calls completed successfully
self.assertEqual(len(results), 10)
for result in results:
self.assertIsInstance(result, bytes)
self.assertEqual(len(result), len(input_audio))
# Verify session.process was called for each frame
self.assertEqual(self.mock_session.process.call_count, 10)
async def test_concurrent_enable_disable(self):
"""Test rapid enable/disable toggling during filtering."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Create audio data
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
# Concurrently toggle enable/disable while filtering
async def toggle_and_filter(toggle_enable):
enable_frame = FilterEnableFrame(enable=toggle_enable)
await filter_instance.process_frame(enable_frame)
return await filter_instance.filter(input_audio)
# Run concurrent enable/disable operations
tasks = [
toggle_and_filter(True),
toggle_and_filter(False),
toggle_and_filter(True),
toggle_and_filter(False),
]
results = await asyncio.gather(*tasks)
# Verify all operations completed
self.assertEqual(len(results), 4)
# Verify final state is consistent (last operation was disable)
self.assertFalse(filter_instance._filtering)
async def test_concurrent_start_stop(self):
"""Test concurrent start/stop operations."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
async def start_filter():
await filter_instance.start(16000)
async def stop_filter():
await filter_instance.stop()
# Run start and stop concurrently
await asyncio.gather(start_filter(), stop_filter())
# Verify final state (stop should clear session)
# Note: This tests that operations don't crash, final state may vary
# depending on which completes first
async def test_concurrent_filter_with_state_changes(self):
"""Test filtering while state changes occur concurrently."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def filter_operation():
return await filter_instance.filter(input_audio)
async def toggle_filtering():
# Toggle based on current filtering state
is_filtering = filter_instance._filtering
enable_frame = FilterEnableFrame(enable=not is_filtering)
await filter_instance.process_frame(enable_frame)
# Run filtering and toggling concurrently
filter_tasks = [filter_operation() for _ in range(5)]
toggle_tasks = [toggle_filtering() for _ in range(3)]
results = await asyncio.gather(*filter_tasks + toggle_tasks)
# Verify all operations completed without errors
self.assertEqual(len(results), 8)
# ==================== State Transition Tests ====================
async def test_multiple_start_stop_cycles(self):
"""Test multiple start/stop cycles."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# First cycle
await filter_instance.start(16000)
self.assertIsNotNone(filter_instance._session)
self.assertEqual(filter_instance._current_sample_rate, 16000)
await filter_instance.stop()
self.assertIsNone(filter_instance._session)
# Second cycle
await filter_instance.start(24000)
self.assertIsNotNone(filter_instance._session)
self.assertEqual(filter_instance._current_sample_rate, 24000)
await filter_instance.stop()
self.assertIsNone(filter_instance._session)
# Third cycle
await filter_instance.start(48000)
self.assertIsNotNone(filter_instance._session)
self.assertEqual(filter_instance._current_sample_rate, 48000)
await filter_instance.stop()
self.assertIsNone(filter_instance._session)
# Verify session was created multiple times
self.assertGreaterEqual(self.mock_krisp_audio.NcInt16.create.call_count, 3)
async def test_sample_rate_change_during_operation(self):
"""Test changing sample rate between start/stop cycles."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Start with 16kHz
await filter_instance.start(16000)
self.assertEqual(filter_instance._current_sample_rate, 16000)
self.assertEqual(filter_instance._samples_per_frame, 160)
# Process some audio
samples_16k = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
output_16k = await filter_instance.filter(samples_16k.tobytes())
self.assertEqual(len(output_16k), 320) # 160 samples * 2 bytes
# Stop and change to 48kHz
await filter_instance.stop()
await filter_instance.start(48000)
self.assertEqual(filter_instance._current_sample_rate, 48000)
self.assertEqual(filter_instance._samples_per_frame, 480)
# Process audio at new sample rate
samples_48k = np.random.randint(-32768, 32767, size=480, dtype=np.int16)
output_48k = await filter_instance.filter(samples_48k.tobytes())
self.assertEqual(len(output_48k), 960) # 480 samples * 2 bytes
await filter_instance.stop()
async def test_start_after_stop_with_different_sample_rate(self):
"""Test starting with different sample rate after stop."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Start with 8kHz
await filter_instance.start(8000)
self.assertEqual(filter_instance._current_sample_rate, 8000)
await filter_instance.stop()
# Start with 32kHz
await filter_instance.start(32000)
self.assertEqual(filter_instance._current_sample_rate, 32000)
await filter_instance.stop()
# Start with 44.1kHz
await filter_instance.start(44100)
self.assertEqual(filter_instance._current_sample_rate, 44100)
await filter_instance.stop()
async def test_filter_state_persistence_across_start_stop(self):
"""Test that filtering state persists across start/stop cycles."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Filter starts with filtering enabled
self.assertTrue(filter_instance._filtering)
# Start the filter
await filter_instance.start(16000)
self.assertTrue(filter_instance._filtering)
self.assertIsNotNone(filter_instance._session)
# Disable filtering
disable_frame = FilterEnableFrame(enable=False)
await filter_instance.process_frame(disable_frame)
self.assertFalse(filter_instance._filtering)
# Stop the filter (cleanup)
await filter_instance.stop()
self.assertIsNone(filter_instance._session)
# Enable filtering again
enable_frame = FilterEnableFrame(enable=True)
await filter_instance.process_frame(enable_frame)
self.assertTrue(filter_instance._filtering)
# Start the filter again
await filter_instance.start(16000)
self.assertTrue(filter_instance._filtering)
self.assertIsNotNone(filter_instance._session)
async def test_noise_suppression_level_persistence(self):
"""Test that noise suppression level persists across start/stop."""
filter_instance = KrispVivaFilter(model_path=self.model_path, noise_suppression_level=75)
self.assertEqual(filter_instance._noise_suppression_level, 75)
# Start and stop
await filter_instance.start(16000)
await filter_instance.stop()
# Verify noise suppression level persisted
self.assertEqual(filter_instance._noise_suppression_level, 75)
async def test_buffer_cleared_on_stop(self):
"""Test that audio buffer is cleared when stopping."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
await filter_instance.start(16000)
# Add incomplete frame to buffer
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
await filter_instance.filter(input_audio)
# Verify buffer has data
self.assertGreater(len(filter_instance._audio_buffer), 0)
# Stop should clear buffer (or at least not cause issues)
await filter_instance.stop()
# Buffer state after stop - verify no errors on next start
await filter_instance.start(16000)
# Should be able to filter after restart
output = await filter_instance.filter(input_audio)
self.assertIsInstance(output, bytes)
async def test_multiple_starts_without_stop(self):
"""Test behavior when start is called multiple times without stop."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# First start
await filter_instance.start(16000)
session1 = filter_instance._session
self.assertIsNotNone(session1)
# Second start without stop (should replace session)
await filter_instance.start(24000)
session2 = filter_instance._session
self.assertIsNotNone(session2)
self.assertEqual(filter_instance._current_sample_rate, 24000)
# Third start
await filter_instance.start(48000)
session3 = filter_instance._session
self.assertIsNotNone(session3)
self.assertEqual(filter_instance._current_sample_rate, 48000)
await filter_instance.stop()
async def test_stop_without_start(self):
"""Test that stop can be called safely without start."""
filter_instance = KrispVivaFilter(model_path=self.model_path)
# Stop without starting should not raise an error
await filter_instance.stop()
# Verify session is None
self.assertIsNone(filter_instance._session)
# Should be able to start after stop without start
await filter_instance.start(16000)
self.assertIsNotNone(filter_instance._session)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,442 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for KrispVivaVadAnalyzer."""
import asyncio
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
# Mock package version check before importing pipecat
# This allows tests to run in development mode without installed package
_version_patcher = patch("importlib.metadata.version", return_value="0.0.0-dev")
_version_patcher.start()
# Mock krisp_audio module BEFORE any pipecat imports
# This allows tests to run without krisp_audio installed
mock_krisp_audio = MagicMock()
mock_krisp_audio.SamplingRate.Sr8000Hz = 8000
mock_krisp_audio.SamplingRate.Sr16000Hz = 16000
mock_krisp_audio.SamplingRate.Sr24000Hz = 24000
mock_krisp_audio.SamplingRate.Sr32000Hz = 32000
mock_krisp_audio.SamplingRate.Sr44100Hz = 44100
mock_krisp_audio.SamplingRate.Sr48000Hz = 48000
mock_krisp_audio.FrameDuration.Fd10ms = "10ms"
mock_krisp_audio.FrameDuration.Fd15ms = "15ms"
mock_krisp_audio.FrameDuration.Fd20ms = "20ms"
mock_krisp_audio.FrameDuration.Fd30ms = "30ms"
mock_krisp_audio.FrameDuration.Fd32ms = "32ms"
# Install the mock in sys.modules before importing
sys.modules["krisp_audio"] = mock_krisp_audio
# Mock pipecat_ai_krisp package
mock_pipecat_krisp = MagicMock()
sys.modules["pipecat_ai_krisp"] = mock_pipecat_krisp
sys.modules["pipecat_ai_krisp.audio"] = MagicMock()
sys.modules["pipecat_ai_krisp.audio.krisp_processor"] = MagicMock()
# Now we can safely import
from pipecat.audio.vad.krisp_viva_vad import KrispVivaVadAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams
class TestKrispVivaVadAnalyzer(unittest.TestCase):
"""Test suite for KrispVivaVadAnalyzer."""
def setUp(self):
"""Set up test fixtures before each test method."""
# Create a temporary .kef model file for testing
self.temp_model_file = tempfile.NamedTemporaryFile(suffix=".kef", delete=False)
self.temp_model_file.write(b"dummy model data")
self.temp_model_file.close()
self.model_path = self.temp_model_file.name
# Use the global mock_krisp_audio that was set up before imports
self.mock_krisp_audio = mock_krisp_audio
# Reset all mocks to clear call counts from previous tests
self.mock_krisp_audio.reset_mock()
self.mock_krisp_audio.ModelInfo.reset_mock()
self.mock_krisp_audio.VadSessionConfig.reset_mock()
self.mock_krisp_audio.VadFloat.reset_mock()
# Mock ModelInfo
self.mock_model_info = MagicMock()
self.mock_krisp_audio.ModelInfo.return_value = self.mock_model_info
# Mock VadSessionConfig
self.mock_vad_cfg = MagicMock()
self.mock_krisp_audio.VadSessionConfig.return_value = self.mock_vad_cfg
# Mock VAD session
self.mock_session = MagicMock()
self.mock_session.process = MagicMock(return_value=0.75) # Return voice probability
self.mock_krisp_audio.VadFloat.create.return_value = self.mock_session
# Patch krisp_audio in the module
self.krisp_audio_patch = patch(
"pipecat.audio.vad.krisp_viva_vad.krisp_audio", self.mock_krisp_audio
)
self.krisp_audio_patch.start()
# Patch KrispVivaSDKManager
self.sdk_manager_patcher = patch("pipecat.audio.vad.krisp_viva_vad.KrispVivaSDKManager")
self.mock_sdk_manager = self.sdk_manager_patcher.start()
self.mock_sdk_manager.acquire = MagicMock()
self.mock_sdk_manager.release = MagicMock()
def tearDown(self):
"""Clean up test fixtures after each test method."""
# Stop all patchers
self.krisp_audio_patch.stop()
self.sdk_manager_patcher.stop()
# Remove temporary model file
if os.path.exists(self.model_path):
os.unlink(self.model_path)
def test_initialization_with_model_path(self):
"""Test analyzer initialization with explicit model path."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
# Verify SDK was acquired during initialization
self.mock_sdk_manager.acquire.assert_called_once()
# Verify analyzer attributes
self.assertEqual(analyzer._model_path, self.model_path)
self.assertEqual(analyzer._frame_duration_ms, 10) # Default frame duration
self.assertIsNone(analyzer._session) # Session created in set_sample_rate
def test_initialization_with_env_variable(self):
"""Test analyzer initialization using KRISP_VIVA_VAD_MODEL_PATH environment variable."""
with patch.dict(os.environ, {"KRISP_VIVA_VAD_MODEL_PATH": self.model_path}):
analyzer = KrispVivaVadAnalyzer()
self.mock_sdk_manager.acquire.assert_called_once()
self.assertEqual(analyzer._model_path, self.model_path)
def test_initialization_without_model_path(self):
"""Test analyzer initialization fails without model path."""
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ValueError) as context:
KrispVivaVadAnalyzer()
self.assertIn("Model path", str(context.exception))
# acquire() is not called because exception is raised before it
self.mock_sdk_manager.acquire.assert_not_called()
# release() is called in the initialization exception handler
self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)
def test_initialization_with_invalid_extension(self):
"""Test analyzer initialization fails with non-.kef file."""
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
tmp.write(b"dummy")
tmp_path = tmp.name
try:
with self.assertRaises(Exception) as context:
KrispVivaVadAnalyzer(model_path=tmp_path)
self.assertIn(".kef extension", str(context.exception))
# acquire() is not called because exception is raised before it
self.mock_sdk_manager.acquire.assert_not_called()
# release() is called in the initialization exception handler
self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)
finally:
os.unlink(tmp_path)
def test_initialization_with_nonexistent_file(self):
"""Test analyzer initialization fails with non-existent model file."""
with self.assertRaises(FileNotFoundError):
KrispVivaVadAnalyzer(model_path="/nonexistent/path/model.kef")
# acquire() is not called because exception is raised before it
self.mock_sdk_manager.acquire.assert_not_called()
# release() is called in the initialization exception handler
self.assertGreaterEqual(self.mock_sdk_manager.release.call_count, 1)
def test_initialization_with_custom_frame_duration(self):
"""Test analyzer initialization with custom frame duration."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=20)
self.assertEqual(analyzer._frame_duration_ms, 20)
def test_initialization_with_sample_rate(self):
"""Test analyzer initialization with sample rate."""
analyzer = KrispVivaVadAnalyzer(
model_path=self.model_path, sample_rate=16000, frame_duration=10
)
# Should calculate samples per frame
self.assertEqual(analyzer._samples_per_frame, 160) # 16000 * 10 / 1000
def test_set_sample_rate_with_supported_rates(self):
"""Test setting sample rate with supported values."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
for sample_rate in [8000, 16000, 32000, 44100, 48000]:
analyzer.set_sample_rate(sample_rate)
# Verify session was created
self.assertIsNotNone(analyzer._session)
self.assertEqual(analyzer.sample_rate, sample_rate)
# Verify samples per frame was calculated
expected_samples = int((sample_rate * analyzer._frame_duration_ms) / 1000)
self.assertEqual(analyzer._samples_per_frame, expected_samples)
# Verify VadSessionConfig was created and configured
self.mock_krisp_audio.VadSessionConfig.assert_called()
self.assertEqual(self.mock_vad_cfg.modelInfo, self.mock_model_info)
def test_set_sample_rate_with_unsupported_rate(self):
"""Test setting sample rate with unsupported value raises ValueError."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
with self.assertRaises(ValueError) as context:
analyzer.set_sample_rate(12000) # Unsupported sample rate
self.assertIn("sample rate needs to be", str(context.exception))
def test_num_frames_required_with_session(self):
"""Test num_frames_required when session is created."""
analyzer = KrispVivaVadAnalyzer(
model_path=self.model_path, sample_rate=16000, frame_duration=10
)
analyzer.set_sample_rate(16000)
# Should return samples per frame from session
frames_required = analyzer.num_frames_required()
self.assertEqual(frames_required, 160) # 16000 * 10 / 1000
def test_num_frames_required_without_session(self):
"""Test num_frames_required when session is not created yet."""
analyzer = KrispVivaVadAnalyzer(
model_path=self.model_path, sample_rate=16000, frame_duration=10
)
# Should calculate from sample_rate
frames_required = analyzer.num_frames_required()
self.assertEqual(frames_required, 160)
def test_num_frames_required_with_different_sample_rates(self):
"""Test num_frames_required with different sample rates."""
test_cases = [
(8000, 10, 80), # 8kHz @ 10ms = 80 samples
(16000, 10, 160), # 16kHz @ 10ms = 160 samples
(16000, 20, 320), # 16kHz @ 20ms = 320 samples
(32000, 10, 320), # 32kHz @ 10ms = 320 samples
(44100, 10, 441), # 44.1kHz @ 10ms = 441 samples
(48000, 10, 480), # 48kHz @ 10ms = 480 samples
]
for sample_rate, frame_duration, expected_frames in test_cases:
analyzer = KrispVivaVadAnalyzer(
model_path=self.model_path,
sample_rate=sample_rate,
frame_duration=frame_duration,
)
analyzer.set_sample_rate(sample_rate)
frames_required = analyzer.num_frames_required()
self.assertEqual(
frames_required,
expected_frames,
f"Failed for {sample_rate}Hz @ {frame_duration}ms",
)
def test_num_frames_required_fallback(self):
"""Test num_frames_required fallback when sample rate not set."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=10)
# Should use default fallback (16kHz)
frames_required = analyzer.num_frames_required()
self.assertEqual(frames_required, 160) # 16000 * 10 / 1000
def test_voice_confidence_with_valid_buffer(self):
"""Test voice_confidence with valid audio buffer."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
analyzer.set_sample_rate(16000)
# Create audio buffer for one frame (160 samples = 320 bytes)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
audio_buffer = samples.tobytes()
confidence = analyzer.voice_confidence(audio_buffer)
# Verify confidence is returned
self.assertIsInstance(confidence, float)
self.assertEqual(confidence, 0.75) # Mock returns 0.75
# Verify session.process was called
self.mock_session.process.assert_called_once()
def test_voice_confidence_without_session(self):
"""Test voice_confidence when session is not initialized."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
# Create audio buffer
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
audio_buffer = samples.tobytes()
# Should return 0.0 and log warning
confidence = analyzer.voice_confidence(audio_buffer)
self.assertEqual(confidence, 0.0)
# Verify session.process was NOT called
self.mock_session.process.assert_not_called()
def test_voice_confidence_error_handling(self):
"""Test voice_confidence handles processing errors gracefully."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
analyzer.set_sample_rate(16000)
# Make session.process raise an exception
self.mock_session.process.side_effect = Exception("Processing error")
# Create audio buffer
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
audio_buffer = samples.tobytes()
# Should return 0.0 on error
confidence = analyzer.voice_confidence(audio_buffer)
self.assertEqual(confidence, 0.0)
def test_voice_confidence_audio_conversion(self):
"""Test voice_confidence properly converts audio format."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
analyzer.set_sample_rate(16000)
# Create deterministic audio buffer
samples = np.array([1000, -2000, 3000, -4000], dtype=np.int16)
audio_buffer = samples.tobytes()
analyzer.voice_confidence(audio_buffer)
# Verify process was called with float32 array
call_args = self.mock_session.process.call_args[0][0]
self.assertIsInstance(call_args, np.ndarray)
self.assertEqual(call_args.dtype, np.float32)
# Verify normalization (int16 to float32)
expected_float = samples.astype(np.float32) / 32768.0
np.testing.assert_array_almost_equal(call_args, expected_float)
def test_voice_confidence_different_buffer_sizes(self):
"""Test voice_confidence with different buffer sizes."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
analyzer.set_sample_rate(16000)
test_sizes = [80, 160, 320, 480] # Different numbers of samples
for size in test_sizes:
samples = np.random.randint(-32768, 32767, size=size, dtype=np.int16)
audio_buffer = samples.tobytes()
confidence = analyzer.voice_confidence(audio_buffer)
# Should always return a float
self.assertIsInstance(confidence, float)
self.assertGreaterEqual(confidence, 0.0)
self.assertLessEqual(confidence, 1.0)
def test_cleanup(self):
"""Test that cleanup releases resources explicitly."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
analyzer.set_sample_rate(16000)
# Reset mock to track calls
self.mock_sdk_manager.release.reset_mock()
asyncio.run(analyzer.cleanup())
# Verify SDK was released and session was cleared
self.mock_sdk_manager.release.assert_called_once()
self.assertIsNone(analyzer._session)
def test_initialization_with_vad_params(self):
"""Test analyzer initialization with VAD parameters."""
params = VADParams(confidence=0.8, start_secs=0.3, stop_secs=0.9)
analyzer = KrispVivaVadAnalyzer(
model_path=self.model_path, sample_rate=16000, params=params
)
self.assertEqual(analyzer.params.confidence, 0.8)
self.assertEqual(analyzer.params.start_secs, 0.3)
self.assertEqual(analyzer.params.stop_secs, 0.9)
def test_set_sample_rate_creates_session(self):
"""Test that set_sample_rate creates a new session."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
# Initially no session
self.assertIsNone(analyzer._session)
# Set sample rate should create session
analyzer.set_sample_rate(16000)
# Verify session was created
self.assertIsNotNone(analyzer._session)
self.mock_krisp_audio.VadFloat.create.assert_called_once()
def test_set_sample_rate_replaces_session(self):
"""Test that set_sample_rate replaces existing session."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
# Create first session
analyzer.set_sample_rate(16000)
session1 = analyzer._session
self.assertIsNotNone(session1)
# Reset mock to track new calls
self.mock_krisp_audio.VadFloat.create.reset_mock()
# Set different sample rate should create new session
analyzer.set_sample_rate(48000)
session2 = analyzer._session
self.assertIsNotNone(session2)
# Verify new session was created
self.mock_krisp_audio.VadFloat.create.assert_called_once()
def test_session_configuration(self):
"""Test that session is configured correctly."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path, frame_duration=15)
analyzer.set_sample_rate(16000)
# Verify VadSessionConfig was configured
self.assertEqual(self.mock_vad_cfg.modelInfo, self.mock_model_info)
self.assertEqual(self.mock_vad_cfg.inputFrameDuration, "15ms")
# Verify sample rate was set
from pipecat.audio.krisp_instance import int_to_krisp_sample_rate
expected_sample_rate = int_to_krisp_sample_rate(16000)
self.assertEqual(self.mock_vad_cfg.inputSampleRate, expected_sample_rate)
def test_multiple_sample_rate_changes(self):
"""Test multiple sample rate changes."""
analyzer = KrispVivaVadAnalyzer(model_path=self.model_path)
sample_rates = [8000, 16000, 32000, 48000]
for sample_rate in sample_rates:
analyzer.set_sample_rate(sample_rate)
self.assertEqual(analyzer.sample_rate, sample_rate)
# Verify num_frames_required is correct
expected_frames = int((sample_rate * 10) / 1000)
self.assertEqual(analyzer.num_frames_required(), expected_frames)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 20242025, Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -10,6 +10,7 @@ from langchain.prompts import ChatPromptTemplate
from langchain_core.language_models import FakeStreamingListLLM
from pipecat.frames.frames import (
InterruptionFrame,
LLMContextAssistantTimestampFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
@@ -18,16 +19,20 @@ from pipecat.frames.frames import (
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response import (
LLMAssistantAggregatorParams,
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.processors.frameworks.langchain import LangchainProcessor
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
class TestLangchain(unittest.IsolatedAsyncioTestCase):
@@ -65,20 +70,29 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
self.mock_proc = self.MockProcessor("token_collector")
context = LLMContext()
context_aggregator = LLMContextAggregatorPair(context)
context_aggregator = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(stop=[SpeechTimeoutUserTurnStopStrategy()])
),
)
pipeline = Pipeline(
[context_aggregator.user(), proc, self.mock_proc, context_aggregator.assistant()]
)
frames_to_send = [
UserStartedSpeakingFrame(),
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hi World", user_id="user", timestamp="now"),
SleepFrame(),
UserStoppedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=1.0),
]
expected_down_frames = [
VADUserStartedSpeakingFrame,
UserStartedSpeakingFrame,
InterruptionFrame,
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
LLMContextFrame,
LLMContextAssistantTimestampFrame,
@@ -93,3 +107,7 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
context_aggregator.assistant().messages[-1]["content"], self.expected_response
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,124 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for LiveKit transport video stream handling.
Regression tests for issue #3116: Memory leak when video_in_enabled=False
but video tracks are subscribed. The fix ensures video stream processing
only starts when there is a consumer for the frames.
"""
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
try:
from livekit import rtc
from pipecat.transports.livekit.transport import (
LiveKitCallbacks,
LiveKitParams,
LiveKitTransportClient,
)
LIVEKIT_AVAILABLE = True
except ImportError:
LIVEKIT_AVAILABLE = False
@unittest.skipUnless(LIVEKIT_AVAILABLE, "livekit package not installed")
class TestLiveKitVideoStreamMemoryLeak(unittest.IsolatedAsyncioTestCase):
"""Regression tests for video queue memory leak (#3116).
The bug: When video_in_enabled=False, subscribing to a video track would
start a producer that fills _video_queue, but no consumer would drain it,
causing unbounded memory growth (~3GB/min).
The fix: Only start video stream processing when video_in_enabled=True.
"""
def _create_client(self, video_in_enabled: bool) -> LiveKitTransportClient:
"""Create a client with the specified video input setting."""
params = LiveKitParams(video_in_enabled=video_in_enabled)
callbacks = LiveKitCallbacks(
on_connected=AsyncMock(),
on_disconnected=AsyncMock(),
on_before_disconnect=AsyncMock(),
on_participant_connected=AsyncMock(),
on_participant_disconnected=AsyncMock(),
on_audio_track_subscribed=AsyncMock(),
on_audio_track_unsubscribed=AsyncMock(),
on_video_track_subscribed=AsyncMock(),
on_video_track_unsubscribed=AsyncMock(),
on_data_received=AsyncMock(),
on_first_participant_joined=AsyncMock(),
)
client = LiveKitTransportClient(
url="wss://test.livekit.cloud",
token="test-token",
room_name="test-room",
params=params,
callbacks=callbacks,
transport_name="test-transport",
)
client._task_manager = MagicMock()
return client
def _create_mock_video_track(self):
"""Create a mock video track subscription event."""
track = MagicMock()
track.kind = rtc.TrackKind.KIND_VIDEO
track.sid = "video-track-123"
publication = MagicMock()
participant = MagicMock()
participant.sid = "participant-456"
return track, publication, participant
async def test_disabled_video_input_does_not_start_queue_producer(self):
"""When video input is disabled, no producer should fill the queue.
This prevents the memory leak where frames accumulate with no consumer.
"""
client = self._create_client(video_in_enabled=False)
track, publication, participant = self._create_mock_video_track()
await client._async_on_track_subscribed(track, publication, participant)
# Verify no video processing task was started
task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list]
video_tasks = [name for name in task_names if "video" in name.lower()]
self.assertEqual(video_tasks, [], "No video processing task should be started")
# Queue should remain empty
self.assertEqual(client._video_queue.qsize(), 0)
# Track metadata should still be recorded
self.assertIn(participant.sid, client._video_tracks)
# Callback should still fire for user code
client._callbacks.on_video_track_subscribed.assert_called_once()
async def test_enabled_video_input_starts_queue_producer(self):
"""When video input is enabled, the producer should start."""
client = self._create_client(video_in_enabled=True)
track, publication, participant = self._create_mock_video_track()
with patch.object(rtc, "VideoStream"):
await client._async_on_track_subscribed(track, publication, participant)
# Verify video processing task was started
task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list]
video_tasks = [name for name in task_names if "video" in name.lower()]
self.assertEqual(len(video_tasks), 1, "Video processing task should be started")
# Track metadata should be recorded
self.assertIn(participant.sid, client._video_tracks)
# Callback should fire
client._callbacks.on_video_track_subscribed.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,765 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
InterruptionFrame,
LLMContextSummaryRequestFrame,
LLMContextSummaryResultFrame,
LLMFullResponseStartFrame,
LLMSummarizeContextFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_context_summarizer import (
LLMContextSummarizer,
SummaryAppliedEvent,
)
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
from pipecat.utils.context.llm_context_summarization import (
LLMAutoContextSummarizationConfig,
LLMContextSummaryConfig,
)
class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
self.context = LLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
]
)
async def test_summarization_triggered_by_token_limit(self):
"""Test that summarization is triggered when token limit is reached."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100, # Very low to trigger easily
max_unsummarized_messages=100, # High so it doesn't trigger by message count
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages to exceed token limit
for i in range(10):
self.context.add_message(
{
"role": "user",
"content": "This is a test message that adds tokens to the context.",
}
)
# Trigger check by processing LLMFullResponseStartFrame
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should have triggered summarization
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
self.assertEqual(request_frame.context, self.context)
await summarizer.cleanup()
async def test_summarization_triggered_by_message_count(self):
"""Test that summarization is triggered when message count threshold is reached."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # Very high so it doesn't trigger by tokens
max_unsummarized_messages=5, # Low to trigger easily
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages to exceed message count
for i in range(6):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Trigger check
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should have triggered summarization
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
await summarizer.cleanup()
async def test_summarization_not_triggered_below_thresholds(self):
"""Test that summarization is not triggered when below thresholds."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=10000,
max_unsummarized_messages=20,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add a few messages (below threshold)
for i in range(3):
self.context.add_message({"role": "user", "content": "Short message"})
# Trigger check
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should NOT have triggered summarization
self.assertIsNone(request_frame)
await summarizer.cleanup()
async def test_summarization_in_progress_prevents_duplicate(self):
"""Test that a summarization in progress prevents triggering another."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50, # Very low
max_unsummarized_messages=100,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
# Add enough messages to trigger
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message to add tokens."})
# First trigger - should request summarization
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
# Second trigger while first is in progress - should NOT request again
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
await summarizer.cleanup()
async def test_summary_result_handling(self):
"""Test that summary results are processed and applied correctly."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
original_message_count = len(self.context.messages)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
# Simulate receiving a summary result
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="This is a test summary.",
last_summarized_index=5,
error=None,
)
await summarizer.process_frame(summary_result)
# Should have applied the summary and reduced message count
# Expected: system message + summary message + 2 recent messages = 4 messages
# (since last_summarized_index=5, we keep messages after index 5)
self.assertLess(len(self.context.messages), original_message_count)
# Check that summary was added
summary_messages = [
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
]
self.assertEqual(len(summary_messages), 1)
await summarizer.cleanup()
async def test_interruption_cancels_summarization(self):
"""Test that an interruption cancels pending summarization."""
config = LLMAutoContextSummarizationConfig(max_context_tokens=50)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
# Process interruption
await summarizer.process_frame(InterruptionFrame())
# Try to trigger again - should work since the previous one was canceled
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 2)
await summarizer.cleanup()
async def test_stale_summary_result_ignored(self):
"""Test that stale summary results are ignored."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
original_message_count = len(self.context.messages)
await summarizer.process_frame(LLMFullResponseStartFrame())
valid_request_id = request_frame.request_id
# Send a stale summary result (wrong request_id)
stale_result = LLMContextSummaryResultFrame(
request_id="stale-id-123",
summary="Stale summary",
last_summarized_index=3,
error=None,
)
await summarizer.process_frame(stale_result)
# Should be ignored - message count should not change
self.assertEqual(len(self.context.messages), original_message_count)
# Send the correct summary result
valid_result = LLMContextSummaryResultFrame(
request_id=valid_request_id,
summary="Valid summary",
last_summarized_index=5,
error=None,
)
await summarizer.process_frame(valid_result)
# Should be processed - message count should decrease
self.assertLess(len(self.context.messages), original_message_count)
# Check that summary was added
summary_messages = [
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
]
self.assertEqual(len(summary_messages), 1)
await summarizer.cleanup()
async def test_manual_summarization_via_frame(self):
"""Test that LLMSummarizeContextFrame triggers summarization on demand."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # High — auto trigger would never fire
max_unsummarized_messages=100,
)
summarizer = LLMContextSummarizer(
context=self.context,
config=config,
auto_trigger=False, # Disable auto; only manual requests should work
)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Auto-trigger should NOT fire even on LLMFullResponseStartFrame
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNone(request_frame)
# Manual trigger via LLMSummarizeContextFrame should fire
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
# The request must have a valid request_id and carry the current context
self.assertTrue(request_frame.request_id)
self.assertEqual(request_frame.context, self.context)
await summarizer.cleanup()
async def test_manual_summarization_with_config_override(self):
"""Test that LLMSummarizeContextFrame can override default summary config."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000,
summary_config=LLMContextSummaryConfig(
target_context_tokens=6000,
min_messages_after_summary=4,
),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Push a manual frame with custom config overrides
custom_config = LLMContextSummaryConfig(
target_context_tokens=500,
min_messages_after_summary=1,
)
await summarizer.process_frame(LLMSummarizeContextFrame(config=custom_config))
self.assertIsNotNone(request_frame)
# The request should use the overridden values
self.assertEqual(request_frame.target_context_tokens, 500)
self.assertEqual(request_frame.min_messages_to_keep, 1)
await summarizer.cleanup()
async def test_manual_summarization_blocked_when_in_progress(self):
"""Test that a second LLMSummarizeContextFrame is ignored while one is in progress."""
config = LLMAutoContextSummarizationConfig(max_context_tokens=100000)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# First manual request
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertEqual(request_count, 1)
# Second manual request while first is in progress — should be ignored
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertEqual(request_count, 1)
await summarizer.cleanup()
async def test_summary_message_role_is_user(self):
"""Test that the summary message uses the user role."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
# Simulate receiving a summary result
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="This is a test summary.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Find the summary message and verify its role is "user"
summary_msg = next(
(msg for msg in self.context.messages if "summary" in msg.get("content", "").lower()),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(summary_msg["role"], "user")
await summarizer.cleanup()
async def test_summary_message_default_template(self):
"""Test that the default summary_message_template is used."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Key facts from conversation.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Default template wraps with "Conversation summary: {summary}"
summary_msg = next(
(
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(
summary_msg["content"], "Conversation summary: Key facts from conversation."
)
await summarizer.cleanup()
async def test_summary_message_custom_template(self):
"""Test that a custom summary_message_template is applied."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(
min_messages_after_summary=2,
summary_message_template="<context_summary>\n{summary}\n</context_summary>",
),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Key facts from conversation.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Custom template wraps with XML tags
summary_msg = next(
(msg for msg in self.context.messages if "<context_summary>" in msg.get("content", "")),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(
summary_msg["content"],
"<context_summary>\nKey facts from conversation.\n</context_summary>",
)
await summarizer.cleanup()
async def test_on_summary_applied_event(self):
"""Test that on_summary_applied event fires with correct data."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages (1 system + 10 user = 11 total)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
applied_event = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
@summarizer.event_handler("on_summary_applied")
async def on_summary_applied(summarizer, event):
nonlocal applied_event
applied_event = event
original_count = len(self.context.messages) # 11
await summarizer.process_frame(LLMFullResponseStartFrame())
# Summarize up to index 7 (system=0, user1..user7), keep last 3 (user8, user9, user10)
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Test summary.",
last_summarized_index=7,
)
await summarizer.process_frame(summary_result)
# Allow async event handler to complete
await asyncio.sleep(0.05)
# Verify event was fired
self.assertIsNotNone(applied_event)
self.assertIsInstance(applied_event, SummaryAppliedEvent)
self.assertEqual(applied_event.original_message_count, original_count)
# After summarization: system + summary + 3 recent = 5
self.assertEqual(applied_event.new_message_count, 5)
# Summarized messages: indices 1-7 = 7 messages (excluding system at index 0)
self.assertEqual(applied_event.summarized_message_count, 7)
# Preserved: system (1) + recent messages after index 7 (3) = 4
self.assertEqual(applied_event.preserved_message_count, 4)
await summarizer.cleanup()
async def test_on_summary_applied_not_fired_on_error(self):
"""Test that on_summary_applied event is NOT fired when summarization fails."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
applied_event = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
@summarizer.event_handler("on_summary_applied")
async def on_summary_applied(summarizer, event):
nonlocal applied_event
applied_event = event
await summarizer.process_frame(LLMFullResponseStartFrame())
# Send a result with an error
error_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="",
last_summarized_index=-1,
error="Summarization timed out",
)
await summarizer.process_frame(error_result)
await asyncio.sleep(0.05)
# Event should NOT have fired
self.assertIsNone(applied_event)
await summarizer.cleanup()
async def test_request_frame_includes_timeout(self):
"""Test that the request frame includes the configured summarization_timeout."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(summarization_timeout=60.0),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message to add tokens."})
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
self.assertEqual(request_frame.summarization_timeout, 60.0)
await summarizer.cleanup()
async def test_token_limit_none_only_message_threshold(self):
"""Test that only message threshold triggers when token limit is None."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=None,
max_unsummarized_messages=5,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many tokens but fewer than 5 messages — should NOT trigger
for i in range(3):
self.context.add_message(
{"role": "user", "content": "x" * 10000} # Lots of tokens
)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNone(request_frame)
# Cross the message threshold (5 messages since summary = 6 total including system)
for i in range(3):
self.context.add_message({"role": "user", "content": f"Message {i}"})
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
await summarizer.cleanup()
async def test_message_limit_none_only_token_threshold(self):
"""Test that only token threshold triggers when message limit is None."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100, # Very low
max_unsummarized_messages=None,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many messages that exceed the token limit
for i in range(10):
self.context.add_message(
{"role": "user", "content": "This is a test message with enough tokens."}
)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
await summarizer.cleanup()
async def test_message_limit_none_no_trigger_below_tokens(self):
"""Test that many messages don't trigger when message limit is None and tokens are low."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # Very high
max_unsummarized_messages=None,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many short messages — would exceed any reasonable message count
# but tokens stay well below the limit
for i in range(50):
self.context.add_message({"role": "user", "content": f"Msg {i}"})
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should NOT trigger because token limit is not exceeded
self.assertIsNone(request_frame)
await summarizer.cleanup()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -134,3 +134,7 @@ class TestLLMFullResponseAggregator(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
assert completion_ok
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -244,3 +244,7 @@ class TestMarkdownTextFilter(unittest.IsolatedAsyncioTestCase):
"bold and italic",
"Text filtering should be re-enabled",
)
if __name__ == "__main__":
unittest.main()

67
tests/test_novita_llm.py Normal file
View File

@@ -0,0 +1,67 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for Novita LLM service."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.novita.llm import NovitaLLMService
@pytest.mark.asyncio
async def test_novita_llm_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
"""
with patch.object(NovitaLLMService, "create_client"):
service = NovitaLLMService(api_key="test-key", model="test-model")
service._client = AsyncMock()
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
raise asyncio.CancelledError()
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.choices = []
return mock_chunk
async def close(self):
nonlocal stream_closed
stream_closed = True
mock_stream = MockAsyncStream()
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
assert stream_closed, "Stream should be closed even when CancelledError occurs"

View File

@@ -0,0 +1,299 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for OpenAI LLM error handling."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from pipecat.frames.frames import (
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.llm import OpenAILLMService
@pytest.mark.asyncio
async def test_openai_llm_emits_error_frame_on_timeout():
"""Test that OpenAI LLM service emits ErrorFrame when a timeout occurs.
This enables LLMSwitcher to trigger failover to backup LLMs when the
primary LLM times out.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track pushed frames and errors
pushed_frames = []
pushed_errors = []
timeout_handler_called = False
original_push_frame = service.push_frame
async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pushed_frames.append(frame)
await original_push_frame(frame, direction)
async def mock_push_error(error_msg, exception=None):
pushed_errors.append({"error_msg": error_msg, "exception": exception})
async def mock_timeout_handler(event_name):
nonlocal timeout_handler_called
if event_name == "on_completion_timeout":
timeout_handler_called = True
service.push_frame = mock_push_frame
service.push_error = mock_push_error
service._call_event_handler = AsyncMock(side_effect=mock_timeout_handler)
# Mock _process_context to raise TimeoutException
service._process_context = AsyncMock(
side_effect=httpx.TimeoutException("Connection timed out")
)
# Mock metrics methods
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
# Create a context frame to process
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
# Process the frame
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify timeout handler was called
service._call_event_handler.assert_called_once_with("on_completion_timeout")
assert timeout_handler_called
# Verify push_error was called with correct message
assert len(pushed_errors) == 1
assert pushed_errors[0]["error_msg"] == "LLM completion timeout"
assert isinstance(pushed_errors[0]["exception"], httpx.TimeoutException)
# Verify LLMFullResponseStartFrame and LLMFullResponseEndFrame were pushed
frame_types = [type(f) for f in pushed_frames]
assert LLMFullResponseStartFrame in frame_types
assert LLMFullResponseEndFrame in frame_types
@pytest.mark.asyncio
async def test_openai_llm_timeout_still_pushes_end_frame():
"""Test that LLMFullResponseEndFrame is pushed even when timeout occurs.
The finally block should ensure proper cleanup regardless of timeout.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
pushed_frames = []
async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pushed_frames.append(frame)
service.push_frame = mock_push_frame
service.push_error = AsyncMock()
service._call_event_handler = AsyncMock()
service._process_context = AsyncMock(side_effect=httpx.TimeoutException("Timeout"))
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify both start and end frames are pushed
frame_types = [type(f) for f in pushed_frames]
assert LLMFullResponseStartFrame in frame_types
assert LLMFullResponseEndFrame in frame_types
# Verify metrics were stopped
service.stop_processing_metrics.assert_called_once()
@pytest.mark.asyncio
async def test_openai_llm_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3589.
"""
import asyncio
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track if close was called
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def close(self):
nonlocal stream_closed
stream_closed = True
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
# Simulate cancellation during iteration
raise asyncio.CancelledError()
# Return a minimal chunk for first iteration
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.model = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
# Mock the stream creation methods
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
# Process context should raise CancelledError but stream should still be closed
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
# Verify stream was closed despite the cancellation
assert stream_closed, "Stream should be closed even when CancelledError occurs"
@pytest.mark.asyncio
async def test_openai_llm_emits_error_frame_on_exception():
"""Test that OpenAI LLM service emits ErrorFrame when a general exception occurs.
This enables proper error handling for API errors, rate limits, and other failures.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
pushed_errors = []
async def mock_push_error(error_msg, exception=None):
pushed_errors.append({"error_msg": error_msg, "exception": exception})
service.push_frame = AsyncMock()
service.push_error = mock_push_error
service._call_event_handler = AsyncMock()
service._process_context = AsyncMock(side_effect=RuntimeError("API Error"))
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify push_error was called with correct message
assert len(pushed_errors) == 1
assert "Error during completion" in pushed_errors[0]["error_msg"]
assert "API Error" in pushed_errors[0]["error_msg"]
assert isinstance(pushed_errors[0]["exception"], RuntimeError)
@pytest.mark.asyncio
async def test_openai_llm_async_iterator_closed_on_stream_end():
"""Test that the async iterator is explicitly closed after stream consumption.
This prevents uvloop's broken asyncgen finalizer from firing on Python 3.12+
when async generators are garbage-collected without explicit cleanup.
See MagicStack/uvloop#699.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track if the iterator's aclose was called
iterator_aclosed = False
stream_closed = False
class MockAsyncIterator:
"""Mock async iterator that tracks aclose() calls."""
def __init__(self):
self.iteration_count = 0
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 2:
raise StopAsyncIteration()
# Return a minimal chunk
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.model = None
mock_chunk.choices = []
return mock_chunk
async def aclose(self):
nonlocal iterator_aclosed
iterator_aclosed = True
class MockAsyncStream:
"""Mock stream whose __aiter__ returns a separate iterator object."""
def __init__(self, iterator):
self._iterator = iterator
def __aiter__(self):
return self._iterator
async def close(self):
nonlocal stream_closed
stream_closed = True
mock_iterator = MockAsyncIterator()
mock_stream = MockAsyncStream(mock_iterator)
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
await service._process_context(context)
# Verify the iterator was explicitly closed (prevents uvloop crash)
assert iterator_aclosed, "Async iterator should be explicitly closed"
# Verify the stream was also closed (releases HTTP resources)
assert stream_closed, "Stream should be closed to release HTTP resources"

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -7,141 +7,253 @@
import unittest
from unittest.mock import AsyncMock
from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator
from pipecat.utils.text.pattern_pair_aggregator import (
MatchAction,
PatternMatch,
PatternPairAggregator,
)
class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.aggregator = PatternPairAggregator()
self.test_handler = AsyncMock()
self.code_handler = AsyncMock()
# Add a test pattern
self.aggregator.add_pattern_pair(
pattern_id="test_pattern",
start_pattern="<test>",
end_pattern="</test>",
remove_match=True,
)
self.aggregator.add_pattern(
type="code_pattern",
start_pattern="<code>",
end_pattern="</code>",
action=MatchAction.AGGREGATE,
)
# Register the mock handler
self.aggregator.on_pattern_match("test_pattern", self.test_handler)
self.aggregator.on_pattern_match("code_pattern", self.code_handler)
async def test_pattern_match_and_removal(self):
# First part doesn't complete the pattern
result = await self.aggregator.aggregate("Hello <test>pattern")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text, "Hello <test>pattern")
# Second part completes the pattern and includes an exclamation point
result = await self.aggregator.aggregate(" content</test>!")
text = "Hello <test>pattern content</test>!"
results = [result async for result in self.aggregator.aggregate(text)]
# Verify the handler was called with correct PatternMatch object
self.test_handler.assert_called_once()
call_args = self.test_handler.call_args[0][0]
self.assertIsInstance(call_args, PatternMatch)
self.assertEqual(call_args.pattern_id, "test_pattern")
self.assertEqual(call_args.type, "test_pattern")
self.assertEqual(call_args.full_match, "<test>pattern content</test>")
self.assertEqual(call_args.content, "pattern content")
self.assertEqual(call_args.text, "pattern content")
# The exclamation point should be treated as a sentence boundary,
# so the result should include just text up to and including "!"
self.assertEqual(result, "Hello !")
# No results yet (waiting for lookahead after "!")
self.assertEqual(len(results), 0)
# Next sentence should be processed separately
result = await self.aggregator.aggregate(" This is another sentence.")
self.assertEqual(result, " This is another sentence.")
# Next sentence should provide the lookahead and trigger the previous sentence
async for result in self.aggregator.aggregate(" This is another sentence."):
results.append(result)
# First result should be "Hello !" triggered by the space lookahead
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hello !")
self.assertEqual(results[0].type, "sentence")
# Now flush to get the remaining sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "This is another sentence.")
# Buffer should be empty after returning a complete sentence
self.assertEqual(self.aggregator.text, "")
self.assertEqual(self.aggregator.text.text, "")
async def test_pattern_match_and_aggregate(self):
text = "Here is code <code>pattern content</code> This is another sentence."
results = [result async for result in self.aggregator.aggregate(text)]
# First result should be "Here is code" when pattern starts
self.assertEqual(results[0].text, "Here is code")
self.assertEqual(results[0].type, "sentence")
# Second result should be the code pattern content
self.assertEqual(results[1].text, "pattern content")
self.assertEqual(results[1].type, "code_pattern")
# Verify the handler was called with correct PatternMatch object
self.code_handler.assert_called_once()
call_args = self.code_handler.call_args[0][0]
self.assertIsInstance(call_args, PatternMatch)
self.assertEqual(call_args.type, "code_pattern")
self.assertEqual(call_args.full_match, "<code>pattern content</code>")
self.assertEqual(call_args.text, "pattern content")
# Last sentence needs flush (waiting for lookahead after ".")
result = await self.aggregator.flush()
self.assertEqual(result.text, "This is another sentence.")
self.assertEqual(result.type, "sentence")
# Buffer should be empty after returning a complete sentence
self.assertEqual(self.aggregator.text.text, "")
async def test_incomplete_pattern(self):
# Add text with incomplete pattern
result = await self.aggregator.aggregate("Hello <test>pattern content")
text = "Hello <test>pattern content"
results = [result async for result in self.aggregator.aggregate(text)]
# No complete pattern yet, so nothing should be returned
self.assertIsNone(result)
self.assertEqual(len(results), 0)
# The handler should not be called yet
self.test_handler.assert_not_called()
# Buffer should contain the incomplete text
self.assertEqual(self.aggregator.text, "Hello <test>pattern content")
self.assertEqual(self.aggregator.text.text, "Hello <test>pattern content")
self.assertEqual(self.aggregator.text.type, "test_pattern")
# Reset and confirm buffer is cleared
await self.aggregator.reset()
self.assertEqual(self.aggregator.text, "")
self.assertEqual(self.aggregator.text.text, "")
async def test_multiple_patterns(self):
# Set up multiple patterns and handlers
voice_handler = AsyncMock()
emphasis_handler = AsyncMock()
self.aggregator.add_pattern_pair(
pattern_id="voice", start_pattern="<voice>", end_pattern="</voice>", remove_match=True
self.aggregator.add_pattern(
type="voice",
start_pattern="<voice>",
end_pattern="</voice>",
action=MatchAction.REMOVE,
)
self.aggregator.add_pattern_pair(
pattern_id="emphasis",
self.aggregator.add_pattern(
type="emphasis",
start_pattern="<em>",
end_pattern="</em>",
remove_match=False, # Keep emphasis tags
action=MatchAction.KEEP, # Keep emphasis tags
)
self.aggregator.on_pattern_match("voice", voice_handler)
self.aggregator.on_pattern_match("emphasis", emphasis_handler)
# Test with multiple patterns in one text block
text = "Hello <voice>female</voice> I am <em>very</em> excited to meet you!"
result = await self.aggregator.aggregate(text)
results = [result async for result in self.aggregator.aggregate(text)]
# Both handlers should be called with correct data
voice_handler.assert_called_once()
voice_match = voice_handler.call_args[0][0]
self.assertEqual(voice_match.pattern_id, "voice")
self.assertEqual(voice_match.content, "female")
self.assertEqual(voice_match.type, "voice")
self.assertEqual(voice_match.text, "female")
emphasis_handler.assert_called_once()
emphasis_match = emphasis_handler.call_args[0][0]
self.assertEqual(emphasis_match.pattern_id, "emphasis")
self.assertEqual(emphasis_match.content, "very")
self.assertEqual(emphasis_match.type, "emphasis")
self.assertEqual(emphasis_match.text, "very")
# With lookahead, we need to flush to get the final sentence
self.assertEqual(len(results), 0) # Waiting for lookahead after "!"
result = await self.aggregator.flush()
# Voice pattern should be removed, emphasis pattern should remain
self.assertEqual(result, "Hello I am <em>very</em> excited to meet you!")
self.assertEqual(result.text, "Hello I am <em>very</em> excited to meet you!")
# Buffer should be empty
self.assertEqual(self.aggregator.text, "")
self.assertEqual(self.aggregator.text.text, "")
async def test_handle_interruption(self):
# Start with incomplete pattern
result = await self.aggregator.aggregate("Hello <test>pattern")
self.assertIsNone(result)
text = "Hello <test>pattern"
results = [result async for result in self.aggregator.aggregate(text)]
self.assertEqual(len(results), 0)
# Simulate interruption
await self.aggregator.handle_interruption()
# Buffer should be cleared
self.assertEqual(self.aggregator.text, "")
self.assertEqual(self.aggregator.text.text, "")
# Handler should not have been called
self.test_handler.assert_not_called()
async def test_pattern_across_sentences(self):
# Test pattern that spans multiple sentences
result = await self.aggregator.aggregate("Hello <test>This is sentence one.")
# First sentence contains start of pattern but no end, so no complete pattern yet
self.assertIsNone(result)
# Add second part with pattern end
result = await self.aggregator.aggregate(" This is sentence two.</test> Final sentence.")
text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence."
results = [result async for result in self.aggregator.aggregate(text)]
# Handler should be called with entire content
self.test_handler.assert_called_once()
call_args = self.test_handler.call_args[0][0]
self.assertEqual(call_args.content, "This is sentence one. This is sentence two.")
self.assertEqual(call_args.text, "This is sentence one. This is sentence two.")
# With lookahead, we need to flush to get the final sentence
self.assertEqual(len(results), 0) # Waiting for lookahead after "."
result = await self.aggregator.flush()
# Pattern should be removed, resulting in text with sentences merged
self.assertEqual(result, "Hello Final sentence.")
self.assertEqual(result.text, "Hello Final sentence.")
# Buffer should be empty
self.assertEqual(self.aggregator.text, "")
self.assertEqual(self.aggregator.text.text, "")
class TestPatternPairAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = PatternPairAggregator(aggregation_type=AggregationType.TOKEN)
self.handler = AsyncMock()
self.aggregator.add_pattern(
type="think",
start_pattern="<think>",
end_pattern="</think>",
action=MatchAction.REMOVE,
)
self.aggregator.on_pattern_match("think", self.handler)
async def test_token_no_patterns(self):
"""Non-pattern text passes through as TOKEN, one per aggregate call."""
results = []
for token in ["Hello", " world", "."]:
async for r in self.aggregator.aggregate(token):
results.append(r)
self.assertEqual(len(results), 3)
self.assertEqual(results[0].text, "Hello")
self.assertEqual(results[1].text, " world")
self.assertEqual(results[2].text, ".")
for r in results:
self.assertEqual(r.type, "token")
async def test_token_pattern_detection(self):
"""Pattern detection still works with word-by-word token delivery."""
results = []
for token in ["Hi ", "<think>", "secret", "</think>", " bye"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Handler called once when the pattern completes
self.handler.assert_called_once()
call_args = self.handler.call_args[0][0]
self.assertEqual(call_args.text, "secret")
# "Hi " yields before pattern starts, pattern is removed, " bye" yields after
self.assertEqual(len(results), 2)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.assertEqual(results[1].text, " bye")
self.assertEqual(results[1].type, "token")
async def test_token_incomplete_pattern_buffers(self):
"""Incomplete pattern is buffered across calls, not leaked to output."""
results = []
for token in ["Hi ", "<think>", "partial"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Only "Hi " should be yielded; "<think>partial" stays buffered
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.handler.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -99,6 +99,34 @@ class TestParallelPipeline(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
async def test_parallel_internal_frames_buffered_during_start(self):
"""Frames pushed by internal processors during StartFrame processing
should be buffered and only released after StartFrame synchronization
completes."""
class EmitOnStartProcessor(FrameProcessor):
"""Pushes a TextFrame when it receives a StartFrame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, StartFrame):
await self.push_frame(TextFrame(text="from start"))
pipeline = ParallelPipeline([EmitOnStartProcessor()], [IdentityFilter()])
frames_to_send = [TextFrame(text="Hello!")]
# StartFrame should come first, then the TextFrame emitted during
# StartFrame processing, then the regular TextFrame.
expected_down_frames = [StartFrame, TextFrame, TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
ignore_start=False,
)
class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
async def test_task_single(self):
@@ -267,6 +295,63 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
assert upstream_received
assert downstream_received
async def test_task_queue_frame_upstream(self):
upstream_received = False
pipeline = Pipeline([IdentityFilter()])
task = PipelineTask(pipeline, cancel_on_idle_timeout=False)
task.set_reached_upstream_filter((TextFrame,))
@task.event_handler("on_frame_reached_upstream")
async def on_frame_reached_upstream(task, frame):
nonlocal upstream_received
if isinstance(frame, TextFrame) and frame.text == "Hello Upstream!":
upstream_received = True
@task.event_handler("on_pipeline_started")
async def on_pipeline_started(task, frame):
await task.queue_frame(TextFrame(text="Hello Upstream!"), FrameDirection.UPSTREAM)
try:
await asyncio.wait_for(
task.run(PipelineTaskParams(loop=asyncio.get_event_loop())),
timeout=1.0,
)
except asyncio.TimeoutError:
pass
assert upstream_received
async def test_task_queue_frames_upstream(self):
upstream_texts = []
pipeline = Pipeline([IdentityFilter()])
task = PipelineTask(pipeline, cancel_on_idle_timeout=False)
task.set_reached_upstream_filter((TextFrame,))
@task.event_handler("on_frame_reached_upstream")
async def on_frame_reached_upstream(task, frame):
if isinstance(frame, TextFrame):
upstream_texts.append(frame.text)
@task.event_handler("on_pipeline_started")
async def on_pipeline_started(task, frame):
await task.queue_frames(
[TextFrame(text="First"), TextFrame(text="Second")],
FrameDirection.UPSTREAM,
)
try:
await asyncio.wait_for(
task.run(PipelineTaskParams(loop=asyncio.get_event_loop())),
timeout=1.0,
)
except asyncio.TimeoutError:
pass
assert "First" in upstream_texts
assert "Second" in upstream_texts
async def test_task_heartbeats(self):
heartbeats_counter = 0
@@ -570,3 +655,7 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
await task.run(PipelineTaskParams(loop=asyncio.get_event_loop()))
except asyncio.CancelledError:
assert error_received
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -7,12 +7,14 @@
"""Tests for PiperTTSService."""
import asyncio
import unittest
import aiohttp
import pytest
from aiohttp import web
from pipecat.frames.frames import (
AggregatedTextFrame,
ErrorFrame,
TTSAudioRawFrame,
TTSSpeakFrame,
@@ -20,7 +22,7 @@ from pipecat.frames.frames import (
TTSStoppedFrame,
TTSTextFrame,
)
from pipecat.services.piper.tts import PiperTTSService
from pipecat.services.piper.tts import PiperHttpTTSService
from pipecat.tests.utils import run_test
@@ -66,34 +68,45 @@ async def test_run_piper_tts_success(aiohttp_client):
base_url = str(client.make_url("")).rstrip("/")
async with aiohttp.ClientSession() as session:
# Instantiate PiperTTSService with our mock server
tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000)
# Instantiate PiperHttpTTSService with our mock server
tts_service = PiperHttpTTSService(
base_url=base_url, aiohttp_session=session, sample_rate=24000
)
frames_to_send = [
TTSSpeakFrame(text="Hello world."),
]
expected_returned_frames = [
TTSStartedFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSStoppedFrame,
TTSTextFrame,
]
frames_received = await run_test(
tts_service,
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
down_frames = frames_received[0]
frame_types = [type(f) for f in down_frames]
# Verify key frames are present
assert AggregatedTextFrame in frame_types
assert TTSStartedFrame in frame_types
assert TTSStoppedFrame in frame_types
assert TTSTextFrame in frame_types
# Verify ordering: Started → audio → Stopped → Text
started_idx = frame_types.index(TTSStartedFrame)
stopped_idx = frame_types.index(TTSStoppedFrame)
text_idx = frame_types.index(TTSTextFrame)
assert started_idx < text_idx < stopped_idx, (
"Expected: TTSStartedFrame < TTSTextFrame < TTSStoppedFrame"
)
# Frames between Started and Stopped must all be audio or text
for i in range(started_idx + 1, stopped_idx):
assert frame_types[i] in (TTSAudioRawFrame, TTSTextFrame), (
f"Unexpected frame type between Started and Stopped: {frame_types[i]}"
)
# All audio frames have correct sample rate
audio_frames = [f for f in down_frames if isinstance(f, TTSAudioRawFrame)]
assert len(audio_frames) >= 1, "Expected at least one audio frame"
for a_frame in audio_frames:
assert a_frame.sample_rate == 24000, "Sample rate should match the default (24000)"
@@ -115,13 +128,15 @@ async def test_run_piper_tts_error(aiohttp_client):
base_url = str(client.make_url("")).rstrip("/")
async with aiohttp.ClientSession() as session:
tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000)
tts_service = PiperHttpTTSService(
base_url=base_url, aiohttp_session=session, sample_rate=24000
)
frames_to_send = [
TTSSpeakFrame(text="Error case."),
TTSSpeakFrame(text="Error case.", append_to_context=False),
]
expected_down_frames = [TTSStoppedFrame, TTSTextFrame]
expected_down_frames = [AggregatedTextFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame]
expected_up_frames = [ErrorFrame]
@@ -137,3 +152,7 @@ async def test_run_piper_tts_error(aiohttp_client):
assert "status: 404" in up_frames[0].error, (
"ErrorFrame should contain details about the 404"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -118,3 +118,7 @@ class TestProducerConsumerProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -21,7 +21,7 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase):
async def test_roundtrip(self):
text_frame = TextFrame(text="hello world")
frame = await self.serializer.deserialize(await self.serializer.serialize(text_frame))
self.assertEqual(text_frame, frame)
self.assertEqual(frame.text, text_frame.text)
transcription_frame = TranscriptionFrame(
text="Hello there!", user_id="123", timestamp="2021-01-01"
@@ -29,10 +29,16 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase):
frame = await self.serializer.deserialize(
await self.serializer.serialize(transcription_frame)
)
self.assertEqual(frame, transcription_frame)
self.assertEqual(frame.text, transcription_frame.text)
self.assertEqual(frame.user_id, transcription_frame.user_id)
self.assertEqual(frame.timestamp, transcription_frame.timestamp)
audio_frame = OutputAudioRawFrame(audio=b"1234567890", sample_rate=16000, num_channels=1)
frame = await self.serializer.deserialize(await self.serializer.serialize(audio_frame))
self.assertEqual(frame.audio, audio_frame.audio)
self.assertEqual(frame.sample_rate, audio_frame.sample_rate)
self.assertEqual(frame.num_channels, audio_frame.num_channels)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,177 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
import numpy as np
try:
import pyrnnoise
except ImportError:
pyrnnoise = None
from pipecat.audio.filters.rnnoise_filter import RNNoiseFilter
from pipecat.frames.frames import FilterEnableFrame
class TestRNNoiseCancellation(unittest.IsolatedAsyncioTestCase):
async def test_rnnoise_cancellation_functionality(self):
print("\nStarting Noise Cancellation Test")
# 1. Check for pyrnnoise
if pyrnnoise is None:
self.skipTest("pyrnnoise not installed. Cannot verify actual noise cancellation.")
# 2. Generate clean speech-like audio (Harmonic series)
sample_rate = 48000
duration = 2.0
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
# Fundamental 200Hz + harmonics
clean_signal = np.sin(2 * np.pi * 200 * t) * 0.5
clean_signal += np.sin(2 * np.pi * 400 * t) * 0.3
clean_signal += np.sin(2 * np.pi * 600 * t) * 0.2
# Apply envelope to simulate speech (bursts)
# sin(2*pi*2*t) has period 0.5s.
envelope = np.sin(2 * np.pi * 2 * t)
envelope = np.clip(envelope, 0, 1)
clean_signal *= envelope
# 3. Add Noise (White Noise)
noise_level = 0.1 # Reduced noise level slightly to make speech clearer for alignment
noise = np.random.normal(0, noise_level, len(t))
noisy_signal = clean_signal + noise
# Normalize to int16 range
noisy_signal = np.clip(noisy_signal, -1, 1)
noisy_int16 = (noisy_signal * 32767).astype(np.int16)
noisy_bytes = noisy_int16.tobytes()
clean_int16 = (clean_signal * 32767).astype(np.int16)
print(f"Generated 2s of noisy audio at {sample_rate}Hz")
# 4. Initialize RNNoiseFilter
rnnoise_filter = RNNoiseFilter()
await rnnoise_filter.start(sample_rate)
await rnnoise_filter.process_frame(FilterEnableFrame(enable=True))
# 5. Process
# Feed in chunks
chunk_size = 960 # 20ms
processed_audio = b""
for i in range(0, len(noisy_bytes), chunk_size):
chunk = noisy_bytes[i : i + chunk_size]
result = await rnnoise_filter.filter(chunk)
processed_audio += result
await rnnoise_filter.stop()
print(f"Output audio size: {len(processed_audio)}")
# 6. Verify Noise Reduction
output_int16 = np.frombuffer(processed_audio, dtype=np.int16)
# Truncate to min length
min_len = min(len(clean_int16), len(output_int16))
clean_trunc = clean_int16[:min_len]
output_trunc = output_int16[:min_len]
noisy_trunc = noisy_int16[:min_len]
# 7. Compensate for Delay
# Use cross-correlation on a segment to find delay
# We expect output to be delayed relative to clean (lag is positive)
# search window +/- 2000 samples (~40ms)
search_range = 2400 # 50ms
# Use the middle of the signal to avoid edge effects and have strong signal
mid_point = min_len // 2
window_len = 4800 # 100ms
ref_sig = clean_trunc[mid_point : mid_point + window_len].astype(float)
target_sig = output_trunc[
mid_point - search_range : mid_point + window_len + search_range
].astype(float)
correlation = np.correlate(target_sig, ref_sig, mode="valid")
best_idx = np.argmax(correlation)
# The 'valid' mode correlation result corresponds to shifts.
# index 0 matches alignment where ref starts at target start.
# target start is (mid_point - search_range).
# ref start is mid_point.
# So index 0 means target is shifted left by search_range (or delay = -search_range).
# delay = best_idx - search_range
delay = best_idx - search_range
print(f"Detected delay: {delay} samples ({delay / sample_rate * 1000:.2f} ms)")
# Shift output to align
if delay > 0:
# Output is delayed, so we need to look at output[delay:] to match clean[0:]
aligned_output = output_trunc[delay:]
aligned_clean = clean_trunc[: len(aligned_output)]
aligned_noisy = noisy_trunc[: len(aligned_output)]
elif delay < 0:
# Output is ahead (unlikely for causal filter), but handling it
aligned_output = output_trunc[:delay]
aligned_clean = clean_trunc[-delay:]
aligned_noisy = noisy_trunc[-delay:]
else:
aligned_output = output_trunc
aligned_clean = clean_trunc
aligned_noisy = noisy_trunc
# Recalculate MSE on aligned signals
mse_input = np.mean((aligned_noisy.astype(float) - aligned_clean.astype(float)) ** 2)
mse_output = np.mean((aligned_output.astype(float) - aligned_clean.astype(float)) ** 2)
print(f"MSE (Input vs Clean): {mse_input:.2f}")
print(f"MSE (Output vs Clean): {mse_output:.2f}")
# Also check noise reduction in silent regions
# Clean signal envelope is 0 at t=0, 0.25, 0.5...
# Let's find indices where aligned_clean is very small
threshold = 100 # amplitude threshold (out of 32767)
silent_mask = np.abs(aligned_clean) < threshold
if np.sum(silent_mask) > 1000:
noise_power_input = np.mean(aligned_noisy[silent_mask].astype(float) ** 2)
noise_power_output = np.mean(aligned_output[silent_mask].astype(float) ** 2)
print(f"Noise Power in Silence (Input): {noise_power_input:.2f}")
print(f"Noise Power in Silence (Output): {noise_power_output:.2f}")
self.assertLess(
noise_power_output, noise_power_input, "Noise power in silence not reduced"
)
else:
print("Warning: Not enough silent samples found for noise floor check.")
# Main assertion: MSE should improve
# Relax assertion slightly because RNNoise introduces distortion even on clean speech
# But for noisy speech, it should generally be better or at least remove noise.
# If MSE doesn't improve (due to speech distortion), at least Noise Power in Silence should drop.
if mse_output >= mse_input:
print(
"Warning: Overall MSE did not improve (speech distortion?). Relying on Noise Power check."
)
# If we passed the noise power check above, we are good.
self.assertTrue(
np.sum(silent_mask) > 1000
and np.mean(aligned_output[silent_mask].astype(float) ** 2)
< np.mean(aligned_noisy[silent_mask].astype(float) ** 2)
)
else:
self.assertLess(mse_output, mse_input, "MSE did not improve")
print("Test Passed: Noise cancellation verified.")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,149 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from unittest.mock import AsyncMock
import numpy as np
try:
import pyrnnoise
except ImportError:
pyrnnoise = None
from pipecat.audio.filters.rnnoise_filter import RNNoiseFilter
from pipecat.frames.frames import FilterEnableFrame
class TestRNNoiseFilter(unittest.IsolatedAsyncioTestCase):
def setUp(self):
if pyrnnoise is None:
self.skipTest("pyrnnoise not installed")
async def test_rnnoise_filter_reduces_noise(self):
"""Test that RNNoise filter reduces noise in audio."""
filter = RNNoiseFilter()
# Initialize with 48kHz sample rate (RNNoise requirement)
await filter.start(sample_rate=48000)
# Create noisy audio: clean signal + noise
# Generate a simple sine wave as clean signal
# Need at least 480 samples (one frame) for processing
duration = 0.02 # 20ms = 960 samples at 48kHz (2 frames)
sample_rate = 48000
t = np.linspace(0, duration, int(sample_rate * duration), False)
frequency = 440.0 # A4 note
clean_signal = np.sin(2 * np.pi * frequency * t)
# Add white noise
noise = np.random.normal(0, 0.3, clean_signal.shape)
noisy_signal = clean_signal + noise
# Convert to int16 format
noisy_audio_int16 = (noisy_signal * 32767).astype(np.int16)
noisy_audio_bytes = noisy_audio_int16.tobytes()
# Process through filter
filtered_audio_bytes = await filter.filter(noisy_audio_bytes)
# Convert back to numpy array for comparison
filtered_audio = np.frombuffer(filtered_audio_bytes, dtype=np.int16)
# Verify output is not empty (should have at least one processed frame)
self.assertGreater(len(filtered_audio), 0)
# Verify the filtered audio is different from input (noise reduction occurred)
# The filtered audio should have less variance/noise
self.assertIsNotNone(filtered_audio_bytes)
await filter.stop()
async def test_rnnoise_filter_passthrough_when_disabled(self):
"""Test that RNNoise filter passes through audio when disabled."""
filter = RNNoiseFilter()
await filter.start(sample_rate=48000)
# Disable filtering
await filter.process_frame(FilterEnableFrame(enable=False))
# Create test audio
test_audio = np.random.randint(-32768, 32767, 480, dtype=np.int16).tobytes()
# Process through filter
filtered_audio = await filter.filter(test_audio)
# Should pass through unchanged when disabled
self.assertEqual(filtered_audio, test_audio)
await filter.stop()
async def test_rnnoise_filter_buffering(self):
"""Test that RNNoise filter properly buffers incomplete frames."""
filter = RNNoiseFilter()
await filter.start(sample_rate=48000)
# Send a small chunk that's less than a full frame (480 samples)
small_chunk = np.random.randint(-32768, 32767, 100, dtype=np.int16).tobytes()
# First call should return empty (buffering, not enough for a frame)
result1 = await filter.filter(small_chunk)
self.assertEqual(result1, b"")
# Send more data to complete a frame (100 + 500 = 600 samples > 480)
more_data = np.random.randint(-32768, 32767, 500, dtype=np.int16).tobytes()
result2 = await filter.filter(more_data)
# Should return processed audio for at least one complete frame
self.assertGreater(len(result2), 0)
await filter.stop()
async def test_rnnoise_filter_handles_empty_resampler_output(self):
"""Test that RNNoise filter handles empty bytes from resampler gracefully.
This reproduces the issue where mute/silence input (like "bx000") causes
the resampler to return empty bytes. The filter should handle this gracefully
without raising a memory error.
"""
filter = RNNoiseFilter()
# Initialize with a sample rate that requires resampling (not 48kHz)
await filter.start(sample_rate=16000)
# Enable filtering
await filter.process_frame(FilterEnableFrame(enable=True))
# Create mute/silence audio chunk (like "bx000" - all zeros)
# This simulates a mute sound that causes resampler to return empty bytes
mute_audio = b"\x00\x00" * 160 # 160 samples of silence at 16kHz (10ms)
# Mock the resampler to return empty bytes when given mute input
# This simulates the behavior where resampler returns b"" for mute sounds
async def mock_resample(audio, in_rate, out_rate):
# If input is all zeros (mute), return empty bytes
# This simulates the real-world scenario where resampler returns b"" for silence
audio_data = np.frombuffer(audio, dtype=np.int16)
if len(audio_data) == 0 or np.all(audio_data == 0):
return b"" # Return empty bytes - this triggers the bug
# Otherwise, do normal resampling
return audio
filter._resampler_in.resample = AsyncMock(side_effect=mock_resample)
result = await filter.filter(mute_audio)
# When resampler returns empty bytes, filter should return empty bytes
# (or handle it gracefully without crashing)
self.assertEqual(
result, b"", "Filter should return empty bytes when resampler returns empty bytes"
)
await filter.stop()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,138 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import sys
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
# We don't need to mock sys.modules here if we use patch on the imported module member
# But we need to ensure RNNoiseFilter is imported so we can patch its member
try:
from pipecat.audio.filters.rnnoise_filter import RNNoiseFilter
from pipecat.frames.frames import FilterEnableFrame
except ImportError as e:
# If dependencies are missing (like numpy?), we can't test
print(f"Failed to import RNNoiseFilter: {e}")
sys.exit(1)
class TestRNNoiseResampling(unittest.IsolatedAsyncioTestCase):
@patch("pipecat.audio.filters.rnnoise_filter.RNNoise")
async def test_rnnoise_resampling_16k_to_48k_and_back(self, mock_rnnoise_class):
print("\nStarting Resampling Test: 16kHz -> 48kHz -> 16kHz")
# Configure Mock with buffering behavior
processed_chunks_count = 0
buffer = np.array([], dtype=np.int16)
def side_effect_process_chunk(audio_samples, partial=False):
nonlocal buffer, processed_chunks_count
# Append new samples to buffer
if len(audio_samples) > 0:
buffer = np.concatenate((buffer, audio_samples))
# Yield 480-sample chunks
while len(buffer) >= 480:
chunk = buffer[:480]
buffer = buffer[480:]
processed_chunks_count += 1
# Simulate processing (pass through)
# Convert int16 -> float32 [-1, 1]
normalized = chunk.astype(np.float32) / 32768.0
yield 0.99, normalized
mock_rnnoise_instance = MagicMock()
mock_rnnoise_instance.denoise_chunk.side_effect = side_effect_process_chunk
mock_rnnoise_class.return_value = mock_rnnoise_instance
# 1. Generate 1 second of 16kHz audio (sine wave 440Hz)
sample_rate = 16000
duration = 1.0
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
audio_data = (np.sin(2 * np.pi * 440 * t) * 32767).astype(np.int16)
audio_bytes = audio_data.tobytes()
print(
f"Input audio: {len(audio_bytes)} bytes, {len(audio_data)} samples at {sample_rate}Hz"
)
# 2. Initialize RNNoiseFilter
# This will use the patched RNNoise
rnnoise_filter = RNNoiseFilter()
await rnnoise_filter.start(sample_rate)
# Enable filtering
await rnnoise_filter.process_frame(FilterEnableFrame(enable=True))
# 3. Process audio in chunks
chunk_size = 320 # 160 samples (10ms at 16k) * 2 bytes
processed_audio = b""
for i in range(0, len(audio_bytes), chunk_size):
chunk = audio_bytes[i : i + chunk_size]
result = await rnnoise_filter.filter(chunk)
processed_audio += result
await rnnoise_filter.stop()
print(f"Output audio: {len(processed_audio)} bytes")
print(f"Processed chunks (internal 480 samples): {processed_chunks_count}")
# 4. Verify output length
# Expect roughly same length
expected_chunks = (len(audio_data) * 48000 // sample_rate) // 480
print(f"Expected chunks: ~{expected_chunks}")
# Check that we actually processed something
self.assertGreaterEqual(
processed_chunks_count, expected_chunks - 5, "Too few chunks processed"
)
# Check output length
self.assertGreater(len(processed_audio), 0, "Output should not be empty")
# Check length matches input (with some tolerance for buffering latency)
# 100ms tolerance?
byte_tolerance = int(0.2 * sample_rate * 2)
self.assertGreaterEqual(
len(processed_audio),
len(audio_bytes) - byte_tolerance,
f"Output too short: {len(processed_audio)} vs {len(audio_bytes)}",
)
self.assertLessEqual(
len(processed_audio),
len(audio_bytes) + byte_tolerance,
f"Output too long: {len(processed_audio)} vs {len(audio_bytes)}",
)
# 5. Check sample rate / pitch preservation
output_data = np.frombuffer(processed_audio, dtype=np.int16)
if len(output_data) > 2000:
# Use a window in the middle
start_idx = len(output_data) // 4
end_idx = 3 * len(output_data) // 4
segment = output_data[start_idx:end_idx]
fft = np.fft.rfft(segment)
freqs = np.fft.rfftfreq(len(segment), d=1 / sample_rate)
peak_idx = np.argmax(np.abs(fft))
peak_freq = freqs[peak_idx]
print(f"Peak frequency: {peak_freq:.2f} Hz")
self.assertLess(
abs(peak_freq - 440), 50, f"Frequency shifted significantly: {peak_freq} vs 440"
)
print("Test Passed: Resampling logic verified (with mocked RNNoise).")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -14,14 +20,20 @@ from pipecat.services.anthropic.llm import AnthropicLLMService
from pipecat.services.aws.llm import AWSBedrockLLMService
from pipecat.services.google.llm import GoogleLLMService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService
@pytest.mark.asyncio
async def test_openai_run_inference_with_llm_context():
"""Test run_inference with LLMContext returns expected response."""
# Create service with mocked client
# Create service with mocked client and specific parameters
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
from pipecat.services.openai.base_llm import BaseOpenAILLMService
params = BaseOpenAILLMService.InputParams(
temperature=0.7, max_tokens=100, frequency_penalty=0.5, seed=42
)
service = OpenAILLMService(model="gpt-4", params=params)
service._client = AsyncMock()
# Setup mocks
@@ -48,11 +60,79 @@ async def test_openai_run_inference_with_llm_context():
# Verify
assert result == "Hello! How can I help you today?"
service.get_llm_adapter.assert_called_once()
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
# convert_developer_to_user=False because OpenAILLMService.supports_developer_role is True
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, system_instruction=None, convert_developer_to_user=False
)
service._client.chat.completions.create.assert_called_once_with(
model="gpt-4",
messages=test_messages,
stream=False,
frequency_penalty=0.5,
presence_penalty=OPENAI_NOT_GIVEN,
seed=42,
temperature=0.7,
top_p=OPENAI_NOT_GIVEN,
max_tokens=100,
max_completion_tokens=OPENAI_NOT_GIVEN,
service_tier=OPENAI_NOT_GIVEN,
messages=test_messages,
tools=OPENAI_NOT_GIVEN,
tool_choice=OPENAI_NOT_GIVEN,
)
@pytest.mark.asyncio
async def test_openai_run_inference_with_openai_llm_context():
"""Test run_inference with OpenAILLMContext returns expected response."""
# Create service with mocked client and specific parameters
with patch.object(OpenAILLMService, "create_client"):
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.openai.base_llm import BaseOpenAILLMService
params = BaseOpenAILLMService.InputParams(
temperature=0.8, max_completion_tokens=150, presence_penalty=0.3, top_p=0.9
)
service = OpenAILLMService(model="gpt-4", params=params)
service._client = AsyncMock()
# Create OpenAILLMContext
context = OpenAILLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello, world!"},
],
tools=OPENAI_NOT_GIVEN,
tool_choice=OPENAI_NOT_GIVEN,
)
# Mock response
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Hello! How can I help you today?"
service._client.chat.completions.create.return_value = mock_response
# Execute
result = await service.run_inference(context)
# Verify
assert result == "Hello! How can I help you today?"
service._client.chat.completions.create.assert_called_once_with(
model="gpt-4",
stream=False,
frequency_penalty=OPENAI_NOT_GIVEN,
presence_penalty=0.3,
seed=OPENAI_NOT_GIVEN,
temperature=0.8,
top_p=0.9,
max_tokens=OPENAI_NOT_GIVEN,
max_completion_tokens=150,
service_tier=OPENAI_NOT_GIVEN,
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello, world!"},
],
tools=OPENAI_NOT_GIVEN,
tool_choice=OPENAI_NOT_GIVEN,
)
@@ -78,8 +158,13 @@ async def test_openai_run_inference_client_exception():
@pytest.mark.asyncio
async def test_anthropic_run_inference_with_llm_context():
"""Test run_inference with LLMContext returns expected response for Anthropic."""
# Create service with mocked client
service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229")
# Create service with mocked client and specific parameters
from pipecat.services.anthropic.llm import AnthropicLLMService
params = AnthropicLLMService.InputParams(max_tokens=2048, temperature=0.6, top_k=50, top_p=0.95)
service = AnthropicLLMService(
api_key="test-key", model="claude-3-sonnet-20240229", params=params
)
service._client = AsyncMock()
# Setup mocks
@@ -96,7 +181,7 @@ async def test_anthropic_run_inference_with_llm_context():
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Hello! How can I help you today?"
service._client.messages.create.return_value = mock_response
service._client.beta.messages.create.return_value = mock_response
# Execute
result = await service.run_inference(mock_context)
@@ -105,14 +190,67 @@ async def test_anthropic_run_inference_with_llm_context():
assert result == "Hello! How can I help you today?"
service.get_llm_adapter.assert_called_once()
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, enable_prompt_caching=False
mock_context, enable_prompt_caching=False, system_instruction=None
)
service._client.messages.create.assert_called_once_with(
service._client.beta.messages.create.assert_called_once_with(
model="claude-3-sonnet-20240229",
max_tokens=2048,
stream=False,
temperature=0.6,
top_k=50,
top_p=0.95,
messages=test_messages,
system=test_system,
max_tokens=8192,
tools=[],
betas=["interleaved-thinking-2025-05-14"],
)
@pytest.mark.asyncio
async def test_anthropic_run_inference_with_openai_llm_context():
"""Test run_inference with OpenAILLMContext returns expected response for Anthropic."""
# Create service with mocked client and specific parameters
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.anthropic.llm import AnthropicLLMService
params = AnthropicLLMService.InputParams(max_tokens=1024, temperature=0.7, top_k=40, top_p=0.9)
service = AnthropicLLMService(
api_key="test-key", model="claude-3-sonnet-20240229", params=params
)
service._client = AsyncMock()
# Create OpenAILLMContext
context = OpenAILLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello, world!"},
],
tools=NOT_GIVEN,
tool_choice=NOT_GIVEN,
)
# Mock response
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Hello! How can I help you today?"
service._client.beta.messages.create.return_value = mock_response
# Execute
result = await service.run_inference(context)
# Verify
assert result == "Hello! How can I help you today?"
service._client.beta.messages.create.assert_called_once_with(
model="claude-3-sonnet-20240229",
max_tokens=1024,
stream=False,
temperature=0.7,
top_k=40,
top_p=0.9,
messages=[{"role": "user", "content": "Hello, world!"}],
system="You are a helpful assistant",
tools=[],
betas=["interleaved-thinking-2025-05-14"],
)
@@ -128,7 +266,7 @@ async def test_anthropic_run_inference_client_exception():
messages=[], system="Test system", tools=[]
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
service._client.messages.create.side_effect = Exception("Anthropic API Error")
service._client.beta.messages.create.side_effect = Exception("Anthropic API Error")
with pytest.raises(Exception, match="Anthropic API Error"):
await service.run_inference(mock_context)
@@ -167,7 +305,9 @@ async def test_google_run_inference_with_llm_context():
# Verify
assert result == "Hello! How can I help you today?"
service.get_llm_adapter.assert_called_once()
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, system_instruction=None
)
service._client.aio.models.generate_content.assert_called_once()
@@ -193,11 +333,69 @@ async def test_google_run_inference_client_exception():
await service.run_inference(mock_context)
@pytest.mark.asyncio
async def test_google_run_inference_with_openai_llm_context():
"""Test run_inference with OpenAILLMContext returns expected response for Google."""
# Create service with mocked client and specific parameters
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
params = GoogleLLMService.InputParams(max_tokens=256, temperature=0.4, top_k=30, top_p=0.75)
service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash", params=params)
service._client = AsyncMock()
# Create OpenAILLMContext
context = OpenAILLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello, world!"},
],
tools=NOT_GIVEN,
tool_choice=NOT_GIVEN,
)
# Mock response
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content = MagicMock()
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = "Hello! How can I help you today?"
service._client.aio = AsyncMock()
service._client.aio.models = AsyncMock()
service._client.aio.models.generate_content = AsyncMock(return_value=mock_response)
# Execute
result = await service.run_inference(context)
# Verify
assert result == "Hello! How can I help you today?"
# Verify the call includes configured parameters
call_kwargs = service._client.aio.models.generate_content.call_args.kwargs
assert call_kwargs["model"] == "gemini-2.0-flash"
# Contents is a Google Content object, so check its structure
contents = call_kwargs["contents"]
assert len(contents) == 1
assert contents[0].role == "user"
assert len(contents[0].parts) == 1
assert contents[0].parts[0].text == "Hello, world!"
assert "config" in call_kwargs
config = call_kwargs["config"]
# Config is a GenerateContentConfig object, so access attributes
assert config.system_instruction == "You are a helpful assistant"
assert config.temperature == 0.4
assert config.top_k == 30
assert config.top_p == 0.75
assert config.max_output_tokens == 256
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_with_llm_context():
"""Test run_inference with LLMContext returns expected response for AWS Bedrock."""
# Create service and patch the session client method
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0")
# Create service with specific parameters
from pipecat.services.aws.llm import AWSBedrockLLMService
params = AWSBedrockLLMService.InputParams(max_tokens=1024, temperature=0.5, top_p=0.85)
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0", params=params)
# Setup mocks
mock_context = MagicMock(spec=LLMContext)
@@ -217,9 +415,6 @@ async def test_aws_bedrock_run_inference_with_llm_context():
mock_client.converse.return_value = mock_response
# Patch the _aws_session.client method to be an async context manager
async def mock_client_cm(*args, **kwargs):
return mock_client
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
@@ -231,8 +426,71 @@ async def test_aws_bedrock_run_inference_with_llm_context():
# Verify
assert result == "Hello! How can I help you today?"
service.get_llm_adapter.assert_called_once()
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
mock_client.converse.assert_called_once()
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, system_instruction=None
)
# Verify the call includes configured parameters
call_kwargs = mock_client.converse.call_args.kwargs
assert call_kwargs["modelId"] == "anthropic.claude-3-sonnet-20240229-v1:0"
assert call_kwargs["messages"] == test_messages
assert call_kwargs["system"] == test_system
assert call_kwargs["additionalModelRequestFields"] == {}
assert "inferenceConfig" in call_kwargs
assert call_kwargs["inferenceConfig"]["maxTokens"] == 1024
assert call_kwargs["inferenceConfig"]["temperature"] == 0.5
assert call_kwargs["inferenceConfig"]["topP"] == 0.85
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_with_openai_llm_context():
"""Test run_inference with OpenAILLMContext returns expected response for AWS Bedrock."""
# Create service with specific parameters
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.aws.llm import AWSBedrockLLMService
params = AWSBedrockLLMService.InputParams(max_tokens=512, temperature=0.8, top_p=0.95)
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0", params=params)
# Create OpenAILLMContext
context = OpenAILLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello, world!"},
],
tools=NOT_GIVEN,
tool_choice=NOT_GIVEN,
)
# Mock the client and response
mock_client = AsyncMock()
mock_response = {
"output": {"message": {"content": [{"text": "Hello! How can I help you today?"}]}}
}
mock_client.converse.return_value = mock_response
# Patch the _aws_session.client method to be an async context manager
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
# Execute
result = await service.run_inference(context)
# Verify
assert result == "Hello! How can I help you today?"
# Verify the call includes configured parameters
call_kwargs = mock_client.converse.call_args.kwargs
assert call_kwargs["modelId"] == "anthropic.claude-3-sonnet-20240229-v1:0"
assert call_kwargs["messages"] == [{"role": "user", "content": [{"text": "Hello, world!"}]}]
assert call_kwargs["system"] == [{"text": "You are a helpful assistant"}]
assert call_kwargs["additionalModelRequestFields"] == {}
assert "inferenceConfig" in call_kwargs
assert call_kwargs["inferenceConfig"]["maxTokens"] == 512
assert call_kwargs["inferenceConfig"]["temperature"] == 0.8
assert call_kwargs["inferenceConfig"]["topP"] == 0.95
@pytest.mark.asyncio
@@ -259,3 +517,431 @@ async def test_aws_bedrock_run_inference_client_exception():
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
with pytest.raises(Exception, match="Bedrock API Error"):
await service.run_inference(mock_context)
# --- system_instruction parameter tests ---
@pytest.mark.asyncio
async def test_openai_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message from context."""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [
{"role": "system", "content": "Original system message"},
{"role": "user", "content": "Hello"},
]
mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams(
messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Response"
service._client.chat.completions.create.return_value = mock_response
result = await service.run_inference(
mock_context, system_instruction="New system instruction"
)
assert result == "Response"
# Verify the adapter was called with the correct system_instruction.
# convert_developer_to_user=False because OpenAILLMService.supports_developer_role is True.
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context,
system_instruction="New system instruction",
convert_developer_to_user=False,
)
@pytest.mark.asyncio
async def test_openai_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, behavior is unchanged."""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [
{"role": "system", "content": "Original system message"},
{"role": "user", "content": "Hello"},
]
mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams(
messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Response"
service._client.chat.completions.create.return_value = mock_response
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.chat.completions.create.call_args.kwargs
messages = call_kwargs["messages"]
assert messages[0] == {"role": "system", "content": "Original system message"}
assert messages[1] == {"role": "user", "content": "Hello"}
@pytest.mark.asyncio
async def test_anthropic_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for Anthropic."""
service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams(
messages=test_messages, system="Original system", tools=[]
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Response"
service._client.beta.messages.create.return_value = mock_response
result = await service.run_inference(mock_context, system_instruction="New system instruction")
assert result == "Response"
# Verify the adapter was called with the correct system_instruction
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context,
enable_prompt_caching=False,
system_instruction="New system instruction",
)
@pytest.mark.asyncio
async def test_anthropic_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, Anthropic behavior is unchanged."""
service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams(
messages=test_messages, system="Original system", tools=[]
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Response"
service._client.beta.messages.create.return_value = mock_response
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.beta.messages.create.call_args.kwargs
assert call_kwargs["system"] == "Original system"
@pytest.mark.asyncio
async def test_google_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for Google."""
service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams(
messages=test_messages, system_instruction="Original system", tools=NotGiven()
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content = MagicMock()
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = "Response"
service._client.aio = AsyncMock()
service._client.aio.models = AsyncMock()
service._client.aio.models.generate_content = AsyncMock(return_value=mock_response)
result = await service.run_inference(mock_context, system_instruction="New system instruction")
assert result == "Response"
# Verify the adapter was called with the correct system_instruction
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, system_instruction="New system instruction"
)
@pytest.mark.asyncio
async def test_google_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, Google behavior is unchanged."""
service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams(
messages=test_messages, system_instruction="Original system", tools=NotGiven()
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content = MagicMock()
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = "Response"
service._client.aio = AsyncMock()
service._client.aio.models = AsyncMock()
service._client.aio.models.generate_content = AsyncMock(return_value=mock_response)
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.aio.models.generate_content.call_args.kwargs
config = call_kwargs["config"]
assert config.system_instruction == "Original system"
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for AWS Bedrock."""
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0")
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": [{"text": "Hello"}]}]
mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams(
messages=test_messages,
system=[{"text": "Original system"}],
tools=[],
tool_choice=None,
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_client = AsyncMock()
mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}}
mock_client.converse.return_value = mock_response
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
result = await service.run_inference(
mock_context, system_instruction="New system instruction"
)
assert result == "Response"
# Verify the adapter was called with the correct system_instruction
mock_adapter.get_llm_invocation_params.assert_called_once_with(
mock_context, system_instruction="New system instruction"
)
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, AWS Bedrock behavior is unchanged."""
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0")
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": [{"text": "Hello"}]}]
mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams(
messages=test_messages,
system=[{"text": "Original system"}],
tools=[],
tool_choice=None,
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_client = AsyncMock()
mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}}
mock_client.converse.return_value = mock_response
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = mock_client.converse.call_args.kwargs
assert call_kwargs["system"] == [{"text": "Original system"}]
# --- OpenAI Responses API tests ---
@pytest.mark.asyncio
async def test_openai_responses_run_inference_with_llm_context():
"""Test run_inference with LLMContext returns expected response."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="You are a helpful assistant",
temperature=0.7,
max_completion_tokens=100,
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[
{"role": "user", "content": "Hello, world!"},
]
)
mock_response = MagicMock()
mock_response.output_text = "Hello! How can I help you today?"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context)
assert result == "Hello! How can I help you today?"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["model"] == "gpt-4.1"
assert call_kwargs["stream"] is False
assert call_kwargs["store"] is False
assert call_kwargs["input"] == [{"role": "user", "content": "Hello, world!"}]
assert call_kwargs["instructions"] == "You are a helpful assistant"
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["max_output_tokens"] == 100
@pytest.mark.asyncio
async def test_openai_responses_run_inference_client_exception():
"""Test that exceptions from the client are propagated."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService()
service._client = AsyncMock()
context = LLMContext(messages=[{"role": "user", "content": "Hello"}])
service._client.responses.create = AsyncMock(side_effect=Exception("API Error"))
with pytest.raises(Exception, match="API Error"):
await service.run_inference(context)
@pytest.mark.asyncio
async def test_openai_responses_run_inference_system_instruction_overrides():
"""Test that system_instruction parameter overrides the settings instruction."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="Original instruction",
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context, system_instruction="New system instruction")
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["instructions"] == "New system instruction"
assert call_kwargs["input"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_openai_responses_run_inference_empty_context_with_instruction():
"""Test that system_instruction becomes a developer message when context is empty."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="You are helpful",
),
)
service._client = AsyncMock()
context = LLMContext(messages=[])
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context)
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
# With empty context, instruction should become a developer message
assert call_kwargs["input"] == [{"role": "developer", "content": "You are helpful"}]
assert "instructions" not in call_kwargs
@pytest.mark.asyncio
async def test_openai_responses_run_inference_max_tokens_override():
"""Test that max_tokens parameter overrides max_output_tokens."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
max_completion_tokens=500,
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Summarize this"}],
)
mock_response = MagicMock()
mock_response.output_text = "Summary"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context, max_tokens=200)
assert result == "Summary"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["max_output_tokens"] == 200
@pytest.mark.asyncio
async def test_openai_responses_run_inference_system_instruction_param_with_empty_context():
"""Test that system_instruction param becomes a developer message when context is empty.
The Responses API rejects requests with instructions but no input items.
When run_inference is called with an explicit system_instruction and an
empty context, the instruction must become a developer message — not be
sent as the instructions parameter.
"""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(model="gpt-4.1"),
)
service._client = AsyncMock()
context = LLMContext(messages=[])
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(
context, system_instruction="Summarize the conversation"
)
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["input"] == [
{"role": "developer", "content": "Summarize the conversation"}
]
assert "instructions" not in call_kwargs

153
tests/test_runner_utils.py Normal file
View File

@@ -0,0 +1,153 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import json
import unittest
from unittest.mock import MagicMock
from pipecat.runner.utils import parse_telephony_websocket
class MockAsyncIterator:
"""Mock async iterator for WebSocket messages."""
def __init__(self, messages):
self.messages = messages
self.index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self.index >= len(self.messages):
raise StopAsyncIteration
message = self.messages[self.index]
self.index += 1
return message
class TestParseTelephonyWebSocket(unittest.IsolatedAsyncioTestCase):
async def test_no_messages_raises_value_error(self):
"""Test that no messages raises ValueError."""
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([])
with self.assertRaises(ValueError) as context:
await parse_telephony_websocket(mock_websocket)
self.assertIn("WebSocket closed before receiving", str(context.exception))
async def test_one_message_logs_warning_and_continues(self):
"""Test that one message logs warning but continues processing."""
twilio_message = json.dumps(
{
"event": "start",
"start": {
"streamSid": "MZ123",
"callSid": "CA123",
"customParameters": {"user_id": "test_user"},
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([twilio_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "twilio")
self.assertEqual(call_data["stream_id"], "MZ123")
self.assertEqual(call_data["call_id"], "CA123")
async def test_two_messages_normal_operation(self):
"""Test normal operation with two messages."""
first_message = json.dumps({"event": "connected"})
twilio_message = json.dumps(
{
"event": "start",
"start": {
"streamSid": "MZ456",
"callSid": "CA456",
"customParameters": {},
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([first_message, twilio_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "twilio")
self.assertEqual(call_data["stream_id"], "MZ456")
self.assertEqual(call_data["call_id"], "CA456")
async def test_telnyx_detection(self):
"""Test Telnyx provider detection."""
telnyx_message = json.dumps(
{
"stream_id": "stream_123",
"start": {
"call_control_id": "cc_123",
"media_format": {"encoding": "PCMU"},
"from": "+15551234567",
"to": "+15559876543",
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([telnyx_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "telnyx")
self.assertEqual(call_data["stream_id"], "stream_123")
self.assertEqual(call_data["call_control_id"], "cc_123")
async def test_plivo_detection(self):
"""Test Plivo provider detection."""
plivo_message = json.dumps(
{"start": {"streamId": "stream_plivo_123", "callId": "call_plivo_123"}}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([plivo_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "plivo")
self.assertEqual(call_data["stream_id"], "stream_plivo_123")
self.assertEqual(call_data["call_id"], "call_plivo_123")
async def test_exotel_detection(self):
"""Test Exotel provider detection."""
exotel_message = json.dumps(
{
"event": "start",
"start": {
"stream_sid": "stream_exo_123",
"call_sid": "call_exo_123",
"account_sid": "acc_123",
"from": "+15551111111",
"to": "+15552222222",
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([exotel_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "exotel")
self.assertEqual(call_data["stream_id"], "stream_exo_123")
self.assertEqual(call_data["call_id"], "call_exo_123")
self.assertEqual(call_data["account_sid"], "acc_123")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,72 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for SambaNova LLM service."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.sambanova.llm import SambaNovaLLMService
@pytest.mark.asyncio
async def test_sambanova_llm_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3639.
"""
with patch.object(SambaNovaLLMService, "create_client"):
service = SambaNovaLLMService(api_key="test-key", model="test-model")
service._client = AsyncMock()
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
nonlocal stream_closed
stream_closed = True
return False
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
raise asyncio.CancelledError()
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
assert stream_closed, "Stream should be closed even when CancelledError occurs"

180
tests/test_service_init.py Normal file
View File

@@ -0,0 +1,180 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for service settings and initialization patterns.
Settings objects operate in two modes:
- **Store mode** (``self._settings``): the live state inside a service.
Every field must hold a real value (``None`` is fine, ``NOT_GIVEN`` is not).
- **Delta mode** (``FooSettings()`` with no args): a sparse update.
Every field must default to ``NOT_GIVEN`` so ``apply_update()`` skips
untouched fields and doesn't accidentally overwrite the store.
These tests verify both sides of that contract automatically:
1. **Delta defaults** — Instantiate every ``ServiceSettings`` subclass with
no arguments and assert that every field is ``NOT_GIVEN``. Catches the
bug where a field defaults to ``None`` instead of ``NOT_GIVEN``, which
would cause partial deltas to silently overwrite unrelated store values.
2. **Store completeness** — Instantiate every concrete service with dummy
args and assert that ``_settings`` contains no ``NOT_GIVEN`` values.
This is the same check that ``validate_complete()`` runs in ``start()``,
but caught here at unit-test time without needing a running pipeline.
Catches services that forget to initialize a field in ``default_settings``.
All Settings and Service classes are auto-discovered via ``pkgutil``;
new services are covered automatically with no per-service maintenance.
"""
import importlib
import inspect
import pkgutil
from dataclasses import fields
import pytest
import pipecat.services
from pipecat.services.ai_service import AIService
from pipecat.services.settings import ServiceSettings, is_given
# Modules that define abstract base service classes (not concrete services).
_BASE_MODULES = frozenset(
{
"pipecat.services.ai_service",
"pipecat.services.llm_service",
"pipecat.services.stt_service",
"pipecat.services.tts_service",
"pipecat.services.image_gen_service",
"pipecat.services.vision_service",
}
)
# ---------------------------------------------------------------------------
# Auto-discovery
# ---------------------------------------------------------------------------
def _all_subclasses(cls):
result = set()
for sub in cls.__subclasses__():
result.add(sub)
result.update(_all_subclasses(sub))
return result
def _import_all_service_modules():
"""Import every module under pipecat.services (skipping missing deps)."""
package = pipecat.services
for _importer, modname, _ispkg in pkgutil.walk_packages(
package.__path__, prefix=package.__name__ + ".", onerror=lambda _name: None
):
try:
importlib.import_module(modname)
except Exception:
continue
_import_all_service_modules()
ALL_SETTINGS_CLASSES = sorted(_all_subclasses(ServiceSettings), key=lambda c: c.__qualname__)
assert ALL_SETTINGS_CLASSES, "No settings classes discovered"
# ---------------------------------------------------------------------------
# Service instantiation helpers
# ---------------------------------------------------------------------------
def _try_instantiate(cls):
"""Try to instantiate a service with dummy values for required args.
Inspects the __init__ signature and passes "test" for every required
keyword-only parameter. Services that need non-string required args
or fail for other reasons will raise and be skipped by the test.
"""
sig = inspect.signature(cls.__init__)
kwargs = {}
for name, param in sig.parameters.items():
if name == "self":
continue
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if param.default is not param.empty:
continue
# Required parameter — pass a dummy string
kwargs[name] = "test"
return cls(**kwargs)
def _discover_service_classes():
"""Return concrete service classes that can be instantiated with dummy args."""
result = []
for cls in sorted(_all_subclasses(AIService), key=lambda c: c.__qualname__):
# Skip abstract base classes defined in framework modules.
if cls.__module__ in _BASE_MODULES:
continue
try:
svc = _try_instantiate(cls)
except Exception:
continue
if hasattr(svc, "_settings"):
result.append(cls)
return result
ALL_SERVICE_CLASSES = _discover_service_classes()
assert ALL_SERVICE_CLASSES, "No service classes could be instantiated"
# ---------------------------------------------------------------------------
# 1. Settings defaults: delta-mode safety
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("settings_cls", ALL_SETTINGS_CLASSES, ids=lambda c: c.__qualname__)
def test_delta_defaults_are_not_given(settings_cls):
"""Every field must default to NOT_GIVEN so empty deltas are no-ops.
A field that defaults to None instead of NOT_GIVEN will cause
apply_update() to overwrite the corresponding store value whenever
a partial delta is applied.
"""
instance = settings_cls()
for f in fields(instance):
if f.name == "extra":
continue
val = getattr(instance, f.name)
assert not is_given(val), (
f"{settings_cls.__qualname__}.{f.name} defaults to {val!r}, expected NOT_GIVEN"
)
# ---------------------------------------------------------------------------
# 2. Service construction: store-mode completeness
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("service_cls", ALL_SERVICE_CLASSES, ids=lambda c: c.__qualname__)
def test_service_settings_complete(service_cls):
"""After construction, _settings must have no NOT_GIVEN values.
This is what validate_complete() checks in start(). Catching it
here means we don't need a running pipeline to find missing defaults.
"""
try:
svc = _try_instantiate(service_cls)
except Exception:
pytest.skip("Cannot re-instantiate (environment issue)")
for f in fields(svc._settings):
if f.name == "extra":
continue
val = getattr(svc._settings, f.name)
assert is_given(val), (
f"{service_cls.__qualname__}._settings.{f.name} is NOT_GIVEN after construction"
)

View File

@@ -0,0 +1,251 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for language parameter handling in TTS and STT services.
Verifies that Language enums, raw strings (e.g. "de-DE"), and unrecognized
strings are all resolved correctly at both init time and runtime update time.
"""
from typing import AsyncGenerator, Optional
from unittest.mock import patch
import pytest
from pipecat.frames.frames import Frame
from pipecat.services.settings import STTSettings, TTSSettings
from pipecat.services.stt_service import STTService
from pipecat.services.tts_service import TTSService
from pipecat.transcriptions.language import Language, resolve_language
# ---------------------------------------------------------------------------
# Minimal concrete subclasses for testing
# ---------------------------------------------------------------------------
# A simple language map using only base codes (like ElevenLabs does).
_LANGUAGE_MAP = {
Language.DE: "de",
Language.EN: "en",
Language.FR: "fr",
}
class _TestTTSService(TTSService):
"""Minimal concrete TTS service for testing language resolution."""
class Settings(TTSSettings):
pass
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
yield # pragma: no cover
def language_to_service_language(self, language: Language) -> Optional[str]:
return resolve_language(language, _LANGUAGE_MAP, use_base_code=True)
class _TestSTTService(STTService):
"""Minimal concrete STT service for testing language resolution."""
class Settings(STTSettings):
pass
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
yield # pragma: no cover
async def process_audio_frame(self, frame, direction):
pass # pragma: no cover
def language_to_service_language(self, language: Language) -> Optional[str]:
return resolve_language(language, _LANGUAGE_MAP, use_base_code=True)
# ---------------------------------------------------------------------------
# TTS init tests
# ---------------------------------------------------------------------------
class TestTTSLanguageInit:
"""Test language resolution at TTS service init time."""
def test_language_enum_base_code(self):
"""Language.DE (base code in map) resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE))
assert svc._settings.language == "de"
def test_language_enum_regional_code(self):
"""Language.DE_DE (regional, not in map) falls back to base code 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE_DE))
assert svc._settings.language == "de"
def test_raw_string_base_code(self):
"""Raw string 'de' is converted to Language.DE then resolved to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="de"))
assert svc._settings.language == "de"
def test_raw_string_regional_code(self):
"""Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="de-DE"))
assert svc._settings.language == "de"
def test_raw_string_other_regional(self):
"""Raw string 'en-US' is converted to Language.EN_US then resolved to 'en'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="en-US"))
assert svc._settings.language == "en"
def test_raw_string_unrecognized(self):
"""Unrecognized raw string logs a debug message and is passed through as-is."""
with patch("pipecat.services.tts_service.logger") as mock_logger:
svc = _TestTTSService(settings=_TestTTSService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.debug.assert_called_once()
assert "klingon" in mock_logger.debug.call_args[0][0]
def test_language_none(self):
"""None language is left as None."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
assert svc._settings.language is None
# ---------------------------------------------------------------------------
# STT init tests
# ---------------------------------------------------------------------------
class TestSTTLanguageInit:
"""Test language resolution at STT service init time."""
def test_language_enum_base_code(self):
"""Language.FR (base code in map) resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR))
assert svc._settings.language == "fr"
def test_language_enum_regional_code(self):
"""Language.FR_FR (regional, not in map) falls back to base code 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR_FR))
assert svc._settings.language == "fr"
def test_raw_string_base_code(self):
"""Raw string 'fr' is converted to Language.FR then resolved to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language="fr"))
assert svc._settings.language == "fr"
def test_raw_string_regional_code(self):
"""Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language="de-DE"))
assert svc._settings.language == "de"
def test_raw_string_unrecognized(self):
"""Unrecognized raw string logs a debug message and is passed through as-is."""
with patch("pipecat.services.stt_service.logger") as mock_logger:
svc = _TestSTTService(settings=_TestSTTService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.debug.assert_called_once()
assert "klingon" in mock_logger.debug.call_args[0][0]
def test_language_none(self):
"""None language is left as None."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
assert svc._settings.language is None
# ---------------------------------------------------------------------------
# TTS runtime update tests
# ---------------------------------------------------------------------------
class TestTTSLanguageUpdate:
"""Test language resolution during runtime settings updates."""
@pytest.mark.asyncio
async def test_update_language_enum_base_code(self):
"""Updating with Language.EN resolves to 'en'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language=Language.EN))
assert svc._settings.language == "en"
@pytest.mark.asyncio
async def test_update_language_enum_regional_code(self):
"""Updating with Language.DE_DE falls back to base code 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language=Language.DE_DE))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_base_code(self):
"""Updating with raw string 'de' resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language="de"))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_regional_code(self):
"""Updating with raw string 'de-DE' resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language="de-DE"))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_unrecognized(self):
"""Updating with unrecognized string logs debug message and passes through."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
with patch("pipecat.services.tts_service.logger") as mock_logger:
await svc._update_settings(_TestTTSService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.debug.assert_called_once()
assert "klingon" in mock_logger.debug.call_args[0][0]
# ---------------------------------------------------------------------------
# STT runtime update tests
# ---------------------------------------------------------------------------
class TestSTTLanguageUpdate:
"""Test language resolution during runtime settings updates."""
@pytest.mark.asyncio
async def test_update_language_enum_base_code(self):
"""Updating with Language.EN resolves to 'en'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language=Language.EN))
assert svc._settings.language == "en"
@pytest.mark.asyncio
async def test_update_language_enum_regional_code(self):
"""Updating with Language.FR_FR falls back to base code 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language=Language.FR_FR))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_base_code(self):
"""Updating with raw string 'fr' resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language="fr"))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_regional_code(self):
"""Updating with raw string 'fr-FR' resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language="fr-FR"))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_unrecognized(self):
"""Updating with unrecognized string logs debug message and passes through."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
with patch("pipecat.services.stt_service.logger") as mock_logger:
await svc._update_settings(_TestSTTService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.debug.assert_called_once()
assert "klingon" in mock_logger.debug.call_args[0][0]

View File

@@ -1,22 +1,32 @@
#
# Copyright (c) 2025, Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for ServiceSwitcher and related components."""
import asyncio
import unittest
from dataclasses import dataclass
from pipecat.frames.frames import (
ErrorFrame,
Frame,
ManuallySwitchServiceFrame,
ServiceMetadataFrame,
ServiceSwitcherRequestMetadataFrame,
StartFrame,
SystemFrame,
TextFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.service_switcher import ServiceSwitcher, ServiceSwitcherStrategyManual
from pipecat.pipeline.service_switcher import (
ServiceSwitcher,
ServiceSwitcherStrategy,
ServiceSwitcherStrategyFailover,
ServiceSwitcherStrategyManual,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@@ -54,6 +64,79 @@ class MockFrameProcessor(FrameProcessor):
self.frame_count = 0
@dataclass
class MockMetadataFrame(ServiceMetadataFrame):
"""A mock metadata frame for testing ServiceMetadataFrame handling."""
pass
class MockMetadataService(FrameProcessor):
"""A mock service that emits ServiceMetadataFrame like STT services.
Pushes MockMetadataFrame on StartFrame and ServiceSwitcherRequestMetadataFrame.
"""
def __init__(self, test_name: str, **kwargs):
super().__init__(name=test_name, **kwargs)
self.test_name = test_name
self.processed_frames = []
self.metadata_push_count = 0
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
self.processed_frames.append(frame)
if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
await self._push_metadata()
elif isinstance(frame, ServiceSwitcherRequestMetadataFrame):
await self._push_metadata()
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
async def _push_metadata(self):
self.metadata_push_count += 1
await self.push_frame(MockMetadataFrame(service_name=self.test_name))
def reset_counters(self):
self.processed_frames = []
self.metadata_push_count = 0
class ErrorInjectorProcessor(FrameProcessor):
"""A downstream processor that pushes an ErrorFrame upstream on receiving a TextFrame.
Simulates an error from a service outside the ServiceSwitcher (e.g. TTS
erroring while propagating upstream through an LLM switcher).
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame) and direction == FrameDirection.DOWNSTREAM:
await self.push_error("downstream service error")
await self.push_frame(frame, direction)
class ErrorOnTextService(FrameProcessor):
"""A mock service that pushes an error on the first TextFrame it receives.
Simulates a managed service inside a ServiceSwitcher that encounters an error.
"""
def __init__(self, test_name: str, **kwargs):
super().__init__(name=test_name, **kwargs)
self._errored = False
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame) and not self._errored:
self._errored = True
await self.push_error("service connection lost")
await self.push_frame(frame, direction)
@dataclass
class DummySystemFrame(SystemFrame):
"""A dummy system frame for testing purposes."""
@@ -61,6 +144,52 @@ class DummySystemFrame(SystemFrame):
text: str = ""
class TestServiceSwitcherStrategy(unittest.IsolatedAsyncioTestCase):
"""Test cases for the base ServiceSwitcherStrategy."""
def setUp(self):
"""Set up test fixtures."""
self.service1 = MockFrameProcessor("service1")
self.service2 = MockFrameProcessor("service2")
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_services(self):
"""Test initialization with a list of services."""
strategy = ServiceSwitcherStrategy(self.services)
self.assertEqual(strategy.services, self.services)
self.assertEqual(strategy.active_service, self.service1)
async def test_handle_frame_returns_none_for_manual_switch(self):
"""Test that base strategy does not handle ManuallySwitchServiceFrame."""
strategy = ServiceSwitcherStrategy(self.services)
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertIsNone(result)
self.assertEqual(strategy.active_service, self.service1)
async def test_handle_frame_returns_none_for_unsupported_frame(self):
"""Test that unsupported frame types return None."""
strategy = ServiceSwitcherStrategy(self.services)
unsupported_frame = TextFrame(text="test")
result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
self.assertIsNone(result)
async def test_handle_error_returns_none(self):
"""Test that handle_error returns None by default."""
strategy = ServiceSwitcherStrategy(self.services)
result = await strategy.handle_error(ErrorFrame(error="error"))
self.assertIsNone(result)
self.assertEqual(strategy.active_service, self.service1)
class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceSwitcherStrategyManual."""
@@ -71,53 +200,64 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_services(self):
"""Test initialization with a list of services."""
def test_is_subclass_of_base_strategy(self):
"""Test that ServiceSwitcherStrategyManual is a subclass of ServiceSwitcherStrategy."""
strategy = ServiceSwitcherStrategyManual(self.services)
self.assertIsInstance(strategy, ServiceSwitcherStrategy)
self.assertEqual(strategy.services, self.services)
self.assertEqual(strategy.active_service, self.service1) # First service should be active
def test_init_with_empty_services(self):
"""Test initialization with an empty list of services."""
strategy = ServiceSwitcherStrategyManual([])
self.assertEqual(strategy.services, [])
self.assertIsNone(strategy.active_service)
def test_handle_manually_switch_service_frame(self):
async def test_handle_manually_switch_service_frame(self):
"""Test manual service switching with ManuallySwitchServiceFrame."""
strategy = ServiceSwitcherStrategyManual(self.services)
# Initially service1 should be active
self.assertEqual(strategy.active_service, self.service1)
self.assertNotEqual(strategy.active_service, self.service2)
# Switch to service2
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertNotEqual(strategy.active_service, self.service1)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(strategy.active_service, self.service2)
self.assertNotEqual(strategy.active_service, self.service3)
# Switch to service3
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertNotEqual(strategy.active_service, self.service1)
self.assertNotEqual(strategy.active_service, self.service2)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(strategy.active_service, self.service3)
def test_handle_frame_unsupported_frame_type(self):
"""Test that unsupported frame types raise an error."""
async def test_on_service_switched_event(self):
"""Test that on_service_switched event fires with correct arguments."""
strategy = ServiceSwitcherStrategyManual(self.services)
unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame
with self.assertRaises(ValueError) as context:
strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
switched_events = []
self.assertIn("Unsupported frame type", str(context.exception))
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append((strategy, service))
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
await asyncio.sleep(0)
self.assertEqual(len(switched_events), 1)
self.assertIsInstance(switched_events[0][0], ServiceSwitcherStrategyManual)
self.assertEqual(switched_events[0][1], self.service2)
async def test_unknown_service_ignored(self):
"""Test that switching to an unknown service is ignored."""
strategy = ServiceSwitcherStrategyManual(self.services)
switched_events = []
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append(service)
unknown_service = MockFrameProcessor("unknown")
switch_frame = ManuallySwitchServiceFrame(service=unknown_service)
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
await asyncio.sleep(0)
self.assertIsNone(result)
self.assertEqual(len(switched_events), 0)
self.assertEqual(strategy.active_service, self.service1)
class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
@@ -130,9 +270,9 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_manual_strategy(self):
"""Test initialization with manual strategy."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
def test_init_with_default_strategy(self):
"""Test initialization with default strategy."""
switcher = ServiceSwitcher(self.services)
self.assertEqual(switcher.services, self.services)
self.assertIsInstance(switcher.strategy, ServiceSwitcherStrategyManual)
@@ -140,7 +280,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
async def test_default_active_service(self):
"""Test that the initially-active service receives frames while others don't."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
switcher = ServiceSwitcher(self.services)
# Reset counters
for service in self.services:
@@ -209,7 +349,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
async def test_service_switching(self):
"""Test that after service switching using ManuallySwitchServiceFrame, the new active service receives frames while others don't."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
switcher = ServiceSwitcher(self.services)
# Reset counters
for service in self.services:
@@ -223,7 +363,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
ManuallySwitchServiceFrame(service=self.service2),
TextFrame("Hello 2"),
],
expected_down_frames=[TextFrame, ManuallySwitchServiceFrame, TextFrame],
expected_down_frames=[TextFrame, TextFrame],
expected_up_frames=[], # Expect no error frames
)
@@ -258,8 +398,8 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
switcher2_services = [switcher2_service1, switcher2_service2]
# Create two service switchers
switcher1 = ServiceSwitcher(switcher1_services, ServiceSwitcherStrategyManual)
switcher2 = ServiceSwitcher(switcher2_services, ServiceSwitcherStrategyManual)
switcher1 = ServiceSwitcher(switcher1_services)
switcher2 = ServiceSwitcher(switcher2_services)
# Create a pipeline with both switchers: switcher1 -> switcher2
pipeline = Pipeline([switcher1, switcher2])
@@ -289,9 +429,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
],
expected_down_frames=[
TextFrame,
ManuallySwitchServiceFrame,
TextFrame,
ManuallySwitchServiceFrame,
TextFrame,
],
expected_up_frames=[], # Expect no error frames
@@ -336,5 +474,203 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
self.assertEqual(switcher2_service2_texts[0].text, "After switching second switcher")
class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceMetadataFrame handling in ServiceSwitcher."""
def setUp(self):
"""Set up test fixtures with mock metadata services."""
self.service1 = MockMetadataService("service1")
self.service2 = MockMetadataService("service2")
self.services = [self.service1, self.service2]
async def test_only_active_service_metadata_at_startup(self):
"""Test that only the active service's metadata leaves the ServiceSwitcher at startup."""
switcher = ServiceSwitcher(self.services)
# Run the pipeline (StartFrame triggers metadata emission)
output_frames = []
async def capture_frame(frame: Frame):
output_frames.append(frame)
await run_test(
switcher,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[MockMetadataFrame, TextFrame],
expected_up_frames=[],
)
# Both services push metadata internally on StartFrame, but only the
# active service's metadata passes through the filter
self.assertEqual(self.service1.metadata_push_count, 1) # StartFrame (passes filter)
self.assertEqual(self.service2.metadata_push_count, 1) # StartFrame (blocked by filter)
async def test_metadata_emitted_on_service_switch(self):
"""Test that switching services triggers metadata emission from the new active service."""
switcher = ServiceSwitcher(self.services)
# Reset counters after startup
self.service1.reset_counters()
self.service2.reset_counters()
await run_test(
switcher,
frames_to_send=[
TextFrame(text="before switch"),
ManuallySwitchServiceFrame(service=self.service2),
TextFrame(text="after switch"),
],
expected_down_frames=[
MockMetadataFrame, # From startup (service1)
TextFrame,
MockMetadataFrame, # From service2 after switch
TextFrame,
],
expected_up_frames=[],
)
# service2 should have received ServiceSwitcherRequestMetadataFrame after becoming active
request_frames = [
f
for f in self.service2.processed_frames
if isinstance(f, ServiceSwitcherRequestMetadataFrame)
]
self.assertEqual(len(request_frames), 1)
async def test_inactive_service_metadata_blocked(self):
"""Test that metadata from inactive services is blocked."""
switcher = ServiceSwitcher(self.services)
# Run and collect output frames
await run_test(
switcher,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[MockMetadataFrame, TextFrame],
expected_up_frames=[],
)
# service2 pushed metadata on StartFrame, but it should have been blocked
self.assertGreaterEqual(self.service2.metadata_push_count, 1)
# Only one MockMetadataFrame should have left (from service1)
class TestServiceSwitcherStrategyFailover(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceSwitcherStrategyFailover."""
def setUp(self):
"""Set up test fixtures."""
self.service1 = MockFrameProcessor("service1")
self.service2 = MockFrameProcessor("service2")
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_defaults(self):
"""Test that default values are set correctly."""
strategy = ServiceSwitcherStrategyFailover(self.services)
self.assertEqual(strategy.active_service, self.service1)
async def test_error_switches_to_next_service(self):
"""Test that an error on the active service switches to the next one."""
strategy = ServiceSwitcherStrategyFailover(self.services)
error = ErrorFrame(error="connection lost")
result = await strategy.handle_error(error)
self.assertEqual(result, self.service2)
self.assertEqual(strategy.active_service, self.service2)
async def test_consecutive_errors_cycle_through_services(self):
"""Test that repeated errors cycle through all services."""
strategy = ServiceSwitcherStrategyFailover(self.services)
# First error: service1 -> service2
await strategy.handle_error(ErrorFrame(error="error 1"))
self.assertEqual(strategy.active_service, self.service2)
# Second error: service2 -> service3
await strategy.handle_error(ErrorFrame(error="error 2"))
self.assertEqual(strategy.active_service, self.service3)
# Third error: service3 -> service1 (wraps around)
await strategy.handle_error(ErrorFrame(error="error 3"))
self.assertEqual(strategy.active_service, self.service1)
async def test_single_service_returns_none(self):
"""Test that handle_error returns None with only one service."""
strategy = ServiceSwitcherStrategyFailover([self.service1])
result = await strategy.handle_error(ErrorFrame(error="error"))
self.assertIsNone(result)
async def test_manual_switch_still_works(self):
"""Test that ManuallySwitchServiceFrame is still handled."""
strategy = ServiceSwitcherStrategyFailover(self.services)
frame = ManuallySwitchServiceFrame(service=self.service3)
result = await strategy.handle_frame(frame, FrameDirection.DOWNSTREAM)
self.assertEqual(result, self.service3)
self.assertEqual(strategy.active_service, self.service3)
async def test_passthrough_error_does_not_trigger_failover(self):
"""Test that an error propagating upstream from a downstream processor does not trigger failover.
This reproduces the bug where an ErrorFrame from e.g. TTS propagates
upstream through an LLM ServiceSwitcher and incorrectly triggers
failover even though neither LLM service produced the error.
"""
switcher = ServiceSwitcher(
[self.service1, self.service2],
strategy_type=ServiceSwitcherStrategyFailover,
)
error_injector = ErrorInjectorProcessor()
pipeline = Pipeline([switcher, error_injector])
await run_test(
pipeline,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[TextFrame],
expected_up_frames=[ErrorFrame],
)
# Active service should NOT have changed — the error came from outside
self.assertEqual(switcher.strategy.active_service, self.service1)
async def test_managed_service_error_triggers_failover(self):
"""Test that an error from a managed service inside the switcher triggers failover."""
error_service = ErrorOnTextService("error_service")
backup_service = MockFrameProcessor("backup_service")
switcher = ServiceSwitcher(
[error_service, backup_service],
strategy_type=ServiceSwitcherStrategyFailover,
)
await run_test(
switcher,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[TextFrame],
expected_up_frames=[ErrorFrame],
)
# Active service SHOULD have changed — the error came from a managed service
self.assertEqual(switcher.strategy.active_service, backup_service)
async def test_on_service_switched_event_fires_on_error(self):
"""Test that on_service_switched event fires when an error triggers a switch."""
strategy = ServiceSwitcherStrategyFailover(self.services)
switched_events = []
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append(service)
await strategy.handle_error(ErrorFrame(error="error"))
await asyncio.sleep(0)
self.assertEqual(len(switched_events), 1)
self.assertEqual(switched_events[0], self.service2)
if __name__ == "__main__":
unittest.main()

983
tests/test_settings.py Normal file
View File

@@ -0,0 +1,983 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for the typed settings infrastructure in pipecat.services.settings."""
from unittest.mock import patch
from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings
from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTSettings
from pipecat.services.openai.realtime import events
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMSettings
from pipecat.services.settings import (
NOT_GIVEN,
LLMSettings,
ServiceSettings,
STTSettings,
TTSSettings,
_NotGiven,
is_given,
)
from pipecat.services.xai.realtime import events as grok_events
from pipecat.services.xai.realtime.llm import GrokRealtimeLLMSettings
# ---------------------------------------------------------------------------
# NOT_GIVEN sentinel
# ---------------------------------------------------------------------------
class TestNotGiven:
def test_singleton(self):
"""NOT_GIVEN is a singleton — every reference is the same object."""
assert _NotGiven() is _NotGiven()
assert NOT_GIVEN is _NotGiven()
def test_repr(self):
assert repr(NOT_GIVEN) == "NOT_GIVEN"
def test_bool_is_false(self):
assert not NOT_GIVEN
assert bool(NOT_GIVEN) is False
def test_is_given_with_not_given(self):
assert is_given(NOT_GIVEN) is False
def test_is_given_with_none(self):
assert is_given(None) is True
def test_is_given_with_values(self):
assert is_given(0) is True
assert is_given("") is True
assert is_given(False) is True
assert is_given(42) is True
assert is_given("hello") is True
# ---------------------------------------------------------------------------
# ServiceSettings base
# ---------------------------------------------------------------------------
class TestServiceSettings:
def test_default_fields_are_not_given(self):
s = ServiceSettings()
assert not is_given(s.model)
assert s.extra == {}
def test_given_fields_empty_by_default(self):
s = ServiceSettings()
assert s.given_fields() == {}
def test_given_fields_includes_set_values(self):
s = ServiceSettings(model="gpt-4o")
assert s.given_fields() == {"model": "gpt-4o"}
def test_given_fields_includes_extra(self):
s = ServiceSettings(model="gpt-4o")
s.extra = {"custom_key": 42}
result = s.given_fields()
assert result == {"model": "gpt-4o", "custom_key": 42}
def test_copy_is_deep(self):
s = ServiceSettings(model="gpt-4o")
s.extra = {"nested": {"a": 1}}
c = s.copy()
assert c.model == "gpt-4o"
assert c.extra == {"nested": {"a": 1}}
# Mutating the copy shouldn't affect the original
c.extra["nested"]["a"] = 999
assert s.extra["nested"]["a"] == 1
# ---------------------------------------------------------------------------
# apply_update
# ---------------------------------------------------------------------------
class TestApplyUpdate:
def test_apply_update_basic(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings(voice="bob")
changed = current.apply_update(delta)
assert changed.keys() == {"voice"}
assert changed["voice"] == "alice" # old value
assert current.voice == "bob"
assert current.language == "en"
def test_apply_update_no_change(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings(voice="alice")
changed = current.apply_update(delta)
assert changed == {}
assert current.voice == "alice"
def test_apply_update_not_given_skipped(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings() # all NOT_GIVEN
changed = current.apply_update(delta)
assert changed == {}
assert current.voice == "alice"
assert current.language == "en"
def test_apply_update_multiple_fields(self):
current = LLMSettings(temperature=0.7, max_tokens=100)
delta = LLMSettings(temperature=0.9, max_tokens=200, top_p=0.95)
changed = current.apply_update(delta)
assert changed.keys() == {"temperature", "max_tokens", "top_p"}
assert changed["temperature"] == 0.7
assert changed["max_tokens"] == 100
assert current.temperature == 0.9
assert current.max_tokens == 200
assert current.top_p == 0.95
def test_apply_update_extra_merged(self):
current = TTSSettings(voice="alice")
current.extra = {"speed": 1.0, "stability": 0.5}
delta = TTSSettings()
delta.extra = {"speed": 1.2}
changed = current.apply_update(delta)
assert "speed" in changed
assert changed["speed"] == 1.0 # old value
assert current.extra == {"speed": 1.2, "stability": 0.5}
def test_apply_update_extra_no_change(self):
current = TTSSettings(voice="alice")
current.extra = {"speed": 1.0}
delta = TTSSettings()
delta.extra = {"speed": 1.0}
changed = current.apply_update(delta)
assert changed == {}
def test_apply_update_model_field(self):
current = ServiceSettings(model="old-model")
delta = ServiceSettings(model="new-model")
changed = current.apply_update(delta)
assert changed.keys() == {"model"}
assert changed["model"] == "old-model"
assert current.model == "new-model"
def test_apply_update_none_is_a_valid_value(self):
"""Setting a field to None should be treated as a change from NOT_GIVEN."""
current = TTSSettings()
delta = TTSSettings(language=None)
changed = current.apply_update(delta)
assert "language" in changed
assert current.language is None
def test_apply_update_none_to_value(self):
current = TTSSettings(language=None)
delta = TTSSettings(language="en")
changed = current.apply_update(delta)
assert "language" in changed
assert changed["language"] is None # old value was None
assert current.language == "en"
# ---------------------------------------------------------------------------
# from_mapping
# ---------------------------------------------------------------------------
class TestFromMapping:
def test_basic_mapping(self):
s = TTSSettings.from_mapping({"voice": "alice", "language": "en"})
assert s.voice == "alice"
assert s.language == "en"
assert not is_given(s.model)
def test_alias_resolution(self):
"""'voice_id' is an alias for 'voice' in TTSSettings."""
s = TTSSettings.from_mapping({"voice_id": "alice"})
assert s.voice == "alice"
def test_unknown_keys_go_to_extra(self):
s = TTSSettings.from_mapping({"voice": "alice", "speed": 1.2, "stability": 0.5})
assert s.voice == "alice"
assert s.extra == {"speed": 1.2, "stability": 0.5}
def test_model_field(self):
s = LLMSettings.from_mapping({"model": "gpt-4o", "temperature": 0.7})
assert s.model == "gpt-4o"
assert s.temperature == 0.7
def test_empty_mapping(self):
s = ServiceSettings.from_mapping({})
assert s.given_fields() == {}
def test_all_unknown_keys(self):
s = ServiceSettings.from_mapping({"foo": 1, "bar": 2})
assert not is_given(s.model)
assert s.extra == {"foo": 1, "bar": 2}
def test_llm_settings_from_mapping(self):
s = LLMSettings.from_mapping({"temperature": 0.5, "max_tokens": 1000, "custom_param": True})
assert s.temperature == 0.5
assert s.max_tokens == 1000
assert s.extra == {"custom_param": True}
def test_stt_settings_from_mapping(self):
s = STTSettings.from_mapping({"language": "fr", "model": "whisper-large"})
assert s.language == "fr"
assert s.model == "whisper-large"
# ---------------------------------------------------------------------------
# LLMSettings specifics
# ---------------------------------------------------------------------------
class TestLLMSettings:
def test_all_fields_not_given_by_default(self):
s = LLMSettings()
for name in (
"model",
"temperature",
"max_tokens",
"top_p",
"top_k",
"frequency_penalty",
"presence_penalty",
"seed",
):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_given_fields(self):
s = LLMSettings(temperature=0.7, seed=42)
assert s.given_fields() == {"temperature": 0.7, "seed": 42}
# ---------------------------------------------------------------------------
# TTSSettings specifics
# ---------------------------------------------------------------------------
class TestTTSSettings:
def test_all_fields_not_given_by_default(self):
s = TTSSettings()
for name in ("model", "voice", "language"):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_aliases_class_var(self):
assert TTSSettings._aliases == {"voice_id": "voice"}
def test_given_fields(self):
s = TTSSettings(voice="alice")
assert s.given_fields() == {"voice": "alice"}
# ---------------------------------------------------------------------------
# STTSettings specifics
# ---------------------------------------------------------------------------
class TestSTTSettings:
def test_all_fields_not_given_by_default(self):
s = STTSettings()
for name in ("model", "language"):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_given_fields(self):
s = STTSettings(language="en", model="whisper-large")
assert s.given_fields() == {"language": "en", "model": "whisper-large"}
# ---------------------------------------------------------------------------
# Integration: roundtrip from_mapping → apply_update
# ---------------------------------------------------------------------------
class TestRoundtrip:
def test_from_mapping_then_apply_update(self):
"""Simulate the real flow: dict arrives via frame, gets converted, applied."""
# Simulating current service state
current = TTSSettings(model="eleven_turbo_v2_5", voice="alice", language="en")
current.extra = {"stability": 0.5, "speed": 1.0}
# Incoming dict-based update
raw = {"voice_id": "bob", "speed": 1.2}
delta = TTSSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert changed.keys() == {"voice", "speed"}
assert changed["voice"] == "alice"
assert changed["speed"] == 1.0
assert current.voice == "bob"
assert current.language == "en"
assert current.extra["speed"] == 1.2
assert current.extra["stability"] == 0.5
def test_from_mapping_preserves_model(self):
current = LLMSettings(model="gpt-4o", temperature=0.7)
delta = LLMSettings.from_mapping({"model": "gpt-4o-mini", "temperature": 0.9})
changed = current.apply_update(delta)
assert changed.keys() == {"model", "temperature"}
assert changed["model"] == "gpt-4o"
assert current.model == "gpt-4o-mini"
assert current.temperature == 0.9
# ---------------------------------------------------------------------------
# DeepgramSTTSettings: flat field apply_update
# ---------------------------------------------------------------------------
class TestDeepgramSTTSettingsApplyUpdate:
def _make_store(self, **kwargs) -> DeepgramSTTSettings:
"""Helper to build a store-mode DeepgramSTTSettings."""
defaults = dict(
model="nova-3-general",
language="en",
interim_results=True,
smart_format=False,
punctuate=True,
profanity_filter=True,
vad_events=False,
)
defaults.update(kwargs)
return DeepgramSTTSettings(**defaults)
def test_apply_update_merges_flat_fields_as_delta(self):
"""Only the given fields in the delta are merged."""
current = self._make_store()
assert current.punctuate is True
delta = DeepgramSTTSettings(punctuate=False)
changed = current.apply_update(delta)
assert current.punctuate is False
assert "punctuate" in changed
# Other fields are untouched
assert current.model == "nova-3-general"
assert current.language == "en"
def test_apply_update_model(self):
"""model field is updated directly."""
current = self._make_store()
assert current.model == "nova-3-general"
delta = DeepgramSTTSettings(model="nova-2")
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert "model" in changed
def test_apply_update_language(self):
"""language field is updated directly."""
current = self._make_store()
assert current.language == "en"
delta = DeepgramSTTSettings(language="es")
changed = current.apply_update(delta)
assert current.language == "es"
assert "language" in changed
def test_apply_update_no_change(self):
"""Delta with same values should report no changes."""
current = self._make_store()
delta = DeepgramSTTSettings(punctuate=True)
changed = current.apply_update(delta)
assert changed == {}
def test_apply_update_multiple_fields(self):
"""Multiple flat fields updated at once."""
current = self._make_store()
delta = DeepgramSTTSettings(model="nova-2", language="fr", punctuate=False)
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert current.language == "fr"
assert current.punctuate is False
assert changed.keys() == {"model", "language", "punctuate"}
class TestDeepgramSTTSettingsFromMapping:
def test_known_flat_fields_mapped_directly(self):
"""Deepgram field names map directly to flat settings fields."""
delta = DeepgramSTTSettings.from_mapping({"punctuate": False, "diarize": True})
assert delta.punctuate is False
assert delta.diarize is True
def test_model_and_language_top_level(self):
"""model and language are top-level fields."""
delta = DeepgramSTTSettings.from_mapping({"model": "nova-2", "language": "es"})
assert delta.model == "nova-2"
assert delta.language == "es"
def test_unknown_keys_go_to_extra(self):
"""Keys that aren't declared fields go to extra."""
delta = DeepgramSTTSettings.from_mapping({"unknown_param": 42})
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + known Deepgram fields + unknown keys are routed correctly."""
delta = DeepgramSTTSettings.from_mapping(
{"model": "nova-2", "punctuate": False, "unknown": "val"}
)
assert delta.model == "nova-2"
assert delta.punctuate is False
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
current = DeepgramSTTSettings(
model="nova-3-general",
language="en",
interim_results=True,
punctuate=True,
profanity_filter=True,
vad_events=False,
)
raw = {"punctuate": False, "diarize": True}
delta = DeepgramSTTSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert current.punctuate is False
assert current.diarize is True
# Unchanged fields stay put
assert current.model == "nova-3-general"
assert "punctuate" in changed
def test_roundtrip_model_via_dict(self):
"""Dict update with model should change top-level model field."""
current = DeepgramSTTSettings(
model="nova-3-general",
language="en",
)
raw = {"model": "nova-2"}
delta = DeepgramSTTSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert "model" in changed
# ---------------------------------------------------------------------------
# DeepgramSageMakerSTTSettings: smoke test that flat base is inherited
# ---------------------------------------------------------------------------
class TestDeepgramSageMakerSTTSettings:
def test_inherits_flat_settings_behavior(self):
"""Smoke test: SageMaker settings inherit the flat base correctly."""
store = DeepgramSageMakerSTTSettings(
model="nova-3",
language="en",
)
delta = DeepgramSageMakerSTTSettings(model="nova-2")
changed = store.apply_update(delta)
assert store.model == "nova-2"
assert store.language == "en"
assert "model" in changed
# ---------------------------------------------------------------------------
# DeepgramSTTService: settings initialization with extra syncing
# ---------------------------------------------------------------------------
class TestDeepgramSTTSettingsExtraSync:
"""Test that settings.extra values are synced to declared fields at init time."""
def _make_service(self, **kwargs):
with patch("pipecat.services.deepgram.stt.AsyncDeepgramClient"):
return DeepgramSTTService(api_key="test-key", sample_rate=16000, **kwargs)
def test_extra_synced_to_declared_field_at_init(self):
"""LiveOptions params that match declared fields are synced at init."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(numerals=True)
svc = self._make_service(live_options=live_options)
# 'numerals' is a declared DeepgramSTTSettings field,
# so it should be promoted from extra to the declared field
assert svc._settings.numerals is True
assert "numerals" not in svc._settings.extra
def test_declared_field_from_live_options(self):
"""LiveOptions fields that match DeepgramSTTSettings fields are applied."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(
punctuate=False,
diarize=True,
)
svc = self._make_service(live_options=live_options)
# These should be in the declared fields
assert svc._settings.punctuate is False
assert svc._settings.diarize is True
def test_sync_after_from_mapping_with_extra(self):
"""If we use from_mapping with keys matching declared fields, they sync."""
# Simulate a dict-style update with both declared and undeclared keys
raw_dict = {
"diarize": True, # matches declared field
"punctuate": False, # matches declared field
"custom_param": "value", # doesn't match - stays in extra
}
delta = DeepgramSTTSettings.from_mapping(raw_dict)
# After from_mapping, declared fields should be set
assert delta.diarize is True
assert delta.punctuate is False
# Unknown stays in extra
assert delta.extra["custom_param"] == "value"
# Now simulate syncing (though from_mapping already routes correctly)
delta._sync_extra_to_fields()
# Still the same - from_mapping already put them in the right place
assert delta.diarize is True
assert delta.punctuate is False
assert delta.extra["custom_param"] == "value"
def test_sync_promotes_extra_to_field_when_not_given(self):
"""_sync_extra_to_fields promotes extra dict entries to declared fields."""
settings = DeepgramSTTSettings()
# Manually populate extra with a key matching a declared field
settings.extra = {"diarize": True, "punctuate": False, "unknown": "value"}
# Before sync, fields are NOT_GIVEN
assert not is_given(settings.diarize)
assert not is_given(settings.punctuate)
# Sync it
settings._sync_extra_to_fields()
# Now the matching fields should be promoted
assert settings.diarize is True
assert settings.punctuate is False
# And removed from extra
assert "diarize" not in settings.extra
assert "punctuate" not in settings.extra
# Unknown stays
assert settings.extra["unknown"] == "value"
def test_sync_doesnt_overwrite_already_set_field(self):
"""If a field is already set, extra shouldn't overwrite it."""
settings = DeepgramSTTSettings(punctuate=True)
# Try to put a different value in extra
settings.extra = {"punctuate": False}
# Sync
settings._sync_extra_to_fields()
# The already-set field should win
assert settings.punctuate is True
# extra entry should still be removed to avoid confusion
assert "punctuate" not in settings.extra
def test_build_connect_kwargs_after_sync(self):
"""After syncing, _build_connect_kwargs should use the right values."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(
model="nova-2",
language="es",
punctuate=True,
diarize=False,
)
svc = self._make_service(live_options=live_options)
kwargs = svc._build_connect_kwargs()
# All should appear in connect kwargs
assert kwargs["model"] == "nova-2"
assert kwargs["language"] == "es"
assert kwargs["punctuate"] == "true"
assert kwargs["diarize"] == "false"
def test_unknown_params_stay_in_extra_and_appear_in_kwargs(self):
"""Unknown params (not matching fields) stay in extra and get forwarded."""
from pipecat.services.deepgram.stt import LiveOptions
# 'numerals' is now a declared field; 'custom_param' is not
live_options = LiveOptions(numerals=True, custom_param="test")
svc = self._make_service(live_options=live_options)
# 'numerals' is a declared field, so it should be promoted
assert svc._settings.numerals is True
# 'custom_param' is unknown, so it stays in extra
assert svc._settings.extra["custom_param"] == "test"
# Both forwarded to kwargs
kwargs = svc._build_connect_kwargs()
assert kwargs["numerals"] == "true"
assert kwargs["custom_param"] == "test"
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: apply_update with bidirectional sync
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsApplyUpdate:
def _make_store(self, **kwargs) -> OpenAIRealtimeLLMSettings:
"""Helper to build a store-mode OpenAIRealtimeLLMSettings."""
defaults = dict(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
defaults.update(kwargs)
return OpenAIRealtimeLLMSettings(**defaults)
def test_top_level_model_syncs_to_sp(self):
"""Updating top-level model should propagate to session_properties.model."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(model="gpt-realtime-2.0")
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_top_level_system_instruction_syncs_to_sp(self):
"""Updating top-level system_instruction should propagate to session_properties.instructions."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(system_instruction="Be helpful.")
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "Be helpful."
assert store.session_properties.instructions == "Be helpful."
def test_sp_replaces_wholesale(self):
"""session_properties in delta replaces the entire stored SP."""
store = self._make_store(
session_properties=events.SessionProperties(
output_modalities=["audio", "text"],
instructions="Old instructions.",
),
system_instruction="Old instructions.",
)
new_sp = events.SessionProperties(output_modalities=["text"])
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.output_modalities == ["text"]
# Fields not in the new SP become None (wholesale replacement)
# But model is synced from top-level
assert store.session_properties.model == "gpt-realtime-1.5"
def test_sp_model_syncs_to_top_level(self):
"""session_properties.model should sync to top-level model."""
store = self._make_store()
new_sp = events.SessionProperties(model="gpt-realtime-2.0")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_sp_instructions_syncs_to_top_level(self):
"""session_properties.instructions should sync to top-level system_instruction."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="New instructions.")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "New instructions."
assert store.session_properties.instructions == "New instructions."
def test_top_level_model_takes_precedence_over_sp_model(self):
"""When both model and session_properties.model are in the delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(model="sp-model")
delta = OpenAIRealtimeLLMSettings(model="top-model", session_properties=new_sp)
store.apply_update(delta)
assert store.model == "top-model"
assert store.session_properties.model == "top-model"
def test_top_level_si_takes_precedence_over_sp_instructions(self):
"""When both system_instruction and SP.instructions are in delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="sp instructions")
delta = OpenAIRealtimeLLMSettings(
system_instruction="top instructions",
session_properties=new_sp,
)
store.apply_update(delta)
assert store.system_instruction == "top instructions"
assert store.session_properties.instructions == "top instructions"
def test_non_synced_field_update_does_not_affect_sp(self):
"""Updating a non-synced field like temperature shouldn't touch session_properties."""
store = self._make_store(
session_properties=events.SessionProperties(instructions="Keep me."),
system_instruction="Keep me.",
)
original_sp = store.session_properties
delta = OpenAIRealtimeLLMSettings(temperature=0.5)
changed = store.apply_update(delta)
assert "temperature" in changed
assert store.temperature == 0.5
# SP should be untouched (same object)
assert store.session_properties is original_sp
assert store.session_properties.instructions == "Keep me."
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: from_mapping
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsFromMapping:
def test_sp_keys_route_to_session_properties(self):
"""SessionProperties fields (instructions, audio, etc.) route into nested SP."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{"instructions": "Be concise.", "output_modalities": ["text"]}
)
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be concise."
assert delta.session_properties.output_modalities == ["text"]
def test_model_routes_to_top_level(self):
"""model should go to the top-level field, not session_properties."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"model": "gpt-realtime-2.0"})
assert delta.model == "gpt-realtime-2.0"
# No session_properties should be created since no SP keys were present
assert not is_given(delta.session_properties)
def test_unknown_keys_go_to_extra(self):
"""Unrecognized keys should land in extra."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"unknown_param": 42})
assert not is_given(delta.model)
assert not is_given(delta.session_properties)
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + SP keys + unknown keys are routed correctly."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{
"model": "gpt-realtime-2.0",
"instructions": "Be helpful.",
"unknown": "val",
}
)
assert delta.model == "gpt-realtime-2.0"
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be helpful."
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
store = OpenAIRealtimeLLMSettings(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
raw = {"instructions": "Be concise.", "output_modalities": ["text"]}
delta = OpenAIRealtimeLLMSettings.from_mapping(raw)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.instructions == "Be concise."
assert store.session_properties.output_modalities == ["text"]
assert store.system_instruction == "Be concise."
# ---------------------------------------------------------------------------
# GrokRealtimeLLMSettings: apply_update
# ---------------------------------------------------------------------------
class TestGrokRealtimeSettingsApplyUpdate:
def _make_store(self, **kwargs) -> GrokRealtimeLLMSettings:
"""Helper to build a store-mode GrokRealtimeLLMSettings."""
defaults = dict(
model=None,
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=grok_events.SessionProperties(),
)
defaults.update(kwargs)
return GrokRealtimeLLMSettings(**defaults)
def test_top_level_system_instruction_syncs_to_sp(self):
"""Updating top-level system_instruction should propagate to session_properties.instructions."""
store = self._make_store()
delta = GrokRealtimeLLMSettings(system_instruction="Be helpful.")
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "Be helpful."
assert store.session_properties.instructions == "Be helpful."
def test_sp_replaces_wholesale(self):
"""session_properties in delta replaces the entire stored SP."""
store = self._make_store(
session_properties=grok_events.SessionProperties(
voice="Rex",
instructions="Old instructions.",
),
system_instruction="Old instructions.",
)
new_sp = grok_events.SessionProperties(voice="Sal")
delta = GrokRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.voice == "Sal"
# instructions is synced from top-level system_instruction
assert store.session_properties.instructions == "Old instructions."
def test_sp_instructions_syncs_to_top_level(self):
"""session_properties.instructions should sync to top-level system_instruction."""
store = self._make_store()
new_sp = grok_events.SessionProperties(instructions="New instructions.")
delta = GrokRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "New instructions."
assert store.session_properties.instructions == "New instructions."
def test_top_level_si_takes_precedence_over_sp_instructions(self):
"""When both system_instruction and SP.instructions are in delta, top-level wins."""
store = self._make_store()
new_sp = grok_events.SessionProperties(instructions="sp instructions")
delta = GrokRealtimeLLMSettings(
system_instruction="top instructions",
session_properties=new_sp,
)
store.apply_update(delta)
assert store.system_instruction == "top instructions"
assert store.session_properties.instructions == "top instructions"
def test_non_synced_field_update_does_not_affect_sp(self):
"""Updating a non-synced field like temperature shouldn't touch session_properties."""
store = self._make_store(
session_properties=grok_events.SessionProperties(instructions="Keep me."),
system_instruction="Keep me.",
)
original_sp = store.session_properties
delta = GrokRealtimeLLMSettings(temperature=0.5)
changed = store.apply_update(delta)
assert "temperature" in changed
assert store.temperature == 0.5
# SP should be untouched (same object)
assert store.session_properties is original_sp
assert store.session_properties.instructions == "Keep me."
# ---------------------------------------------------------------------------
# GrokRealtimeLLMSettings: from_mapping
# ---------------------------------------------------------------------------
class TestGrokRealtimeSettingsFromMapping:
def test_sp_keys_route_to_session_properties(self):
"""SessionProperties fields (instructions, voice, etc.) route into nested SP."""
delta = GrokRealtimeLLMSettings.from_mapping(
{"instructions": "Be concise.", "voice": "Rex"}
)
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be concise."
assert delta.session_properties.voice == "Rex"
def test_model_routes_to_top_level(self):
"""model should go to the top-level field, not session_properties."""
delta = GrokRealtimeLLMSettings.from_mapping({"model": "some-model"})
assert delta.model == "some-model"
# No session_properties should be created since no SP keys were present
assert not is_given(delta.session_properties)
def test_unknown_keys_go_to_extra(self):
"""Unrecognized keys should land in extra."""
delta = GrokRealtimeLLMSettings.from_mapping({"unknown_param": 42})
assert not is_given(delta.model)
assert not is_given(delta.session_properties)
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + SP keys + unknown keys are routed correctly."""
delta = GrokRealtimeLLMSettings.from_mapping(
{
"model": "some-model",
"instructions": "Be helpful.",
"unknown": "val",
}
)
assert delta.model == "some-model"
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be helpful."
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
store = GrokRealtimeLLMSettings(
model=None,
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=grok_events.SessionProperties(),
)
raw = {"instructions": "Be concise.", "voice": "Eve"}
delta = GrokRealtimeLLMSettings.from_mapping(raw)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.instructions == "Be concise."
assert store.session_properties.voice == "Eve"
assert store.system_instruction == "Be concise."

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -14,16 +14,206 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator = SimpleTextAggregator()
async def test_reset_aggregations(self):
assert await self.aggregator.aggregate("Hello ") == None
assert self.aggregator.text == "Hello "
text = "Hello "
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet
assert len(results) == 0
assert self.aggregator.text.text == "Hello"
await self.aggregator.reset()
assert self.aggregator.text == ""
assert self.aggregator.text.text == ""
async def test_simple_sentence(self):
assert await self.aggregator.aggregate("Hello ") == None
assert await self.aggregator.aggregate("Pipecat!") == "Hello Pipecat!"
assert self.aggregator.text == ""
text = "Hello Pipecat!"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after "!")
assert len(results) == 0
# Flush to get the pending sentence
aggregate = await self.aggregator.flush()
assert aggregate.text == "Hello Pipecat!"
assert aggregate.type == "sentence"
assert self.aggregator.text.text == ""
async def test_multiple_sentences(self):
assert await self.aggregator.aggregate("Hello Pipecat! How are ") == "Hello Pipecat!"
assert await self.aggregator.aggregate("you?") == " How are you?"
text = "Hello Pipecat! How are you?"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "H" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello Pipecat!"
# Flush to get the pending sentence
result = await self.aggregator.flush()
assert result.text == "How are you?"
async def test_lookahead_decimal_number(self):
"""Test that $29.95 is not split at $29."""
text = "Ask me for only $29.95/month."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Ask me for only $29.95/month."
async def test_lookahead_abbreviation(self):
"""Test that Mr. Smith is not split at Mr."""
text = "Hello Mr. Smith."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Hello Mr. Smith."
async def test_lookahead_actual_sentence_end(self):
"""Test that a real sentence end is detected after lookahead."""
text = "Hello world. Next sentence"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "N" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello world."
async def test_flush_pending_sentence(self):
"""Test that flush() returns pending sentence waiting for lookahead."""
text = "Hello world."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead)
assert len(results) == 0
# Call flush to get it
result = await self.aggregator.flush()
assert result is not None
assert result.text == "Hello world."
# Flush again should return None
assert await self.aggregator.flush() == None
async def test_flush_with_no_pending(self):
"""Test that flush() returns any remaining text in buffer."""
text = "Hello"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences
assert len(results) == 0
result = await self.aggregator.flush()
# flush() now returns any remaining text, not just pending lookahead
assert result is not None
assert result.text == "Hello"
# Buffer should be empty after flush
assert self.aggregator.text.text == ""
async def test_flush_after_lookahead_confirmed(self):
"""Test flush after lookahead has already confirmed sentence."""
text = "Hello. W"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "W" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello."
# flush() returns any remaining text (the "W" in this case)
result = await self.aggregator.flush()
assert result.text == "W"
async def test_japanese_multiple_sentences(self):
"""Test that Japanese sentences are properly split during streaming."""
text = "こんにちは。元気ですか?"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence detected when 元 arrives as lookahead after 。
assert len(results) == 1
assert results[0].text == "こんにちは。"
# Flush returns the second sentence
result = await self.aggregator.flush()
assert result.text == "元気ですか?"
async def test_japanese_sentence_with_lookahead(self):
"""Test that a Japanese sentence is detected with a lookahead character."""
text = "こんにちは。元"
results = [agg async for agg in self.aggregator.aggregate(text)]
# 。 triggers lookahead, then 元 confirms it
assert len(results) == 1
assert results[0].text == "こんにちは。"
# Flush returns remainder
result = await self.aggregator.flush()
assert result.text == ""
async def test_chinese_streaming_tokens(self):
"""Test Chinese text split across multiple streaming tokens."""
aggregator = SimpleTextAggregator()
tokens = ["你好", "世界", "", "下一", "句话", ""]
all_results = []
for token in tokens:
results = [agg async for agg in aggregator.aggregate(token)]
all_results.extend(results)
# First sentence detected when 下 arrives after 。
assert len(all_results) == 1
assert all_results[0].text == "你好世界。"
# Flush returns the second sentence
result = await aggregator.flush()
assert result.text == "下一句话。"
async def test_japanese_single_sentence_flush(self):
"""Test that a single Japanese sentence with no lookahead flushes correctly."""
text = "こんにちは。"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No lookahead yet - waiting
assert len(results) == 0
# Flush returns the complete sentence
result = await self.aggregator.flush()
assert result.text == "こんにちは。"
class TestSimpleTextAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SimpleTextAggregator(aggregation_type=AggregationType.TOKEN)
async def test_token_passthrough(self):
"""TOKEN mode yields text immediately without buffering."""
results = [agg async for agg in self.aggregator.aggregate("Hello")]
assert len(results) == 1
assert results[0].text == "Hello"
assert results[0].type == "token"
async def test_token_multiple_calls(self):
"""Each aggregate call yields its text independently."""
r1 = [agg async for agg in self.aggregator.aggregate("Hello ")]
r2 = [agg async for agg in self.aggregator.aggregate("world.")]
assert len(r1) == 1
assert r1[0].text == "Hello "
assert len(r2) == 1
assert r2[0].text == "world."
async def test_token_empty_text(self):
"""Empty text yields nothing."""
results = [agg async for agg in self.aggregator.aggregate("")]
assert len(results) == 0
async def test_token_flush_returns_none(self):
"""Flush returns None in TOKEN mode since nothing is buffered."""
await self.aggregator.aggregate("Hello").__anext__()
result = await self.aggregator.flush()
assert result is None
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -17,38 +17,107 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset()
# No tags involved, aggregate at end of sentence.
result = await self.aggregator.aggregate("Hello Pipecat!")
self.assertEqual(result, "Hello Pipecat!")
self.assertEqual(self.aggregator.text, "")
text = "Hello Pipecat!"
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "!"
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "Hello Pipecat!")
self.assertEqual(result.type, "sentence")
self.assertEqual(self.aggregator.text.text, "")
async def test_basic_tags(self):
await self.aggregator.reset()
# Tags involved, avoid aggregation during tags.
result = await self.aggregator.aggregate("My email is <spell>foo@pipecat.ai</spell>.")
self.assertEqual(result, "My email is <spell>foo@pipecat.ai</spell>.")
self.assertEqual(self.aggregator.text, "")
text = "My email is <spell>foo@pipecat.ai</spell>."
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "."
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "My email is <spell>foo@pipecat.ai</spell>.")
self.assertEqual(result.type, "sentence")
self.assertEqual(self.aggregator.text.text, "")
async def test_streaming_tags(self):
await self.aggregator.reset()
# Tags involved, stream small chunk of texts.
result = await self.aggregator.aggregate("My email is <sp")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text, "My email is <sp")
# Tags involved
text = "My email is <spell>foo.bar@pipecat.ai</spell>."
results = [agg async for agg in self.aggregator.aggregate(text)]
result = await self.aggregator.aggregate("ell>foo.")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text, "My email is <spell>foo.")
# Should still be waiting for lookahead after "."
self.assertEqual(len(results), 0)
self.assertEqual(self.aggregator.text.text, text)
self.assertEqual(self.aggregator.text.type, "sentence")
result = await self.aggregator.aggregate("bar@pipecat.")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text, "My email is <spell>foo.bar@pipecat.")
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, text)
self.assertEqual(self.aggregator.text.text, "")
self.assertEqual(self.aggregator.text.type, "sentence")
result = await self.aggregator.aggregate("ai</spe")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text, "My email is <spell>foo.bar@pipecat.ai</spe")
result = await self.aggregator.aggregate("ll>.")
self.assertEqual(result, "My email is <spell>foo.bar@pipecat.ai</spell>.")
self.assertEqual(self.aggregator.text, "")
class TestSkipTagsAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SkipTagsAggregator(
[("<spell>", "</spell>")], aggregation_type=AggregationType.TOKEN
)
async def test_token_no_tags(self):
"""No tags: text passes through immediately as TOKEN."""
results = [agg async for agg in self.aggregator.aggregate("Hello!")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hello!")
self.assertEqual(results[0].type, "token")
async def test_token_inside_tag_buffers(self):
"""Inside a tag, text is buffered until the closing tag is found."""
results = [agg async for agg in self.aggregator.aggregate("<spell>foo@bar")]
# Still inside tag, nothing yielded
self.assertEqual(len(results), 0)
# Close the tag
results = [agg async for agg in self.aggregator.aggregate("</spell>")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "<spell>foo@bar</spell>")
self.assertEqual(results[0].type, "token")
async def test_token_flush_unclosed_tag(self):
"""Flush with unclosed tag returns remaining text."""
async for _ in self.aggregator.aggregate("<spell>unclosed"):
pass
result = await self.aggregator.flush()
# TOKEN mode flush returns None (parent behavior)
self.assertIsNone(result)
async def test_token_text_around_tags(self):
"""Simulate word-by-word token delivery with tags."""
results = []
# Simulate LLM streaming tokens one at a time
for token in ["Hi ", "<spell>", "X", "</spell>", " bye"]:
async for agg in self.aggregator.aggregate(token):
results.append(agg)
self.assertEqual(len(results), 3)
# Text before tag passes through immediately
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
# Tagged content is buffered until the closing tag, then yielded whole
self.assertEqual(results[1].text, "<spell>X</spell>")
self.assertEqual(results[1].type, "token")
# Text after tag passes through immediately
self.assertEqual(results[2].text, " bye")
self.assertEqual(results[2].type, "token")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,337 @@
import asyncio
import unittest
from pipecat.frames.frames import (
BotConnectedFrame,
ClientConnectedFrame,
Frame,
StartFrame,
TextFrame,
)
from pipecat.observers.startup_timing_observer import (
StartupTimingObserver,
StartupTimingReport,
TransportTimingReport,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
class SlowStartProcessor(FrameProcessor):
"""A processor that sleeps during start to simulate slow initialization."""
def __init__(self, delay: float = 0.1, **kwargs):
super().__init__(**kwargs)
self._delay = delay
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
await asyncio.sleep(self._delay)
await self.push_frame(frame, direction)
class FastProcessor(FrameProcessor):
"""A processor with no start delay."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for StartupTimingObserver."""
async def test_timing_reported(self):
"""Test that startup timing is measured and reported."""
observer = StartupTimingObserver()
processor = SlowStartProcessor(delay=0.1)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
self.assertGreater(report.total_duration_secs, 0)
self.assertGreater(len(report.processor_timings), 0)
# Find our slow processor in the timings.
slow_timings = [
t for t in report.processor_timings if "SlowStartProcessor" in t.processor_name
]
self.assertEqual(len(slow_timings), 1)
self.assertGreaterEqual(slow_timings[0].duration_secs, 0.05)
async def test_processor_types_filter(self):
"""Test that processor_types filter limits which processors appear."""
observer = StartupTimingObserver(processor_types=(SlowStartProcessor,))
processor = SlowStartProcessor(delay=0.05)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
# Only SlowStartProcessor should be in the timings.
for t in report.processor_timings:
self.assertIn("SlowStartProcessor", t.processor_name)
async def test_report_emits_once(self):
"""Test that the report is emitted only once even with multiple frames."""
observer = StartupTimingObserver()
processor = FastProcessor()
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [
TextFrame(text="first"),
TextFrame(text="second"),
TextFrame(text="third"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame, TextFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
async def test_event_handler_receives_report(self):
"""Test that the event handler receives a proper StartupTimingReport."""
observer = StartupTimingObserver()
processor = SlowStartProcessor(delay=0.05)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
self.assertIsInstance(report, StartupTimingReport)
self.assertIsInstance(report.total_duration_secs, float)
self.assertGreater(report.start_time, 0)
for timing in report.processor_timings:
self.assertIsInstance(timing.processor_name, str)
self.assertIsInstance(timing.duration_secs, float)
self.assertGreaterEqual(timing.start_offset_secs, 0)
async def test_excludes_internal_processors(self):
"""Test that internal pipeline processors are excluded by default."""
observer = StartupTimingObserver()
processor = FastProcessor()
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
# No internal processors (PipelineSource, PipelineSink, Pipeline) in the report.
internal_names = ("Pipeline#", "PipelineTask#")
for t in report.processor_timings:
for prefix in internal_names:
self.assertNotIn(
prefix,
t.processor_name,
f"Internal processor {t.processor_name} should be excluded by default",
)
async def test_transport_timing_client_only(self):
"""Test that ClientConnectedFrame emits on_transport_timing_report."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
report = transport_reports[0]
self.assertIsInstance(report, TransportTimingReport)
self.assertGreater(report.start_time, 0)
self.assertGreater(report.client_connected_secs, 0)
self.assertIsNone(report.bot_connected_secs)
async def test_transport_timing_only_first_client(self):
"""Test that only the first ClientConnectedFrame triggers the event."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
ClientConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[ClientConnectedFrame, ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
async def test_transport_timing_without_start_frame(self):
"""Test that ClientConnectedFrame before StartFrame does not crash."""
observer = StartupTimingObserver()
# Directly call on_push_frame with a ClientConnectedFrame before any
# StartFrame has been seen. This should be a no-op (no crash).
from pipecat.observers.base_observer import FramePushed
processor = FastProcessor()
destination = FastProcessor()
data = FramePushed(
source=processor,
destination=destination,
frame=ClientConnectedFrame(),
direction=FrameDirection.DOWNSTREAM,
timestamp=1000,
)
await observer.on_push_frame(data)
# No event should have been emitted.
self.assertFalse(observer._transport_timing_reported)
async def test_bot_and_client_connected(self):
"""Test that BotConnectedFrame timing is included in the transport report."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
report = transport_reports[0]
self.assertGreater(report.client_connected_secs, 0)
self.assertIsNotNone(report.bot_connected_secs)
self.assertGreater(report.bot_connected_secs, 0)
# Client connected should be >= bot connected.
self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs)
async def test_bot_connected_only_first(self):
"""Test that only the first BotConnectedFrame is recorded."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[
BotConnectedFrame,
BotConnectedFrame,
ClientConnectedFrame,
TextFrame,
],
observers=[observer],
)
# Only one transport report, with bot timing from first frame.
self.assertEqual(len(transport_reports), 1)
self.assertIsNotNone(transport_reports[0].bot_connected_secs)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -9,10 +9,12 @@ import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallInProgressFrame,
FunctionCallFromLLM,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InputAudioRawFrame,
InterimTranscriptionFrame,
InterruptionFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
@@ -148,54 +150,59 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_returned_frames,
)
# TODO: Revisit once we figure out how to test SystemFrames and DataFrames
# async def test_function_call_strategy(self):
# filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.FUNCTION_CALL}))
async def test_function_call_strategy(self):
filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.FUNCTION_CALL}))
# frames_to_send = [
# VADUserStartedSpeakingFrame(), # Should pass through initially
# UserStartedSpeakingFrame(), # Should pass through initially
# VADUserStoppedSpeakingFrame(),
# UserStoppedSpeakingFrame(),
# FunctionCallInProgressFrame(
# function_name="get_weather",
# tool_call_id="call_123",
# arguments='{"location": "San Francisco"}',
# ), # Start function call
# VADUserStartedSpeakingFrame(), # Should be suppressed
# UserStartedSpeakingFrame(), # Should be suppressed
# VADUserStoppedSpeakingFrame(), # Should be suppressed
# UserStoppedSpeakingFrame(), # Should be suppressed
# FunctionCallResultFrame(
# function_name="get_weather",
# tool_call_id="call_123",
# arguments='{"location": "San Francisco"}',
# result={"temperature": 22},
# ), # End function call
# VADUserStartedSpeakingFrame(), # Should pass through again
# UserStartedSpeakingFrame(), # Should pass through again
# VADUserStoppedSpeakingFrame(),
# UserStoppedSpeakingFrame(),
# ]
frames_to_send = [
VADUserStartedSpeakingFrame(), # Should pass through initially
UserStartedSpeakingFrame(), # Should pass through initially
VADUserStoppedSpeakingFrame(), # Should pass through initially
UserStoppedSpeakingFrame(), # Should pass through initially
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name="get_weather",
tool_call_id="call_123",
arguments='{"location": "San Francisco"}',
context=None,
)
]
), # Start function call
VADUserStartedSpeakingFrame(), # Should be suppressed
UserStartedSpeakingFrame(), # Should be suppressed
VADUserStoppedSpeakingFrame(), # Should be suppressed
UserStoppedSpeakingFrame(), # Should be suppressed
FunctionCallResultFrame(
function_name="get_weather",
tool_call_id="call_123",
arguments='{"location": "San Francisco"}',
result={"temperature": 22},
), # End function call
SleepFrame(),
VADUserStartedSpeakingFrame(), # Should pass through again
UserStartedSpeakingFrame(), # Should pass through again
VADUserStoppedSpeakingFrame(),
UserStoppedSpeakingFrame(),
]
# expected_returned_frames = [
# VADUserStartedSpeakingFrame,
# UserStartedSpeakingFrame,
# VADUserStoppedSpeakingFrame,
# UserStoppedSpeakingFrame,
# FunctionCallInProgressFrame,
# FunctionCallResultFrame,
# VADUserStartedSpeakingFrame,
# UserStartedSpeakingFrame,
# VADUserStoppedSpeakingFrame,
# UserStoppedSpeakingFrame,
# ]
expected_returned_frames = [
VADUserStartedSpeakingFrame,
UserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
FunctionCallsStartedFrame,
FunctionCallResultFrame,
VADUserStartedSpeakingFrame,
UserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
]
# await run_test(
# filter,
# frames_to_send=frames_to_send,
# expected_down_frames=expected_returned_frames,
# )
await run_test(
filter,
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
async def test_mute_until_first_bot_complete_strategy(self):
filter = STTMuteFilter(
@@ -320,3 +327,28 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
async def test_interruption_frame_suppressed_when_muted(self):
"""Test that InterruptionFrame is suppressed when the filter is muted."""
filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.ALWAYS}))
frames_to_send = [
BotStartedSpeakingFrame(),
InterruptionFrame(),
BotStoppedSpeakingFrame(),
]
expected_returned_frames = [
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
filter,
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,117 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from dataclasses import dataclass
from pipecat.frames.frames import Frame, TextFrame
from pipecat.pipeline.sync_parallel_pipeline import FrameOrder, SyncParallelPipeline
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@dataclass
class TaggedFrame(Frame):
"""A simple tagged frame for testing pipeline ordering."""
tag: str = ""
def __str__(self):
return f"{self.name}(tag: {self.tag})"
class EmitTaggedFrameProcessor(FrameProcessor):
"""Emits a TaggedFrame for every TextFrame it receives.
Used to produce distinguishable output from different pipelines so tests
can verify ordering.
"""
def __init__(self, tag: str, *, delay: float = 0, **kwargs):
super().__init__(**kwargs)
self._tag = tag
self._delay = delay
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
if self._delay > 0:
await asyncio.sleep(self._delay)
await self.push_frame(TaggedFrame(tag=self._tag))
else:
await self.push_frame(frame, direction)
class TestSyncParallelPipeline(unittest.IsolatedAsyncioTestCase):
async def test_dedup_multiple_frames(self):
"""Identical frames from multiple paths should be deduplicated."""
pipeline = SyncParallelPipeline([IdentityFilter()], [IdentityFilter()])
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
expected_down_frames = [TextFrame, TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_arrival_order(self):
"""With FrameOrder.ARRIVAL, a slow first pipeline's frames should
arrive after a fast second pipeline's frames."""
pipeline = SyncParallelPipeline(
[EmitTaggedFrameProcessor("slow", delay=0.05)],
[EmitTaggedFrameProcessor("fast")],
frame_order=FrameOrder.ARRIVAL,
)
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
)
tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)]
assert tags == [
"fast",
"slow",
"fast",
"slow",
], f"Expected fast before slow in each batch, got {tags}"
async def test_pipeline_order(self):
"""With FrameOrder.PIPELINE and multiple input frames, each batch
should follow pipeline definition order regardless of processing speed."""
pipeline = SyncParallelPipeline(
[EmitTaggedFrameProcessor("slow", delay=0.05)],
[EmitTaggedFrameProcessor("fast")],
frame_order=FrameOrder.PIPELINE,
)
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
)
tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)]
assert tags == [
"slow",
"fast",
"slow",
"fast",
], f"Expected pipeline definition order (slow, fast) in each batch, got {tags}"
async def test_default_is_arrival(self):
"""The default frame_order should be ARRIVAL."""
pipeline = SyncParallelPipeline([IdentityFilter()])
assert pipeline._frame_order == FrameOrder.ARRIVAL
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,127 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
try:
from opentelemetry.sdk.trace import TracerProvider
HAS_OPENTELEMETRY = True
except ImportError:
HAS_OPENTELEMETRY = False
from pipecat.utils.tracing.tracing_context import TracingContext
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
class TestTracingContext(unittest.TestCase):
"""Tests for TracingContext."""
@classmethod
def setUpClass(cls):
"""Set up a tracer provider for generating span contexts."""
cls._provider = TracerProvider()
cls._tracer = cls._provider.get_tracer("test")
def test_initial_state_is_empty(self):
"""Test that a new TracingContext starts with no context set."""
ctx = TracingContext()
self.assertIsNone(ctx.get_conversation_context())
self.assertIsNone(ctx.get_turn_context())
self.assertIsNone(ctx.conversation_id)
def test_set_and_get_conversation_context(self):
"""Test setting and retrieving conversation context."""
ctx = TracingContext()
span = self._tracer.start_span("conv")
span_context = span.get_span_context()
ctx.set_conversation_context(span_context, "conv-123")
self.assertIsNotNone(ctx.get_conversation_context())
self.assertEqual(ctx.conversation_id, "conv-123")
span.end()
def test_clear_conversation_context(self):
"""Test clearing conversation context by passing None."""
ctx = TracingContext()
span = self._tracer.start_span("conv")
ctx.set_conversation_context(span.get_span_context(), "conv-123")
self.assertIsNotNone(ctx.get_conversation_context())
ctx.set_conversation_context(None)
self.assertIsNone(ctx.get_conversation_context())
self.assertIsNone(ctx.conversation_id)
span.end()
def test_set_and_get_turn_context(self):
"""Test setting and retrieving turn context."""
ctx = TracingContext()
span = self._tracer.start_span("turn")
span_context = span.get_span_context()
ctx.set_turn_context(span_context)
self.assertIsNotNone(ctx.get_turn_context())
span.end()
def test_clear_turn_context(self):
"""Test clearing turn context by passing None."""
ctx = TracingContext()
span = self._tracer.start_span("turn")
ctx.set_turn_context(span.get_span_context())
self.assertIsNotNone(ctx.get_turn_context())
ctx.set_turn_context(None)
self.assertIsNone(ctx.get_turn_context())
span.end()
def test_generate_conversation_id(self):
"""Test that generated conversation IDs are unique UUIDs."""
id1 = TracingContext.generate_conversation_id()
id2 = TracingContext.generate_conversation_id()
self.assertIsInstance(id1, str)
self.assertNotEqual(id1, id2)
def test_instances_are_isolated(self):
"""Test that two TracingContext instances do not share state."""
ctx_a = TracingContext()
ctx_b = TracingContext()
span = self._tracer.start_span("turn")
ctx_a.set_turn_context(span.get_span_context())
ctx_a.set_conversation_context(span.get_span_context(), "conv-a")
# ctx_b should still be empty
self.assertIsNone(ctx_b.get_turn_context())
self.assertIsNone(ctx_b.get_conversation_context())
self.assertIsNone(ctx_b.conversation_id)
span.end()
def test_conversation_and_turn_are_independent(self):
"""Test that clearing turn context does not affect conversation context."""
ctx = TracingContext()
conv_span = self._tracer.start_span("conv")
turn_span = self._tracer.start_span("turn")
ctx.set_conversation_context(conv_span.get_span_context(), "conv-1")
ctx.set_turn_context(turn_span.get_span_context())
# Clear turn but conversation should remain
ctx.set_turn_context(None)
self.assertIsNone(ctx.get_turn_context())
self.assertIsNotNone(ctx.get_conversation_context())
self.assertEqual(ctx.conversation_id, "conv-1")
conv_span.end()
turn_span.end()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -11,10 +11,15 @@ from datetime import datetime, timezone
from typing import List, Tuple, cast
from pipecat.frames.frames import (
AggregationType,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
CancelFrame,
InterruptionFrame,
LLMThoughtEndFrame,
LLMThoughtStartFrame,
LLMThoughtTextFrame,
ThoughtTranscriptionMessage,
TranscriptionFrame,
TranscriptionMessage,
TranscriptionUpdateFrame,
@@ -130,11 +135,11 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(), # Wait for StartedSpeaking to process
TTSTextFrame(text="Hello"),
TTSTextFrame(text="world!"),
TTSTextFrame(text="How"),
TTSTextFrame(text="are"),
TTSTextFrame(text="you?"),
TTSTextFrame(text="Hello", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="world!", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="How", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="are", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="you?", aggregated_by=AggregationType.WORD),
SleepFrame(), # Wait for text frames to queue
BotStoppedSpeakingFrame(),
]
@@ -195,9 +200,9 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(),
TTSTextFrame(text=""), # Empty text
TTSTextFrame(text=" "), # Just whitespace
TTSTextFrame(text="\n"), # Just newline
TTSTextFrame(text="", aggregated_by=AggregationType.WORD), # Empty text
TTSTextFrame(text=" ", aggregated_by=AggregationType.WORD), # Just whitespace
TTSTextFrame(text="\n", aggregated_by=AggregationType.WORD), # Just newline
BotStoppedSpeakingFrame(),
# Pipeline ends here; run_test will automatically send EndFrame
]
@@ -235,14 +240,14 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(),
TTSTextFrame(text="Hello"),
TTSTextFrame(text="world!"),
TTSTextFrame(text="Hello", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="world!", aggregated_by=AggregationType.WORD),
SleepFrame(),
InterruptionFrame(), # User interrupts here
SleepFrame(),
BotStartedSpeakingFrame(),
TTSTextFrame(text="New"),
TTSTextFrame(text="response"),
TTSTextFrame(text="New", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="response", aggregated_by=AggregationType.WORD),
SleepFrame(),
BotStoppedSpeakingFrame(),
]
@@ -299,8 +304,8 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(),
TTSTextFrame(text="Hello"),
TTSTextFrame(text="world"),
TTSTextFrame(text="Hello", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="world", aggregated_by=AggregationType.WORD),
# Pipeline ends here; run_test will automatically send EndFrame
]
@@ -338,8 +343,8 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(),
TTSTextFrame(text="Hello"),
TTSTextFrame(text="world"),
TTSTextFrame(text="Hello", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="world", aggregated_by=AggregationType.WORD),
SleepFrame(), # Ensure messages are processed
CancelFrame(),
]
@@ -401,8 +406,8 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
BotStartedSpeakingFrame(),
SleepFrame(),
TTSTextFrame(text="Assistant"),
TTSTextFrame(text="message"),
TTSTextFrame(text="Assistant", aggregated_by=AggregationType.WORD),
TTSTextFrame(text="message", aggregated_by=AggregationType.WORD),
BotStoppedSpeakingFrame(),
]
@@ -439,7 +444,7 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
# Test the specific pattern shared
def make_tts_text_frame(text: str) -> TTSTextFrame:
frame = TTSTextFrame(text=text)
frame = TTSTextFrame(text=text, aggregated_by=AggregationType.WORD)
frame.includes_inter_frame_spaces = True
return frame
@@ -484,3 +489,310 @@ class TestUserTranscriptProcessor(unittest.IsolatedAsyncioTestCase):
self.assertEqual(message.role, "assistant")
# Should be properly joined without extra spaces
self.assertEqual(message.content, "Hello there! How's it going?")
class TestThoughtTranscription(unittest.IsolatedAsyncioTestCase):
"""Tests for thought transcription in AssistantTranscriptProcessor"""
async def test_basic_thought_transcription(self):
"""Test basic thought frame processing"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
# Create frames for a simple thought
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="Let me think about this..."),
LLMThoughtEndFrame(),
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
TranscriptionUpdateFrame,
LLMThoughtEndFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify update was received
self.assertEqual(len(received_updates), 1)
message = received_updates[0].messages[0]
self.assertIsInstance(message, ThoughtTranscriptionMessage)
self.assertEqual(message.content, "Let me think about this...")
self.assertIsNotNone(message.timestamp)
async def test_thought_aggregation(self):
"""Test that thought text frames are properly aggregated"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
# Create frames simulating chunked thought text
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="The user "),
LLMThoughtTextFrame(text="is asking "),
LLMThoughtTextFrame(text="about electric "),
LLMThoughtTextFrame(text="cars."),
LLMThoughtEndFrame(),
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
LLMThoughtTextFrame,
LLMThoughtTextFrame,
LLMThoughtTextFrame,
TranscriptionUpdateFrame,
LLMThoughtEndFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify aggregation
self.assertEqual(len(received_updates), 1)
message = received_updates[0].messages[0]
self.assertIsInstance(message, ThoughtTranscriptionMessage)
self.assertEqual(message.content, "The user is asking about electric cars.")
async def test_thought_with_interruption(self):
"""Test that thoughts are properly captured when interrupted"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="I need to consider "),
LLMThoughtTextFrame(text="multiple factors"),
SleepFrame(),
InterruptionFrame(), # User interrupts
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
LLMThoughtTextFrame,
InterruptionFrame,
TranscriptionUpdateFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify thought was captured on interruption
self.assertEqual(len(received_updates), 1)
message = received_updates[0].messages[0]
self.assertIsInstance(message, ThoughtTranscriptionMessage)
self.assertEqual(message.content, "I need to consider multiple factors")
async def test_thought_with_cancel(self):
"""Test that thoughts are properly captured when cancelled"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="Starting analysis"),
SleepFrame(),
CancelFrame(),
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
CancelFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
send_end_frame=False,
)
# Verify thought was captured on cancellation
self.assertEqual(len(received_updates), 1)
message = received_updates[0].messages[0]
self.assertIsInstance(message, ThoughtTranscriptionMessage)
self.assertEqual(message.content, "Starting analysis")
async def test_thought_with_end_frame(self):
"""Test that thoughts are captured when pipeline ends normally"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="Final thought"),
# Pipeline ends here; run_test will automatically send EndFrame
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
TranscriptionUpdateFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify thought was captured on EndFrame
self.assertEqual(len(received_updates), 1)
message = received_updates[0].messages[0]
self.assertIsInstance(message, ThoughtTranscriptionMessage)
self.assertEqual(message.content, "Final thought")
async def test_multiple_thoughts(self):
"""Test multiple separate thoughts in sequence"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
frames_to_send = [
# First thought
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="First consideration"),
LLMThoughtEndFrame(),
# Second thought
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text="Second consideration"),
LLMThoughtEndFrame(),
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
TranscriptionUpdateFrame,
LLMThoughtEndFrame,
LLMThoughtStartFrame,
LLMThoughtTextFrame,
TranscriptionUpdateFrame,
LLMThoughtEndFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify both thoughts were captured
self.assertEqual(len(received_updates), 2)
first_message = received_updates[0].messages[0]
self.assertIsInstance(first_message, ThoughtTranscriptionMessage)
self.assertEqual(first_message.content, "First consideration")
second_message = received_updates[1].messages[0]
self.assertIsInstance(second_message, ThoughtTranscriptionMessage)
self.assertEqual(second_message.content, "Second consideration")
async def test_empty_thought_handling(self):
"""Test that empty thoughts are not emitted"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
frames_to_send = [
LLMThoughtStartFrame(),
LLMThoughtTextFrame(text=""), # Empty
LLMThoughtTextFrame(text=" "), # Just whitespace
LLMThoughtEndFrame(),
]
expected_down_frames = [
LLMThoughtStartFrame,
LLMThoughtTextFrame,
LLMThoughtTextFrame,
LLMThoughtEndFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify no updates emitted for empty content
self.assertEqual(len(received_updates), 0)
async def test_thought_without_start_frame(self):
"""Test that thought text without start frame is ignored"""
processor = AssistantTranscriptProcessor(process_thoughts=True)
received_updates: List[TranscriptionUpdateFrame] = []
@processor.event_handler("on_transcript_update")
async def handle_update(proc, frame: TranscriptionUpdateFrame):
received_updates.append(frame)
# Send thought text without start frame
frames_to_send = [
LLMThoughtTextFrame(text="This should be ignored"),
LLMThoughtEndFrame(),
]
expected_down_frames = [
LLMThoughtTextFrame,
LLMThoughtEndFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify no updates since thought wasn't properly started
self.assertEqual(len(received_updates), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,410 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for frame ordering across TTS service types.
Covers three patterns:
- HTTP TTS services (e.g. CartesiaHttpTTSService): yield audio frames synchronously.
- WebSocket TTS services without pause (e.g. CartesiaTTSService): deliver audio via
append_to_audio_context from a background receive loop, no frame-processing pause.
- WebSocket TTS services with pause (e.g. ElevenLabsTTSService): same delivery
mechanism, but pause downstream frame processing while audio is in flight.
For all three patterns we verify:
AggregatedTextFrame → TTSStartedFrame → TTSAudioRawFrame (1+) → TTSStoppedFrame → FooFrame
repeated for each TTSSpeakFrame, with no cross-group contamination.
Also covers LLM response flow with push_text_frames=True (non-word-timestamp TTS):
verifies TTSTextFrame ordering relative to LLMFullResponseEndFrame.
"""
import asyncio
import unittest
from dataclasses import dataclass
from typing import AsyncGenerator, List, Sequence, Tuple
import pytest
from pipecat.frames.frames import (
AggregatedTextFrame,
DataFrame,
Frame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
TextFrame,
TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame,
TTSStoppedFrame,
TTSTextFrame,
)
from pipecat.services.tts_service import TTSService
from pipecat.tests.utils import run_test
# ---------------------------------------------------------------------------
# Test-only frame
# ---------------------------------------------------------------------------
_FAKE_AUDIO = b"\x00\x01" * 320 # 320 bytes of silence
_SAMPLE_RATE = 16000
@dataclass
class FooFrame(DataFrame):
"""Marker frame used to verify relative ordering against TTS audio frames."""
label: str = ""
# ---------------------------------------------------------------------------
# Mock TTS services
# ---------------------------------------------------------------------------
class MockHttpTTSService(TTSService):
"""Simulates an HTTP TTS service (e.g. CartesiaHttpTTSService).
Audio frames are yielded synchronously from run_tts(), so the audio context
is fully populated before the next downstream frame is processed.
TTSStoppedFrame is appended by the base class in on_turn_context_completed()
once it detects _is_yielding_frames_synchronously is True.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_stop_frames=True,
push_text_frames=False,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
yield TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
)
class MockHttpPushTextTTSService(TTSService):
"""Simulates an HTTP TTS service with push_text_frames=True.
Used to test that LLMFullResponseEndFrame is emitted after all TTSTextFrames
when the TTS service generates text frames itself (non-word-timestamp mode).
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_stop_frames=True,
push_text_frames=True,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
yield TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
)
class MockWebSocketTTSService(TTSService):
"""Simulates a WebSocket TTS service without frame-processing pause (e.g. CartesiaTTSService).
run_tts() is an empty async generator (signals async delivery). A background
task appends audio frames and the TTSStoppedFrame to the audio context after a
short delay, mimicking real WebSocket receive-loop behaviour.
pause_frame_processing=False means downstream frames (FooFrame) are NOT held.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_text_frames=False,
pause_frame_processing=False,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
async def _deliver_audio():
await asyncio.sleep(0.01)
await self.append_to_audio_context(
context_id,
TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
),
)
await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id))
await self.remove_audio_context(context_id)
self.create_task(_deliver_audio(), name=f"mock_ws_deliver_{context_id}")
if False:
yield # make this an async generator without yielding anything
class MockWebSocketPauseTTSService(TTSService):
"""Simulates a WebSocket TTS service WITH frame-processing pause (e.g. ElevenLabsTTSService).
Identical to MockWebSocketTTSService except pause_frame_processing=True.
on_audio_context_completed() resumes downstream processing once the full
audio context has been pushed, guaranteeing FooFrame arrives after TTSStoppedFrame.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_text_frames=False,
pause_frame_processing=True,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def on_audio_context_completed(self, context_id: str):
# Resume frame processing after the audio context is fully played out.
await self._maybe_resume_frame_processing()
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
async def _deliver_audio():
await asyncio.sleep(0.01)
await self.append_to_audio_context(
context_id,
TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
),
)
await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id))
await self.remove_audio_context(context_id)
self.create_task(_deliver_audio(), name=f"mock_ws_pause_deliver_{context_id}")
if False:
yield
# ---------------------------------------------------------------------------
# Assertion helper
# ---------------------------------------------------------------------------
def _assert_group_ordering(
down_frames: Sequence[Frame],
expected_groups: List[Tuple[str, str]],
) -> None:
"""Assert two (or more) TTS+FooFrame groups are in strict order.
Args:
down_frames: All downstream frames received by the test sink.
expected_groups: List of (tts_text, foo_label) pairs, one per TTSSpeakFrame.
tts_text is unused in assertions today but included for readability.
"""
relevant = [
f
for f in down_frames
if isinstance(
f, (AggregatedTextFrame, TTSStartedFrame, TTSAudioRawFrame, TTSStoppedFrame, FooFrame)
)
]
# Locate the FooFrames that delimit groups.
foo_indices = [i for i, f in enumerate(relevant) if isinstance(f, FooFrame)]
assert len(foo_indices) == len(expected_groups), (
f"Expected {len(expected_groups)} FooFrames, got {len(foo_indices)}.\n"
f"Relevant frames: {[type(f).__name__ for f in relevant]}"
)
# Build groups: everything up to and including each FooFrame.
groups: List[List[Frame]] = []
prev = 0
for idx in foo_indices:
groups.append(relevant[prev : idx + 1])
prev = idx + 1
for group, (_, foo_label) in zip(groups, expected_groups):
types = [type(f) for f in group]
type_names = [t.__name__ for t in types]
assert AggregatedTextFrame in types, (
f"Group {foo_label!r}: missing AggregatedTextFrame. Got: {type_names}"
)
assert TTSStartedFrame in types, (
f"Group {foo_label!r}: missing TTSStartedFrame. Got: {type_names}"
)
assert TTSAudioRawFrame in types, (
f"Group {foo_label!r}: missing TTSAudioRawFrame. Got: {type_names}"
)
assert TTSStoppedFrame in types, (
f"Group {foo_label!r}: missing TTSStoppedFrame. Got: {type_names}"
)
started_idx = types.index(TTSStartedFrame)
stopped_idx = types.index(TTSStoppedFrame)
foo_idx = types.index(FooFrame)
assert started_idx < stopped_idx, (
f"Group {foo_label!r}: TTSStartedFrame (pos {started_idx}) must precede "
f"TTSStoppedFrame (pos {stopped_idx}). Got: {type_names}"
)
assert stopped_idx < foo_idx, (
f"Group {foo_label!r}: TTSStoppedFrame (pos {stopped_idx}) must precede "
f"FooFrame (pos {foo_idx}). Got: {type_names}"
)
# All frames between TTSStartedFrame and TTSStoppedFrame must be audio.
mid_types = types[started_idx + 1 : stopped_idx]
for t in mid_types:
assert t is TTSAudioRawFrame, (
f"Group {foo_label!r}: unexpected frame {t.__name__!r} between "
f"TTSStartedFrame and TTSStoppedFrame. Got: {type_names}"
)
# Check the FooFrame label.
actual_label = group[foo_idx].label
assert actual_label == foo_label, (
f"Expected FooFrame(label={foo_label!r}), got label={actual_label!r}"
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
_GROUPS = [("test 1", "1"), ("test 2", "2")]
def _make_frames_no_sleep() -> List[Frame]:
"""Return two TTSSpeakFrame+FooFrame pairs sent back-to-back.
Only correct for services that pause downstream processing until the audio
context is fully consumed (pause_frame_processing=True + on_audio_context_completed).
"""
return [
TTSSpeakFrame(text="test 1", append_to_context=False),
FooFrame(label="1"),
TTSSpeakFrame(text="test 2", append_to_context=False),
FooFrame(label="2"),
]
def _print_frames_received(frames_received) -> None:
print("FRAMES RECEIVED:")
for frame in frames_received[0]:
print(frame.name)
@pytest.mark.asyncio
async def test_http_tts_frame_ordering():
"""HTTP TTS services yield audio synchronously."""
tts = MockHttpTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
# only for debugging
_print_frames_received(frames_received)
_assert_group_ordering(frames_received[0], _GROUPS)
@pytest.mark.asyncio
async def test_websocket_tts_no_pause_frame_ordering():
"""WebSocket TTS services without pause_frame_processing."""
tts = MockWebSocketTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
_assert_group_ordering(frames_received[0], _GROUPS)
@pytest.mark.asyncio
async def test_websocket_tts_with_pause_frame_ordering():
"""WebSocket TTS services with pause_frame_processing=True."""
tts = MockWebSocketPauseTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
_assert_group_ordering(frames_received[0], _GROUPS)
@pytest.mark.asyncio
async def test_http_push_text_llm_response_end_after_tts_text():
"""LLMFullResponseEndFrame must arrive after all TTSTextFrames.
Simulates an LLM response producing multiple sentences through an HTTP TTS
service with push_text_frames=True. Each sentence is sent as a separate
TextFrame terminated by a period so the sentence aggregator flushes it.
The final sentence is flushed by the LLMFullResponseEndFrame itself.
Expected downstream ordering:
LLMFullResponseStartFrame
... TTSTextFrame (per sentence) ...
LLMFullResponseEndFrame ← must come AFTER all TTSTextFrames
"""
tts = MockHttpPushTextTTSService()
# Two sentences: the first ends with a period (triggers aggregator flush),
# the second does NOT (will be flushed by LLMFullResponseEndFrame).
frames_to_send = [
LLMFullResponseStartFrame(),
TextFrame(text="Hello there. "),
TextFrame(text="How are you?"),
LLMFullResponseEndFrame(),
]
frames_received = await run_test(tts, frames_to_send=frames_to_send)
down = frames_received[0]
# Collect relevant frame types for ordering check.
relevant = [
f
for f in down
if isinstance(f, (LLMFullResponseStartFrame, TTSTextFrame, LLMFullResponseEndFrame))
]
type_names = [type(f).__name__ for f in relevant]
# There should be exactly one LLMFullResponseStartFrame, 2 TTSTextFrames, 1 LLMFullResponseEndFrame.
tts_text_frames = [f for f in relevant if isinstance(f, TTSTextFrame)]
end_frames = [f for f in relevant if isinstance(f, LLMFullResponseEndFrame)]
start_frames = [f for f in relevant if isinstance(f, LLMFullResponseStartFrame)]
assert len(start_frames) == 1, (
f"Expected 1 LLMFullResponseStartFrame, got {len(start_frames)}: {type_names}"
)
assert len(tts_text_frames) == 2, (
f"Expected 2 TTSTextFrames, got {len(tts_text_frames)}: {type_names}"
)
assert len(end_frames) == 1, (
f"Expected 1 LLMFullResponseEndFrame, got {len(end_frames)}: {type_names}"
)
# The critical check: LLMFullResponseEndFrame must come after ALL TTSTextFrames.
end_idx = relevant.index(end_frames[0])
last_tts_text_idx = max(relevant.index(f) for f in tts_text_frames)
assert last_tts_text_idx < end_idx, (
f"LLMFullResponseEndFrame (pos {end_idx}) must come after the last "
f"TTSTextFrame (pos {last_tts_text_idx}). Got: {type_names}"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,505 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import threading
import unittest
try:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
HAS_OPENTELEMETRY = True
except ImportError:
HAS_OPENTELEMETRY = False
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.utils.tracing.tracing_context import TracingContext
from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver
if HAS_OPENTELEMETRY:
class _InMemorySpanExporter(SpanExporter):
"""Simple in-memory span exporter for testing."""
def __init__(self):
"""Initialize the exporter."""
self._spans = []
self._lock = threading.Lock()
def export(self, spans):
"""Export spans to memory."""
with self._lock:
self._spans.extend(spans)
return SpanExportResult.SUCCESS
def get_finished_spans(self):
"""Return collected spans."""
with self._lock:
return list(self._spans)
def clear(self):
"""Clear collected spans."""
with self._lock:
self._spans.clear()
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
class TestTurnTraceObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for TurnTraceObserver."""
def setUp(self):
"""Set up a fresh provider and exporter for each test.
We create a dedicated TracerProvider per test and inject its tracer
directly into the observer, avoiding the global provider singleton.
"""
self._exporter = _InMemorySpanExporter()
self._provider = TracerProvider()
self._provider.add_span_processor(SimpleSpanProcessor(self._exporter))
self._tracer = self._provider.get_tracer("pipecat.turn")
def tearDown(self):
"""Shut down the provider to flush spans."""
self._provider.shutdown()
def _create_observers(self, conversation_id=None, tracing_context=None):
"""Create a standard set of turn/trace observers.
Args:
conversation_id: Optional conversation ID.
tracing_context: Optional TracingContext instance.
Returns:
Tuple of (turn_tracker, latency_tracker, trace_observer, tracing_context).
"""
tracing_context = tracing_context or TracingContext()
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
latency_tracker = UserBotLatencyObserver()
trace_observer = TurnTraceObserver(
turn_tracker,
latency_tracker=latency_tracker,
conversation_id=conversation_id,
tracing_context=tracing_context,
)
# Inject the test tracer so spans go to our in-memory exporter
trace_observer._tracer = self._tracer
return turn_tracker, latency_tracker, trace_observer, tracing_context
def _all_observers(self, trace_observer):
"""Return the list of observers needed for run_test."""
return [trace_observer._turn_tracker, trace_observer._latency_tracker, trace_observer]
def _get_spans_by_name(self, name):
"""Return finished spans with the given name."""
return [s for s in self._exporter.get_finished_spans() if s.name == name]
async def test_conversation_span_created_on_start_frame(self):
"""Test that a conversation span is created when StartFrame is observed."""
_, _, trace_observer, _ = self._create_observers(conversation_id="test-conv")
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span (normally done by PipelineTask._cleanup)
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(conv_spans[0].attributes["conversation.id"], "test-conv")
self.assertEqual(conv_spans[0].attributes["conversation.type"], "voice")
async def test_turn_spans_created_for_each_turn(self):
"""Test that a turn span is created for each conversation turn."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
# Turn 1
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.05),
# Turn 2
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(turn_spans), 2)
turn_numbers = {s.attributes["turn.number"] for s in turn_spans}
self.assertEqual(turn_numbers, {1, 2})
async def test_turn_spans_are_children_of_conversation(self):
"""Test that turn spans are parented under the conversation span."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(len(turn_spans), 1)
# Turn span's parent should be the conversation span
conv_span_id = conv_spans[0].context.span_id
turn_parent_id = turn_spans[0].parent.span_id
self.assertEqual(turn_parent_id, conv_span_id)
async def test_interrupted_turn_marked(self):
"""Test that an interrupted turn span has was_interrupted=True."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
# User interrupts
UserStartedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
UserStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush remaining spans
trace_observer.end_conversation_tracing()
turn_spans = self._get_spans_by_name("turn")
self.assertGreaterEqual(len(turn_spans), 1)
# First turn should be interrupted
interrupted_turns = [s for s in turn_spans if s.attributes.get("turn.was_interrupted")]
self.assertGreaterEqual(len(interrupted_turns), 1)
async def test_tracing_context_updated_during_turn(self):
"""Test that TracingContext is populated during a turn and cleared after."""
tracing_ctx = TracingContext()
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# After the turn ends, turn context should be cleared
self.assertIsNone(tracing_ctx.get_turn_context())
async def test_tracing_context_cleared_after_conversation_end(self):
"""Test that TracingContext is cleared when conversation tracing ends."""
tracing_ctx = TracingContext()
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# Manually end conversation tracing (as PipelineTask._cleanup does)
trace_observer.end_conversation_tracing()
self.assertIsNone(tracing_ctx.get_conversation_context())
self.assertIsNone(tracing_ctx.get_turn_context())
self.assertIsNone(tracing_ctx.conversation_id)
async def test_additional_span_attributes(self):
"""Test that additional span attributes are added to the conversation span."""
extra_attrs = {"deployment.id": "abc-123", "customer.tier": "premium"}
tracing_ctx = TracingContext()
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
latency_tracker = UserBotLatencyObserver()
trace_observer = TurnTraceObserver(
turn_tracker,
latency_tracker=latency_tracker,
additional_span_attributes=extra_attrs,
tracing_context=tracing_ctx,
)
trace_observer._tracer = self._tracer
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[turn_tracker, latency_tracker, trace_observer],
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(conv_spans[0].attributes["deployment.id"], "abc-123")
self.assertEqual(conv_spans[0].attributes["customer.tier"], "premium")
async def test_concurrent_pipelines_are_isolated(self):
"""Test that two pipelines with separate TracingContexts don't interfere."""
tracing_ctx_a = TracingContext()
tracing_ctx_b = TracingContext()
_, _, trace_observer_a, _ = self._create_observers(
conversation_id="conv-a", tracing_context=tracing_ctx_a
)
_, _, trace_observer_b, _ = self._create_observers(
conversation_id="conv-b", tracing_context=tracing_ctx_b
)
processor_a = IdentityFilter()
processor_b = IdentityFilter()
frames = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
# Run both pipelines concurrently
await asyncio.gather(
run_test(
processor_a,
frames_to_send=frames,
expected_down_frames=expected,
observers=self._all_observers(trace_observer_a),
),
run_test(
processor_b,
frames_to_send=frames,
expected_down_frames=expected,
observers=self._all_observers(trace_observer_b),
),
)
# End both conversations to flush spans
trace_observer_a.end_conversation_tracing()
trace_observer_b.end_conversation_tracing()
# Each TracingContext should have its own conversation ID
conv_spans = self._get_spans_by_name("conversation")
conv_ids = {s.attributes["conversation.id"] for s in conv_spans}
self.assertEqual(conv_ids, {"conv-a", "conv-b"})
# Turn spans should be children of their own conversation span, not cross-linked
turn_spans = self._get_spans_by_name("turn")
conv_span_map = {s.context.span_id: s.attributes["conversation.id"] for s in conv_spans}
for turn_span in turn_spans:
parent_id = turn_span.parent.span_id
turn_conv_id = turn_span.attributes["conversation.id"]
parent_conv_id = conv_span_map[parent_id]
self.assertEqual(
turn_conv_id,
parent_conv_id,
f"Turn span for {turn_conv_id} parented under {parent_conv_id}",
)
async def test_end_conversation_closes_active_turn(self):
"""Test that end_conversation_tracing closes any active turn span."""
_, _, trace_observer, _ = self._create_observers()
# Manually start conversation and a turn
trace_observer.start_conversation_tracing("conv-end-test")
await trace_observer._handle_turn_started(1)
self.assertIsNotNone(trace_observer._current_span)
self.assertIsNotNone(trace_observer._conversation_span)
# End conversation — should close both turn and conversation
trace_observer.end_conversation_tracing()
self.assertIsNone(trace_observer._current_span)
self.assertIsNone(trace_observer._conversation_span)
# Check span attributes
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(turn_spans), 1)
self.assertTrue(turn_spans[0].attributes["turn.was_interrupted"])
self.assertTrue(turn_spans[0].attributes["turn.ended_by_conversation_end"])
async def test_conversation_id_auto_generated(self):
"""Test that a conversation ID is auto-generated when none is provided."""
_, _, trace_observer, _ = self._create_observers(conversation_id=None)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
# Should have an auto-generated UUID as conversation.id
conv_id = conv_spans[0].attributes["conversation.id"]
self.assertIsNotNone(conv_id)
self.assertGreater(len(conv_id), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

View File

@@ -0,0 +1,631 @@
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
ClientConnectedFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
InterruptionFrame,
MetricsFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import (
TextAggregationMetricsData,
TTFBMetricsData,
)
from pipecat.observers.user_bot_latency_observer import (
FunctionCallMetrics,
LatencyBreakdown,
TextAggregationBreakdownMetrics,
TTFBBreakdownMetrics,
UserBotLatencyObserver,
)
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.tests.utils import SleepFrame, run_test
class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for UserBotLatencyObserver."""
async def test_normal_latency_measurement(self):
"""Test basic latency measurement from user stop to bot start."""
# Create observer
observer = UserBotLatencyObserver()
# Create identity filter (passes all frames through)
processor = IdentityFilter()
# Capture latency events
latencies = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
# Define frame sequence
frames_to_send = [
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
# Run test
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
# Verify latency was measured
self.assertEqual(len(latencies), 1)
self.assertGreater(latencies[0], 0)
self.assertLess(latencies[0], 1.0) # Should be very quick
async def test_multiple_latency_measurements(self):
"""Test that multiple user-bot exchanges produce separate latency events."""
# Create observer
observer = UserBotLatencyObserver()
# Create identity filter
processor = IdentityFilter()
# Capture latency events
latencies = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
# Define frame sequence with two complete cycles
frames_to_send = [
# First cycle
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
# Second cycle
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
# Run test
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
# Verify two separate latencies were measured
self.assertEqual(len(latencies), 2)
self.assertGreater(latencies[0], 0)
self.assertGreater(latencies[1], 0)
async def test_breakdown_with_metrics(self):
"""Test that metrics collected between VADUserStopped and BotStarted appear in breakdown."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
stt_ttfb = TTFBMetricsData(processor="DeepgramSTTService#0", value=0.080)
llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", model="gpt-4o", value=0.250)
tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070)
text_agg = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[stt_ttfb]),
MetricsFrame(data=[llm_ttfb, text_agg]),
MetricsFrame(data=[tts_ttfb]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
bd = breakdowns[0]
self.assertEqual(len(bd.ttfb), 3)
self.assertEqual(bd.ttfb[0].processor, "DeepgramSTTService#0")
self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0")
self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0")
self.assertIsNotNone(bd.text_aggregation)
self.assertEqual(bd.text_aggregation.duration_secs, 0.030)
async def test_interruption_resets_accumulators(self):
"""Test that InterruptionFrame clears stale metrics from earlier cycles."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
# First cycle metrics (will be interrupted)
stale_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.245)
# Second cycle metrics (the ones that matter)
final_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.224)
final_tts = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.142)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[stale_llm]),
InterruptionFrame(),
MetricsFrame(data=[final_llm]),
MetricsFrame(data=[final_tts]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
InterruptionFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
bd = breakdowns[0]
# Only the post-interruption metrics should be present
self.assertEqual(len(bd.ttfb), 2)
self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0")
self.assertEqual(bd.ttfb[0].duration_secs, 0.224)
self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0")
self.assertEqual(bd.ttfb[1].duration_secs, 0.142)
async def test_only_first_text_aggregation_kept(self):
"""Test that only the first text aggregation metric is kept per cycle."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
text_agg_1 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030)
text_agg_2 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.080)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[text_agg_1]),
MetricsFrame(data=[text_agg_2]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNotNone(breakdowns[0].text_aggregation)
self.assertEqual(breakdowns[0].text_aggregation.duration_secs, 0.030)
async def test_user_turn_measured(self):
"""Test that pre-LLM wait from user silence to UserStopped is captured."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=0.1), # Simulate turn analyzer wait
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNotNone(breakdowns[0].user_turn_secs)
self.assertGreaterEqual(breakdowns[0].user_turn_secs, 0.1)
async def test_user_turn_none_without_user_stopped(self):
"""Test that user_turn is None when no UserStoppedSpeakingFrame arrives."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNone(breakdowns[0].user_turn_secs)
async def test_no_measurement_without_user_stop(self):
"""Test that BotStartedSpeaking without prior user stop emits nothing."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
latencies = []
breakdowns = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
BotStartedSpeakingFrame(),
]
expected_down_frames = [
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(latencies), 0)
self.assertEqual(len(breakdowns), 0)
async def test_first_bot_speech_latency(self):
"""Test first bot speech latency and breakdown from ClientConnected to BotStartedSpeaking."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
breakdowns = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", value=0.250)
tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070)
frames_to_send = [
ClientConnectedFrame(),
MetricsFrame(data=[llm_ttfb]),
MetricsFrame(data=[tts_ttfb]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 1)
self.assertGreater(first_speech_latencies[0], 0)
self.assertLess(first_speech_latencies[0], 1.0)
# Breakdown should also be emitted with the accumulated metrics
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].ttfb), 2)
self.assertEqual(breakdowns[0].ttfb[0].processor, "OpenAILLMService#0")
self.assertEqual(breakdowns[0].ttfb[1].processor, "CartesiaTTSService#0")
async def test_first_bot_speech_only_once(self):
"""Test that first bot speech latency is only emitted once."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
frames_to_send = [
ClientConnectedFrame(),
BotStartedSpeakingFrame(),
# Second bot speech should not trigger the event again
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
BotStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 1)
async def test_first_bot_speech_skipped_when_user_speaks_first(self):
"""Test that first bot speech event is not emitted when user speaks before the bot."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
frames_to_send = [
ClientConnectedFrame(),
# User speaks before bot has a chance to greet
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 0)
async def test_function_call_latency_in_breakdown(self):
"""Test that function call duration appears in the latency breakdown."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
tool_call_id = "call_abc123"
frames_to_send = [
VADUserStoppedSpeakingFrame(),
FunctionCallInProgressFrame(
function_name="get_weather",
tool_call_id=tool_call_id,
arguments={"location": "Atlanta"},
),
SleepFrame(sleep=0.1),
FunctionCallResultFrame(
function_name="get_weather",
tool_call_id=tool_call_id,
arguments={"location": "Atlanta"},
result={"temperature": "75"},
),
BotStartedSpeakingFrame(),
]
await run_test(
processor,
frames_to_send=frames_to_send,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].function_calls), 1)
fc = breakdowns[0].function_calls[0]
self.assertEqual(fc.function_name, "get_weather")
self.assertGreaterEqual(fc.duration_secs, 0.1)
async def test_function_call_reset_on_interruption(self):
"""Test that function call metrics are cleared on interruption."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
FunctionCallInProgressFrame(
function_name="get_weather",
tool_call_id="call_1",
arguments={},
),
FunctionCallResultFrame(
function_name="get_weather",
tool_call_id="call_1",
arguments={},
result={},
),
InterruptionFrame(),
BotStartedSpeakingFrame(),
]
await run_test(
processor,
frames_to_send=frames_to_send,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].function_calls), 0)
class TestLatencyBreakdownChronologicalEvents(unittest.TestCase):
"""Tests for LatencyBreakdown.chronological_events()."""
def test_events_sorted_by_start_time(self):
"""Test that events are returned in chronological order."""
breakdown = LatencyBreakdown(
user_turn_start_time=100.0,
user_turn_secs=0.150,
ttfb=[
TTFBBreakdownMetrics(
processor="OpenAILLMService#0",
model="gpt-4o",
start_time=100.200,
duration_secs=0.250,
),
TTFBBreakdownMetrics(
processor="DeepgramSTTService#0",
start_time=100.050,
duration_secs=0.080,
),
TTFBBreakdownMetrics(
processor="CartesiaTTSService#0",
start_time=100.500,
duration_secs=0.070,
),
],
function_calls=[
FunctionCallMetrics(
function_name="get_weather",
start_time=100.450,
duration_secs=0.120,
),
],
text_aggregation=TextAggregationBreakdownMetrics(
processor="CartesiaTTSService#0",
start_time=100.480,
duration_secs=0.030,
),
)
events = breakdown.chronological_events()
self.assertEqual(len(events), 6)
self.assertEqual(events[0], "User turn: 0.150s")
self.assertEqual(events[1], "DeepgramSTTService#0: TTFB 0.080s")
self.assertEqual(events[2], "OpenAILLMService#0: TTFB 0.250s")
self.assertEqual(events[3], "get_weather: 0.120s")
self.assertEqual(events[4], "CartesiaTTSService#0: text aggregation 0.030s")
self.assertEqual(events[5], "CartesiaTTSService#0: TTFB 0.070s")
def test_empty_breakdown(self):
"""Test that an empty breakdown returns no events."""
breakdown = LatencyBreakdown()
self.assertEqual(breakdown.chronological_events(), [])
def test_user_turn_requires_both_fields(self):
"""Test that user turn is only included when both start_time and secs are set."""
# Only start_time, no duration
breakdown = LatencyBreakdown(user_turn_start_time=100.0)
self.assertEqual(breakdown.chronological_events(), [])
# Only duration, no start_time
breakdown = LatencyBreakdown(user_turn_secs=0.150)
self.assertEqual(breakdown.chronological_events(), [])
def test_ttfb_only(self):
"""Test breakdown with only TTFB metrics."""
breakdown = LatencyBreakdown(
ttfb=[
TTFBBreakdownMetrics(processor="LLM#0", start_time=100.0, duration_secs=0.200),
],
)
events = breakdown.chronological_events()
self.assertEqual(events, ["LLM#0: TTFB 0.200s"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,323 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
import unittest.mock
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
UserIdleTimeoutUpdateFrame,
UserStartedSpeakingFrame,
)
from pipecat.turns.user_idle_controller import UserIdleController
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
USER_IDLE_TIMEOUT = 0.2
class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_idle_after_bot_stops_speaking(self):
"""Test that idle event fires after BotStoppedSpeakingFrame + timeout."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_user_speaking_cancels_timer(self):
"""Test that UserStartedSpeakingFrame cancels the idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(UserStartedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_bot_speaking_cancels_timer(self):
"""Test that BotStartedSpeakingFrame cancels the idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(BotStartedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_no_idle_before_bot_speaks(self):
"""Test that idle does not fire if no BotStoppedSpeakingFrame is received."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Wait without any frames
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_interruption_no_false_trigger(self):
"""Test that BotStoppedSpeakingFrame during a user turn does not start the timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# User starts speaking (interruption)
await controller.process_frame(UserStartedSpeakingFrame())
# Bot stops speaking due to interruption
await controller.process_frame(BotStoppedSpeakingFrame())
# Wait - timer should NOT have started because user turn is in progress
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_idle_cycle(self):
"""Test that idle fires, then can fire again after another bot speaking cycle."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_count = 0
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_count
idle_count += 1
# First cycle: bot stops → idle fires
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertEqual(idle_count, 1)
# Second cycle: bot starts → bot stops → idle fires again
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertEqual(idle_count, 2)
await controller.cleanup()
async def test_cleanup_cancels_timer(self):
"""Test that cleanup cancels a pending idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.cleanup()
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
async def test_function_call_cancels_timer(self):
"""Test normal ordering: BotStopped starts timer, FunctionCallsStarted cancels it."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Bot finishes speaking, timer starts
await controller.process_frame(BotStoppedSpeakingFrame())
# Function call starts shortly after, cancels the timer
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
)
# Wait longer than timeout — should not fire
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_function_call_suppresses_timer(self):
"""Test race condition: FunctionCallsStarted arrives before BotStopped.
A race condition can cause FunctionCallsStarted to arrive before
BotStoppedSpeaking. The counter guard prevents the timer from starting
while a function call is in progress.
"""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# LLM emits function call and "let me check" concurrently
await controller.process_frame(
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
# Wait longer than timeout — should not fire (function call in progress)
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
# Function call completes, bot speaks result
await controller.process_frame(
FunctionCallResultFrame(
function_name="test", tool_call_id="123", arguments={}, result="ok"
)
)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
# Now the timer should start and fire
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_disabled_by_default(self):
"""Test that timeout=0 means idle detection is disabled."""
controller = UserIdleController()
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_enable_via_frame(self):
"""Test enabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
controller = UserIdleController()
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Initially disabled — no idle fires
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
# Enable idle detection
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=USER_IDLE_TIMEOUT))
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_disable_via_frame(self):
"""Test disabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Start the timer
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
# Disable — should cancel running timer
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=0))
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -218,3 +218,7 @@ class TestUserIdleProcessor(unittest.IsolatedAsyncioTestCase):
)
assert callback_called.is_set(), "Idle callback not called after bot speech"
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,143 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallCancelFrame,
FunctionCallFromLLM,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InterruptionFrame,
)
from pipecat.turns.user_mute import (
AlwaysUserMuteStrategy,
FirstSpeechUserMuteStrategy,
FunctionCallUserMuteStrategy,
MuteUntilFirstBotCompleteUserMuteStrategy,
)
class TestAlwaysUserMuteStrategy(unittest.IsolatedAsyncioTestCase):
async def test_user_mute_strategy(self):
strategy = AlwaysUserMuteStrategy()
self.assertTrue(await strategy.process_frame(BotStartedSpeakingFrame()))
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertFalse(await strategy.process_frame(BotStoppedSpeakingFrame()))
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
class TestFirstSpeechUserMuteStrategy(unittest.IsolatedAsyncioTestCase):
async def test_user_mute_strategy(self):
strategy = FirstSpeechUserMuteStrategy()
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
self.assertTrue(await strategy.process_frame(BotStartedSpeakingFrame()))
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertFalse(await strategy.process_frame(BotStoppedSpeakingFrame()))
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
class TestMuteUntilFirstBotCompleteUserMuteStrategy(unittest.IsolatedAsyncioTestCase):
async def test_user_mute_strategy(self):
strategy = MuteUntilFirstBotCompleteUserMuteStrategy()
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertTrue(await strategy.process_frame(BotStartedSpeakingFrame()))
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertFalse(await strategy.process_frame(BotStoppedSpeakingFrame()))
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
class TestFunctionCallUserMuteStrategy(unittest.IsolatedAsyncioTestCase):
async def test_user_mute_strategy(self):
strategy = FunctionCallUserMuteStrategy()
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
# First function call (cancelled)
self.assertTrue(
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name="fn_1", tool_call_id="1", arguments={}, context=None
)
]
)
)
)
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertFalse(
await strategy.process_frame(
FunctionCallCancelFrame(function_name="fn_1", tool_call_id="1")
)
)
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
# Second function call (finished)
self.assertTrue(
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name="fn_2", tool_call_id="2", arguments={}, context=None
)
]
)
)
)
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
self.assertFalse(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="fn_2", tool_call_id="2", arguments={}, result={}
)
)
)
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
# Multiple function calls
self.assertTrue(
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
FunctionCallFromLLM(
function_name="fn_3", tool_call_id="3", arguments={}, context=None
),
FunctionCallFromLLM(
function_name="fn_4", tool_call_id="4", arguments={}, context=None
),
]
)
)
)
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
# First function call is done, we still should be muted since there's
# another one ongoing.
self.assertTrue(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="fn_3", tool_call_id="3", arguments={}, result={}
)
)
)
self.assertTrue(await strategy.process_frame(InterruptionFrame()))
# Last function call finishes.
self.assertFalse(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="fn_4", tool_call_id="4", arguments={}, result={}
)
)
)
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,261 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
import unittest.mock
from unittest.mock import AsyncMock
from pipecat.frames.frames import LLMFullResponseEndFrame, LLMTextFrame
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import LLMService
from pipecat.services.settings import LLMSettings
from pipecat.turns.user_turn_completion_mixin import (
USER_TURN_COMPLETE_MARKER,
USER_TURN_COMPLETION_INSTRUCTIONS,
USER_TURN_INCOMPLETE_LONG_MARKER,
USER_TURN_INCOMPLETE_SHORT_MARKER,
UserTurnCompletionConfig,
UserTurnCompletionLLMServiceMixin,
)
class MockProcessor(UserTurnCompletionLLMServiceMixin, FrameProcessor):
"""Simple mock processor using the turn completion mixin."""
pass
class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase):
"""Tests for UserUserTurnCompletionLLMServiceMixin functionality."""
async def test_complete_marker_pushes_text(self):
"""Test that ✓ marker is detected and text after it is pushed normally."""
processor = MockProcessor()
# Capture frames that get pushed
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Simulate LLM generating: "✓ Hello there!"
await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello there!")
# Should have 2 text frames: marker (skip_tts) and content (normal)
self.assertEqual(len(pushed_frames), 2)
# First frame should be the marker with skip_tts=True
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_COMPLETE_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
# Second frame should be the actual text without skip_tts
self.assertIsInstance(pushed_frames[1], LLMTextFrame)
self.assertEqual(pushed_frames[1].text, "Hello there!")
self.assertFalse(pushed_frames[1].skip_tts)
async def test_incomplete_short_marker_suppresses_text(self):
"""Test that ○ marker suppresses text with skip_tts."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Mock timeout to avoid needing task manager
processor._start_incomplete_timeout = AsyncMock()
await processor._push_turn_text(USER_TURN_INCOMPLETE_SHORT_MARKER)
# Should have 1 text frame with skip_tts=True
self.assertEqual(len(pushed_frames), 1)
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_SHORT_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
async def test_incomplete_long_marker_suppresses_text(self):
"""Test that ◐ marker suppresses text with skip_tts."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Mock timeout to avoid needing task manager
processor._start_incomplete_timeout = AsyncMock()
await processor._push_turn_text(USER_TURN_INCOMPLETE_LONG_MARKER)
# Should have 1 text frame with skip_tts=True
self.assertEqual(len(pushed_frames), 1)
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_LONG_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
async def test_text_buffered_until_marker_found(self):
"""Test that text is buffered until a marker is detected."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Simulate token-by-token streaming without marker
await processor._push_turn_text("Hello")
await processor._push_turn_text(" there")
# No frames should be pushed yet (buffering)
self.assertEqual(len(pushed_frames), 0)
# Now send the complete marker
await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?")
# Now frames should be pushed
self.assertEqual(len(pushed_frames), 2)
async def test_turn_state_reset_after_llm_full_response_end_frame(self):
"""Test that _turn_complete_found is reset when LLMFullResponseEndFrame is pushed."""
processor = MockProcessor()
# Mock push_frame on the instance so _push_turn_text can call it without
# a live pipeline, but keep _turn_reset as the real implementation.
processor.push_frame = AsyncMock()
# Simulate first LLM response: complete marker sets _turn_complete_found = True
await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello!")
self.assertTrue(processor._turn_complete_found)
# Restore the real push_frame so the mixin override runs, then call it
# with LLMFullResponseEndFrame as the LLM service would.
del processor.push_frame # removes instance mock, restores class method
# Patch only the FrameProcessor-level send so no live pipeline is needed.
with unittest.mock.patch.object(FrameProcessor, "push_frame", AsyncMock()):
end_frame = LLMFullResponseEndFrame()
await processor.push_frame(end_frame)
# _turn_complete_found must now be False — ready for the next response
self.assertFalse(processor._turn_complete_found)
self.assertEqual(processor._turn_text_buffer, "")
self.assertFalse(processor._turn_suppressed)
class MockLLMService(LLMService):
"""Minimal LLM service for testing system_instruction composition."""
def __init__(self, **kwargs):
settings = LLMSettings(
model="test-model",
system_instruction=kwargs.pop("system_instruction", None),
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=None,
user_turn_completion_config=None,
)
super().__init__(settings=settings, **kwargs)
class TestSystemInstructionComposition(unittest.IsolatedAsyncioTestCase):
"""Tests for turn completion system_instruction composition in LLMService."""
async def test_enable_turn_completion_sets_system_instruction(self):
"""Enabling turn completion should set system_instruction to completion instructions."""
service = MockLLMService()
self.assertIsNone(service._settings.system_instruction)
delta = LLMSettings(filter_incomplete_user_turns=True)
await service._update_settings(delta)
self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS)
self.assertIsNone(service._base_system_instruction)
async def test_enable_turn_completion_appends_to_existing_system_instruction(self):
"""Enabling turn completion should append instructions to existing system_instruction."""
service = MockLLMService(system_instruction="You are a helpful assistant.")
delta = LLMSettings(filter_incomplete_user_turns=True)
await service._update_settings(delta)
expected = f"You are a helpful assistant.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
self.assertEqual(service._base_system_instruction, "You are a helpful assistant.")
async def test_disable_turn_completion_restores_system_instruction(self):
"""Disabling turn completion should restore the original system_instruction."""
service = MockLLMService(system_instruction="You are a helpful assistant.")
# Enable
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
self.assertIn(USER_TURN_COMPLETION_INSTRUCTIONS, service._settings.system_instruction)
# Disable
await service._update_settings(LLMSettings(filter_incomplete_user_turns=False))
self.assertEqual(service._settings.system_instruction, "You are a helpful assistant.")
self.assertIsNone(service._base_system_instruction)
async def test_disable_turn_completion_restores_none(self):
"""Disabling turn completion when original was None should restore None."""
service = MockLLMService()
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS)
await service._update_settings(LLMSettings(filter_incomplete_user_turns=False))
self.assertIsNone(service._settings.system_instruction)
async def test_update_system_instruction_while_turn_completion_active(self):
"""Changing system_instruction while turn completion is active should recompose."""
service = MockLLMService(system_instruction="Original prompt.")
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
expected = f"Original prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
# Now update system_instruction
await service._update_settings(LLMSettings(system_instruction="New prompt."))
expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
self.assertEqual(service._base_system_instruction, "New prompt.")
async def test_update_config_recomposes_with_custom_instructions(self):
"""Updating turn completion config should recompose with new instructions."""
service = MockLLMService(system_instruction="Base prompt.")
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
custom_config = UserTurnCompletionConfig(instructions="Custom turn instructions.")
await service._update_settings(LLMSettings(user_turn_completion_config=custom_config))
expected = "Base prompt.\n\nCustom turn instructions."
self.assertEqual(service._settings.system_instruction, expected)
async def test_simultaneous_enable_and_system_instruction_change(self):
"""Enabling turn completion and changing system_instruction in the same delta
should use the new system_instruction as the base."""
service = MockLLMService(system_instruction="Original prompt.")
await service._update_settings(
LLMSettings(
filter_incomplete_user_turns=True,
system_instruction="New prompt.",
)
)
# apply_update sets system_instruction to "New prompt." before _update_settings
# runs, so the base should be the new value the user explicitly set.
self.assertEqual(service._base_system_instruction, "New prompt.")
expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,272 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_start import VADUserTurnStartStrategy
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
MinWordsUserTurnStartStrategy,
)
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_controller import UserTurnController
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
USER_TURN_STOP_TIMEOUT = 0.2
TRANSCRIPTION_TIMEOUT = 0.1
class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_default_user_turn_strategies(self):
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
)
)
await controller.setup(self.task_manager)
should_start = None
should_stop = None
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start = True
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal should_stop
should_stop = True
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertTrue(should_start)
self.assertFalse(should_stop)
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
self.assertTrue(should_start)
self.assertFalse(should_stop)
await controller.process_frame(VADUserStoppedSpeakingFrame())
self.assertTrue(should_start)
# Wait for user_speech_timeout to elapse
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertTrue(should_stop)
async def test_user_turn_start_reset(self):
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
start=[MinWordsUserTurnStartStrategy(min_words=3)]
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
should_start = 0
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start += 1
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp=""))
self.assertEqual(should_start, 0)
await controller.process_frame(
TranscriptionFrame(text="One two three!", user_id="cat", timestamp="")
)
self.assertEqual(should_start, 1)
# Trigger user stop turn so we can trigger user start turn again.
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(TranscriptionFrame(text="Hi!", user_id="cat", timestamp=""))
self.assertEqual(should_start, 1)
await controller.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertEqual(should_start, 2)
async def test_user_turn_stop_timeout_no_transcription(self):
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
should_start = None
should_stop = None
timeout = None
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start = True
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal should_stop
should_stop = True
@controller.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(controller):
nonlocal timeout
timeout = True
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertTrue(should_start)
self.assertFalse(should_stop)
self.assertFalse(timeout)
await controller.process_frame(VADUserStoppedSpeakingFrame())
self.assertTrue(should_start)
self.assertFalse(should_stop)
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_external_user_turn_strategies_no_timeout_while_speaking(self):
"""Test that timeout does not trigger when user is still speaking with external strategies."""
controller = UserTurnController(
user_turn_strategies=ExternalUserTurnStrategies(),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
should_start = None
should_stop = None
timeout = None
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start = True
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal should_stop
should_stop = True
@controller.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(controller):
nonlocal timeout
timeout = True
# Simulate external service (like Deepgram Flux) broadcasting UserStartedSpeakingFrame
await controller.process_frame(UserStartedSpeakingFrame())
self.assertTrue(should_start)
self.assertFalse(should_stop)
self.assertFalse(timeout)
# User is still speaking, timeout should not trigger
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertFalse(should_stop)
self.assertFalse(timeout)
# Now external service broadcasts UserStoppedSpeakingFrame
await controller.process_frame(UserStoppedSpeakingFrame())
# But no transcription, so timeout should trigger
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_late_transcription_between_turns_no_premature_stop(self):
"""Test that a late transcription arriving between turns does not cause a premature stop.
Reproduces the bug from issue #4053: after turn 1 completes and reset()
clears state, a late TranscriptionFrame sets _text to stale content. On
the next turn, that stale _text gates a premature turn stop via timeout(0)
before the current turn's transcript arrives.
Uses only VADUserTurnStartStrategy (no TranscriptionUserTurnStartStrategy)
so the late transcription doesn't trigger a spurious turn start.
"""
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
start_count = 0
stop_count = 0
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal start_count
start_count += 1
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal stop_count
stop_count += 1
# === Turn 1: S-T-E ===
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 1)
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 1)
# === Between turns: late transcription arrives ===
# This sets _text on the stop strategy while _user_turn is False.
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
# === Turn 2: S-T-E (transcription arrives during turn) ===
# The fix resets stop strategies at turn start, clearing stale _text.
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 2)
await controller.process_frame(
TranscriptionFrame(text="How are you?", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
# Wait for user_speech_timeout to elapse — should get turn 2 stop
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,164 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from pipecat.frames.frames import (
InterruptionFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_processor import UserTurnProcessor
from pipecat.turns.user_turn_strategies import UserTurnStrategies
USER_TURN_STOP_TIMEOUT = 0.2
TRANSCRIPTION_TIMEOUT = 0.1
class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
async def test_default_user_turn_strategies(self):
user_turn_processor = UserTurnProcessor(
user_turn_strategies=UserTurnStrategies(
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
)
)
should_start = None
should_stop = None
@user_turn_processor.event_handler("on_user_turn_started")
async def on_user_turn_started(processor, strategy):
nonlocal should_start
should_start = True
@user_turn_processor.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(processor, strategy):
nonlocal should_stop
should_stop = True
pipeline = Pipeline([user_turn_processor])
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
]
expected_down_frames = [
VADUserStartedSpeakingFrame,
UserStartedSpeakingFrame,
InterruptionFrame,
TranscriptionFrame,
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
async def test_user_turn_stop_timeout_no_transcription(self):
user_turn_processor = UserTurnProcessor(
user_turn_strategies=UserTurnStrategies(),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
should_start = None
should_stop = None
timeout = None
@user_turn_processor.event_handler("on_user_turn_started")
async def on_user_turn_started(processor, strategy):
nonlocal should_start
should_start = True
@user_turn_processor.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(processor, strategy):
nonlocal should_stop
should_stop = True
@user_turn_processor.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(processor):
nonlocal timeout
timeout = True
pipeline = Pipeline([user_turn_processor])
frames_to_send = [
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT + 0.1),
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_user_turn_stop_timeout_transcription(self):
user_turn_processor = UserTurnProcessor(
user_turn_strategies=UserTurnStrategies(
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
should_start = None
should_stop = None
timeout = None
@user_turn_processor.event_handler("on_user_turn_started")
async def on_user_turn_started(processor, strategy):
nonlocal should_start
should_start = True
@user_turn_processor.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(processor, strategy):
nonlocal should_stop
should_stop = True
@user_turn_processor.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(processor):
nonlocal timeout
timeout = True
pipeline = Pipeline([user_turn_processor])
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
# The transcription strategy should kick-in before the user turn end timeout.
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertFalse(timeout)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,205 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
InterimTranscriptionFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_start import (
ExternalUserTurnStartStrategy,
MinWordsUserTurnStartStrategy,
TranscriptionUserTurnStartStrategy,
VADUserTurnStartStrategy,
)
class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
async def test_bot_speaking_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="Hello", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(
TranscriptionFrame(text="Hello there!", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
# Reset and check again
should_start = None
await strategy.reset()
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
async def test_bot_speaking_singlw_words(self):
strategy = MinWordsUserTurnStartStrategy(min_words=3)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(TranscriptionFrame(text="Two", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(TranscriptionFrame(text="Three", user_id="cat", timestamp=""))
self.assertFalse(should_start)
async def test_bot_speaking_interim_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello", user_id="cat", timestamp="")
)
self.assertFalse(should_start)
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello there!", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
async def test_bot_speaking_all_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello", user_id="cat", timestamp="")
)
self.assertFalse(should_start)
await strategy.process_frame(
TranscriptionFrame(text="Hello there!", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
async def test_bot_not_speaking_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(TranscriptionFrame(text="Hello", user_id="cat", timestamp=""))
self.assertTrue(should_start)
async def test_bot_not_speaking_interim_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
class TestVADUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
async def test_vad_strategy(self):
strategy = VADUserTurnStartStrategy()
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertTrue(should_start)
class TestTranscriptionUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
async def test_transcription_strategy(self):
strategy = TranscriptionUserTurnStartStrategy()
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="", timestamp="now"))
self.assertTrue(should_start)
class TestExternalUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
async def test_external_strategy(self):
strategy = ExternalUserTurnStartStrategy()
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(UserStartedSpeakingFrame())
self.assertTrue(should_start)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,653 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from unittest.mock import patch
from pipecat.frames.frames import (
InterimTranscriptionFrame,
STTMetadataFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, SpeechTimeoutUserTurnStopStrategy
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
AGGREGATION_TIMEOUT = 0.1
# Use 0 STT timeout for deterministic test timing
STT_TIMEOUT = 0.0
class TestSpeechTimeoutUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def _create_strategy(self, user_speech_timeout=AGGREGATION_TIMEOUT):
"""Create strategy and configure STT timeout via metadata frame."""
strategy = SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=user_speech_timeout)
await strategy.setup(self.task_manager)
# Set STT timeout via metadata frame (as would happen in real pipeline)
await strategy.process_frame(
STTMetadataFrame(service_name="test", ttfs_p99_latency=STT_TIMEOUT)
)
return strategy
async def test_ste(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription came in between user started/stopped. Now we wait for
# timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_site(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello!", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# T
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription came in between user started/stopped. Now we wait for
# timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_st1iest2e(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T1
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello!", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T2
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Now we wait for timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_siet(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# T
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_sieit(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# T
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_set(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# T
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_seit(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# T
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_st1et2(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T1
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription came between user start/stopped speaking, wait for timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
should_start = None
# Reset for next turn (in real usage, UserTurnController would do this)
await strategy.reset()
# S - new turn starts
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T2
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_set1t2(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# T1
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# T2
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_siet1it2(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello!", user_id="cat", timestamp="")
)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# T1
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# T2
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_t(self):
"""Transcription without VAD - uses fallback timeout."""
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# T
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# Transcription without VAD triggers fallback timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_it(self):
"""Interim + Transcription without VAD - uses fallback timeout."""
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello!", user_id="cat", timestamp="")
)
# T
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# Transcription without VAD triggers fallback timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_sie_delay_it(self):
strategy = await self._create_strategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
# S
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="Hello!", user_id="cat", timestamp="")
)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Delay - timeout expires but no transcript yet
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
# Still no trigger because no transcript received
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
# T (finalized) - triggers immediately since timeout already elapsed
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="", finalized=True)
)
# Finalized transcript received after timeout, triggers immediately
self.assertTrue(should_start)
async def test_reset_clears_stale_text_no_premature_stop(self):
"""Test that reset() clears stale text and cancels timeout, preventing premature stop.
Reproduces the bug from issue #4053: after turn 1 completes and
reset() is called, a late transcription sets _text. If reset() is
called again at turn 2 start, the stale _text should be cleared
so no premature stop occurs on VAD stop.
"""
strategy = await self._create_strategy()
stop_count = 0
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal stop_count
stop_count += 1
# === Turn 1: S-T-E ===
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
await strategy.process_frame(VADUserStoppedSpeakingFrame())
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 1)
# Reset after turn 1 (as controller would do at turn stop)
await strategy.reset()
# === Late transcription arrives between turns ===
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
# Reset at turn 2 start (the fix: controller now resets stop strategies at turn start)
await strategy.reset()
# === Turn 2: S-T-E (transcription arrives during turn) ===
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
await strategy.process_frame(VADUserStoppedSpeakingFrame())
# Wait for timeout — should get turn 2 stop with the real transcription
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 2)
class TestSpeechTimeoutStopSecsWarnings(unittest.IsolatedAsyncioTestCase):
"""Tests for stop_secs misconfiguration warnings."""
async def asyncSetUp(self) -> None:
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def _create_strategy(self, stt_timeout=0.35):
strategy = SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
await strategy.process_frame(
STTMetadataFrame(service_name="test", ttfs_p99_latency=stt_timeout)
)
return strategy
@patch("pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.logger")
async def test_warns_on_non_default_stop_secs(self, mock_logger):
# Use high stt_timeout so only Warning A fires (stop_secs < stt_timeout)
strategy = await self._create_strategy(stt_timeout=1.0)
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
mock_logger.warning.assert_called_once()
self.assertIn("differs from the recommended default", mock_logger.warning.call_args[0][0])
@patch("pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.logger")
async def test_warns_on_stop_secs_gte_stt_timeout(self, mock_logger):
strategy = await self._create_strategy(stt_timeout=0.35)
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
# Both warnings fire: non-default stop_secs AND stop_secs >= stt_timeout
self.assertEqual(mock_logger.warning.call_count, 2)
self.assertIn("collapsed to 0s", mock_logger.warning.call_args_list[1][0][0])
@patch("pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.logger")
async def test_warns_only_once(self, mock_logger):
# Use high stt_timeout so only Warning A fires
strategy = await self._create_strategy(stt_timeout=1.0)
# First VAD stop — triggers warning
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
self.assertEqual(mock_logger.warning.call_count, 1)
# Second VAD stop — no duplicate warning
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
self.assertEqual(mock_logger.warning.call_count, 1)
@patch("pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.logger")
async def test_warning_resets_on_new_stt_metadata(self, mock_logger):
# Use high stt_timeout so only Warning A fires
strategy = await self._create_strategy(stt_timeout=1.0)
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
self.assertEqual(mock_logger.warning.call_count, 1)
# New STTMetadataFrame resets the warned flag
await strategy.process_frame(STTMetadataFrame(service_name="test", ttfs_p99_latency=1.0))
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.5))
self.assertEqual(mock_logger.warning.call_count, 2)
@patch("pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.logger")
async def test_no_warning_on_default_stop_secs(self, mock_logger):
strategy = await self._create_strategy()
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(VADUserStoppedSpeakingFrame(stop_secs=0.2))
mock_logger.warning.assert_not_called()
class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def test_external_strategy(self):
strategy = ExternalUserTurnStopStrategy()
should_start = None
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(UserStartedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(UserStoppedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(UserStartedSpeakingFrame())
self.assertFalse(should_start)
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertFalse(should_start)
await strategy.process_frame(UserStoppedSpeakingFrame())
self.assertTrue(should_start)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -32,3 +32,7 @@ class TestUtilsNetwork(unittest.IsolatedAsyncioTestCase):
assert exponential_backoff_time(attempt=4, min_wait=1, max_wait=20, multiplier=2) == 16
assert exponential_backoff_time(attempt=5, min_wait=1, max_wait=20, multiplier=2) == 20
assert exponential_backoff_time(attempt=6, min_wait=1, max_wait=20, multiplier=2) == 20
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -153,6 +153,46 @@ class TestUtilsString(unittest.IsolatedAsyncioTestCase):
for sentence in latin_script_sentences:
assert match_endofsentence(sentence), f"Failed for Latin script: {sentence}"
async def test_endofsentence_cjk_with_lookahead(self):
"""Test sentence detection for CJK text with lookahead characters.
This tests the NLTK fallback path: NLTK returns entire text as one
sentence because it doesn't support CJK languages, but unambiguous
punctuation is detected via the fallback scan.
"""
# Japanese: sentence + lookahead character
assert match_endofsentence("こんにちは。元") == 6
assert match_endofsentence("元気ですか?は") == 6
assert match_endofsentence("ありがとう!次") == 6
# Chinese: sentence + lookahead character
assert match_endofsentence("你好世界。下") == 5
assert match_endofsentence("你好吗?我") == 4
# Korean: sentence + lookahead character
assert match_endofsentence("안녕하세요。다") == 6
# Multiple CJK sentences with lookahead - should return first sentence
assert match_endofsentence("こんにちは。元気ですか?は") == 6
# Indic script with lookahead
assert match_endofsentence("हैलो।अ") == 5
# Arabic with lookahead
assert match_endofsentence("مرحبا؟ك") == 6
async def test_endofsentence_latin_not_affected_by_fallback(self):
"""Verify that the CJK fallback does not change behavior for Latin text."""
# These should still return 0 - Latin "." is NOT in the unambiguous set
assert not match_endofsentence("Mr. S")
assert not match_endofsentence("Ok, Mr. Smith let's ")
assert not match_endofsentence("The number pi is 3.14159")
assert not match_endofsentence("America, or the U.S")
# These should still return correct values via NLTK path
assert match_endofsentence("This is a sentence. This is another one") == 19
assert match_endofsentence("For information, call 411.") == 26
async def test_endofsentence_streaming_tokens(self):
"""Test the specific use case of streaming LLM tokens."""
@@ -232,3 +272,7 @@ class TestStartEndTags(unittest.IsolatedAsyncioTestCase):
("<a>", "</a>"),
41,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,210 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from typing import List
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams, VADState
from pipecat.audio.vad.vad_controller import VADController
from pipecat.frames.frames import Frame, InputAudioRawFrame, SpeechControlParamsFrame, StartFrame
from pipecat.processors.frame_processor import FrameDirection
class MockVADAnalyzer(VADAnalyzer):
"""A mock VAD analyzer that returns a configurable state."""
def __init__(self):
"""Initialize with default QUIET state."""
super().__init__(sample_rate=16000)
self._next_state = VADState.QUIET
def set_next_state(self, state: VADState):
"""Set the state to return on the next analyze_audio call.
Args:
state: The VADState to return.
"""
self._next_state = state
def num_frames_required(self) -> int:
return 512
def voice_confidence(self, buffer: bytes) -> float:
return 0.9
async def analyze_audio(self, buffer: bytes) -> VADState:
"""Return the configured state."""
return self._next_state
class TestVADController(unittest.IsolatedAsyncioTestCase):
async def test_speech_started_event(self):
"""Test that on_speech_started event is triggered when speech begins."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
speech_started = False
@controller.event_handler("on_speech_started")
async def on_speech_started(_controller):
nonlocal speech_started
speech_started = True
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Process with QUIET state - no event should fire
analyzer.set_next_state(VADState.QUIET)
await controller.process_frame(audio_frame)
self.assertFalse(speech_started)
# Process with SPEAKING state - event should fire
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
self.assertTrue(speech_started)
async def test_speech_stopped_event(self):
"""Test that on_speech_stopped event is triggered when speech ends."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
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)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Start speaking
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
self.assertFalse(speech_stopped)
# Stop speaking - event should fire
analyzer.set_next_state(VADState.QUIET)
await controller.process_frame(audio_frame)
self.assertTrue(speech_stopped)
async def test_speech_activity_event(self):
"""Test that on_speech_activity event is triggered while speaking."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
activity_count = 0
@controller.event_handler("on_speech_activity")
async def on_speech_activity(_controller):
nonlocal activity_count
activity_count += 1
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Activity events fire while in SPEAKING state
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
await controller.process_frame(audio_frame)
self.assertEqual(activity_count, 2)
async def test_push_frame_event(self):
"""Test that push_frame emits on_push_frame event."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
pushed_frames: List[tuple] = []
@controller.event_handler("on_push_frame")
async def on_push_frame(_controller, frame: Frame, direction: FrameDirection):
pushed_frames.append((frame, direction))
test_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
await controller.push_frame(test_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(len(pushed_frames), 1)
self.assertEqual(pushed_frames[0][0], test_frame)
self.assertEqual(pushed_frames[0][1], FrameDirection.DOWNSTREAM)
async def test_broadcast_frame_event(self):
"""Test that broadcast_frame emits on_broadcast_frame event."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
broadcast_calls: List[tuple] = []
@controller.event_handler("on_broadcast_frame")
async def on_broadcast_frame(_controller, frame_cls, **kwargs):
broadcast_calls.append((frame_cls, kwargs))
await controller.broadcast_frame(
InputAudioRawFrame, audio=b"\x00", sample_rate=16000, num_channels=1
)
self.assertEqual(len(broadcast_calls), 1)
self.assertEqual(broadcast_calls[0][0], InputAudioRawFrame)
self.assertEqual(broadcast_calls[0][1]["sample_rate"], 16000)
async def test_no_event_on_transitional_states(self):
"""Test that STARTING and STOPPING states don't trigger events."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
events_triggered = []
@controller.event_handler("on_speech_started")
async def on_speech_started(_controller):
events_triggered.append("started")
@controller.event_handler("on_speech_stopped")
async def on_speech_stopped(_controller):
events_triggered.append("stopped")
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# STARTING is a transitional state - no event
analyzer.set_next_state(VADState.STARTING)
await controller.process_frame(audio_frame)
self.assertEqual(events_triggered, [])
# STOPPING is a transitional state - no event
analyzer.set_next_state(VADState.STOPPING)
await controller.process_frame(audio_frame)
self.assertEqual(events_triggered, [])
async def test_start_frame_broadcasts_vad_params(self):
"""Test that StartFrame triggers broadcast of SpeechControlParamsFrame with VAD params."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
broadcast_calls: List[tuple] = []
@controller.event_handler("on_broadcast_frame")
async def on_broadcast_frame(_controller, frame_cls, **kwargs):
broadcast_calls.append((frame_cls, kwargs))
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
# Should have broadcast SpeechControlParamsFrame with VAD params
self.assertEqual(len(broadcast_calls), 1)
self.assertEqual(broadcast_calls[0][0], SpeechControlParamsFrame)
self.assertIn("vad_params", broadcast_calls[0][1])
self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams)
if __name__ == "__main__":
unittest.main()

150
tests/test_vad_processor.py Normal file
View File

@@ -0,0 +1,150 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from typing import List
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState
from pipecat.frames.frames import (
InputAudioRawFrame,
SpeechControlParamsFrame,
UserSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.processors.audio.vad_processor import VADProcessor
from pipecat.tests.utils import run_test
class MockVADAnalyzer(VADAnalyzer):
"""A mock VAD analyzer that returns states from a predefined sequence."""
def __init__(self, states: List[VADState]):
super().__init__(sample_rate=16000)
self._states = list(states)
self._call_index = 0
def num_frames_required(self) -> int:
return 512
def voice_confidence(self, buffer: bytes) -> float:
return 0.9
async def analyze_audio(self, buffer: bytes) -> VADState:
if self._call_index < len(self._states):
state = self._states[self._call_index]
self._call_index += 1
return state
return VADState.QUIET
class TestVADProcessor(unittest.IsolatedAsyncioTestCase):
def _make_audio_frame(self):
return InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
async def test_forwards_audio_frames(self):
"""Test that audio frames are forwarded downstream."""
analyzer = MockVADAnalyzer([VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_pushes_started_speaking_frame(self):
"""Test that VADUserStartedSpeakingFrame is pushed when speech starts."""
analyzer = MockVADAnalyzer([VADState.QUIET, VADState.SPEAKING])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
],
)
async def test_pushes_stopped_speaking_frame(self):
"""Test that VADUserStoppedSpeakingFrame is pushed when speech stops."""
analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
InputAudioRawFrame,
VADUserStoppedSpeakingFrame,
],
)
async def test_pushes_user_speaking_frame(self):
"""Test that UserSpeakingFrame is pushed while speaking."""
analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.SPEAKING])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
InputAudioRawFrame,
UserSpeakingFrame,
],
)
async def test_no_vad_frames_on_starting_state(self):
"""Test that STARTING state doesn't push VAD frames."""
analyzer = MockVADAnalyzer([VADState.STARTING])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_no_vad_frames_on_stopping_state(self):
"""Test that STOPPING state doesn't push VAD frames."""
analyzer = MockVADAnalyzer([VADState.STOPPING])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_no_vad_frames_when_quiet(self):
"""Test that no VAD frames are pushed when staying quiet."""
analyzer = MockVADAnalyzer([VADState.QUIET, VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame, InputAudioRawFrame],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,346 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
BotSpeakingFrame,
InterimTranscriptionFrame,
TranscriptionFrame,
UserSpeakingFrame,
VADUserStartedSpeakingFrame,
)
from pipecat.turns.types import ProcessFrameResult
from pipecat.turns.user_start.wake_phrase_user_turn_start_strategy import (
WakePhraseUserTurnStartStrategy,
_WakeState,
)
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
class TestWakePhraseUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
def _create_strategy(self, **kwargs) -> WakePhraseUserTurnStartStrategy:
kwargs.setdefault("phrases", ["hey pipecat"])
kwargs.setdefault("timeout", 10.0)
return WakePhraseUserTurnStartStrategy(**kwargs)
async def _setup_strategy(self, strategy: WakePhraseUserTurnStartStrategy):
task_manager = TaskManager()
loop = asyncio.get_running_loop()
task_manager.setup(TaskManagerParams(loop=loop))
await strategy.setup(task_manager)
return task_manager
async def test_wake_phrase_in_final_transcription(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_interim_transcription_ignored(self):
"""Interim transcriptions are never used for wake phrase matching."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
InterimTranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_no_wake_phrase_returns_stop(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hello world", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_non_matching_text_resets_aggregation(self):
"""Non-matching transcription triggers aggregation reset to prevent LLM context pollution."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
reset_called = False
@strategy.event_handler("on_reset_aggregation")
async def on_reset_aggregation(strategy):
nonlocal reset_called
reset_called = True
await strategy.process_frame(
TranscriptionFrame(text="hello world", user_id="user1", timestamp="")
)
self.assertTrue(reset_called)
await strategy.cleanup()
async def test_vad_frame_returns_stop_in_listening(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_inactive_returns_continue(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
# Trigger wake phrase first.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Subsequent frames should return CONTINUE.
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.CONTINUE)
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.CONTINUE)
await strategy.cleanup()
async def test_accumulation_across_frames(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hey", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
result = await strategy.process_frame(
TranscriptionFrame(text="pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_multiple_phrases(self):
strategy = self._create_strategy(phrases=["hey pipecat", "ok computer"])
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="ok computer", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_punctuation_stripped(self):
"""STT punctuation like 'Hey, Pipecat!' should still match."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="Hey, Pipecat!", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_reset_preserves_inactive_state(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.reset()
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_timeout_returns_to_listening(self):
strategy = self._create_strategy(timeout=0.1)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for timeout to expire.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_activity_refreshes_timeout(self):
strategy = self._create_strategy(timeout=0.2)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Send activity before timeout.
await asyncio.sleep(0.1)
await strategy.process_frame(UserSpeakingFrame())
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Send more activity.
await asyncio.sleep(0.1)
await strategy.process_frame(BotSpeakingFrame())
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for timeout to expire after last activity.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_wake_phrase_detected_event(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
detected_phrase = None
@strategy.event_handler("on_wake_phrase_detected")
async def on_wake_phrase_detected(strategy, phrase):
nonlocal detected_phrase
detected_phrase = phrase
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
# Event fires in a background task, give it a moment.
await asyncio.sleep(0.05)
self.assertEqual(detected_phrase, "hey pipecat")
await strategy.cleanup()
async def test_wake_phrase_timeout_event(self):
strategy = self._create_strategy(timeout=0.1)
await self._setup_strategy(strategy)
timeout_fired = False
@strategy.event_handler("on_wake_phrase_timeout")
async def on_wake_phrase_timeout(strategy):
nonlocal timeout_fired
timeout_fired = True
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
# Wait for timeout.
await asyncio.sleep(0.3)
self.assertTrue(timeout_fired)
await strategy.cleanup()
async def test_single_activation_stays_inactive_after_reset(self):
"""In single activation mode, reset() keeps INACTIVE so the current turn can finish."""
strategy = self._create_strategy(single_activation=True, timeout=0.5)
await self._setup_strategy(strategy)
# Trigger wake phrase.
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Simulate turn start (controller calls reset on all start strategies).
await strategy.reset()
# State remains INACTIVE so frames continue to flow.
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Subsequent frames should pass through (CONTINUE).
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.CONTINUE)
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.CONTINUE)
await strategy.cleanup()
async def test_single_activation_timeout_returns_to_listening(self):
"""In single activation mode, the keepalive timeout returns to LISTENING."""
strategy = self._create_strategy(single_activation=True, timeout=0.1)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for keepalive timeout to expire.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
# Frames should now be blocked again.
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.STOP)
await strategy.cleanup()
async def test_single_activation_requires_wake_phrase_after_timeout(self):
"""Single activation mode requires wake phrase again after keepalive timeout."""
strategy = self._create_strategy(single_activation=True, timeout=0.1)
await self._setup_strategy(strategy)
# First turn: wake phrase -> INACTIVE -> timeout -> LISTENING.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
# Without wake phrase, frames are blocked.
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
# Second turn: wake phrase again.
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

91
tests/test_xai_tts.py Normal file
View File

@@ -0,0 +1,91 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for XAIHttpTTSService."""
import asyncio
import unittest
import aiohttp
import pytest
from aiohttp import web
from pipecat.frames.frames import (
AggregatedTextFrame,
TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame,
TTSStoppedFrame,
TTSTextFrame,
)
from pipecat.services.xai.tts import XAIHttpTTSService
from pipecat.tests.utils import run_test
@pytest.mark.asyncio
async def test_run_xai_tts_success(aiohttp_client):
"""xAI TTS should send the documented request body and emit PCM frames."""
request_bodies = []
async def handler(request):
request_bodies.append(await request.json())
response = web.StreamResponse(
status=200,
reason="OK",
headers={"Content-Type": "audio/pcm"},
)
await response.prepare(request)
await response.write(b"\x00\x01\x02\x03" * 1024)
await asyncio.sleep(0.01)
await response.write(b"\x04\x05\x06\x07" * 1024)
await response.write_eof()
return response
app = web.Application()
app.router.add_post("/v1/tts", handler)
client = await aiohttp_client(app)
base_url = str(client.make_url("/v1/tts"))
async with aiohttp.ClientSession() as session:
tts_service = XAIHttpTTSService(
api_key="test-key",
base_url=base_url,
aiohttp_session=session,
sample_rate=24000,
)
down_frames, _ = await run_test(
tts_service,
frames_to_send=[TTSSpeakFrame(text="Hello from xAI.")],
)
frame_types = [type(frame) for frame in down_frames]
assert AggregatedTextFrame in frame_types
assert TTSStartedFrame in frame_types
assert TTSStoppedFrame in frame_types
assert TTSTextFrame in frame_types
audio_frames = [frame for frame in down_frames if isinstance(frame, TTSAudioRawFrame)]
assert audio_frames
assert all(frame.sample_rate == 24000 for frame in audio_frames)
assert all(frame.num_channels == 1 for frame in audio_frames)
assert len(request_bodies) == 1
assert request_bodies[0] == {
"text": "Hello from xAI.",
"voice_id": "eve",
"language": "en",
"output_format": {
"codec": "pcm",
"sample_rate": 24000,
},
}
if __name__ == "__main__":
unittest.main()