Merge branch 'main' into fix/heartbeat-monitor-configurable
This commit is contained in:
1
tests/genesys/__init__.py
Normal file
1
tests/genesys/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
192
tests/genesys/conftest.py
Normal file
192
tests/genesys/conftest.py
Normal 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": "#",
|
||||
},
|
||||
}
|
||||
376
tests/genesys/test_genesys_serializer.py
Normal file
376
tests/genesys/test_genesys_serializer.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
831
tests/test_aic_filter.py
Normal 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
322
tests/test_aic_vad.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
735
tests/test_context_aggregators_universal.py
Normal file
735
tests/test_context_aggregators_universal.py
Normal 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()
|
||||
1178
tests/test_context_summarization.py
Normal file
1178
tests/test_context_summarization.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
87
tests/test_deepgram_stt.py
Normal file
87
tests/test_deepgram_stt.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
206
tests/test_fastapi_websocket.py
Normal file
206
tests/test_fastapi_websocket.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, 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
81
tests/test_google_llm_openai.py
Normal file
81
tests/test_google_llm_openai.py
Normal 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"
|
||||
55
tests/test_google_utils.py
Normal file
55
tests/test_google_utils.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
200
tests/test_krisp_sdk_manager.py
Normal file
200
tests/test_krisp_sdk_manager.py
Normal 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()
|
||||
777
tests/test_krisp_viva_filter.py
Normal file
777
tests/test_krisp_viva_filter.py
Normal 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()
|
||||
442
tests/test_krisp_viva_vad.py
Normal file
442
tests/test_krisp_viva_vad.py
Normal 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()
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, 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()
|
||||
|
||||
124
tests/test_livekit_transport.py
Normal file
124
tests/test_livekit_transport.py
Normal 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()
|
||||
765
tests/test_llm_context_summarizer.py
Normal file
765
tests/test_llm_context_summarizer.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
67
tests/test_novita_llm.py
Normal 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"
|
||||
299
tests/test_openai_llm_timeout.py
Normal file
299
tests/test_openai_llm_timeout.py
Normal 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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
177
tests/test_rnnoise_cancellation.py
Normal file
177
tests/test_rnnoise_cancellation.py
Normal 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()
|
||||
149
tests/test_rnnoise_filter.py
Normal file
149
tests/test_rnnoise_filter.py
Normal 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()
|
||||
138
tests/test_rnnoise_resampling.py
Normal file
138
tests/test_rnnoise_resampling.py
Normal 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()
|
||||
@@ -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
153
tests/test_runner_utils.py
Normal 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()
|
||||
72
tests/test_sambanova_llm.py
Normal file
72
tests/test_sambanova_llm.py
Normal 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
180
tests/test_service_init.py
Normal 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"
|
||||
)
|
||||
251
tests/test_service_language.py
Normal file
251
tests/test_service_language.py
Normal 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]
|
||||
@@ -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
983
tests/test_settings.py
Normal 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."
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
337
tests/test_startup_timing_observer.py
Normal file
337
tests/test_startup_timing_observer.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
117
tests/test_sync_parallel_pipeline.py
Normal file
117
tests/test_sync_parallel_pipeline.py
Normal 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()
|
||||
127
tests/test_tracing_context.py
Normal file
127
tests/test_tracing_context.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
410
tests/test_tts_frame_ordering.py
Normal file
410
tests/test_tts_frame_ordering.py
Normal 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()
|
||||
505
tests/test_turn_trace_observer.py
Normal file
505
tests/test_turn_trace_observer.py
Normal 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()
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
631
tests/test_user_bot_latency_observer.py
Normal file
631
tests/test_user_bot_latency_observer.py
Normal 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()
|
||||
323
tests/test_user_idle_controller.py
Normal file
323
tests/test_user_idle_controller.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
143
tests/test_user_mute_strategy.py
Normal file
143
tests/test_user_mute_strategy.py
Normal 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()
|
||||
261
tests/test_user_turn_completion_mixin.py
Normal file
261
tests/test_user_turn_completion_mixin.py
Normal 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()
|
||||
272
tests/test_user_turn_controller.py
Normal file
272
tests/test_user_turn_controller.py
Normal 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()
|
||||
164
tests/test_user_turn_processor.py
Normal file
164
tests/test_user_turn_processor.py
Normal 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()
|
||||
205
tests/test_user_turn_start_strategy.py
Normal file
205
tests/test_user_turn_start_strategy.py
Normal 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()
|
||||
653
tests/test_user_turn_stop_strategy.py
Normal file
653
tests/test_user_turn_stop_strategy.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
210
tests/test_vad_controller.py
Normal file
210
tests/test_vad_controller.py
Normal 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
150
tests/test_vad_processor.py
Normal 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()
|
||||
346
tests/test_wake_phrase_user_turn_start_strategy.py
Normal file
346
tests/test_wake_phrase_user_turn_start_strategy.py
Normal 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()
|
||||
@@ -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
91
tests/test_xai_tts.py
Normal 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()
|
||||
Reference in New Issue
Block a user