Clean up Camb TTS service and tests

This commit is contained in:
Neil Ruaro
2026-01-12 22:20:42 +09:00
parent 9293b5f24a
commit 641d17007f
3 changed files with 185 additions and 129 deletions

View File

@@ -34,6 +34,8 @@ from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import LLMRunFrame from pipecat.frames.frames import LLMRunFrame
from pipecat.metrics.metrics import TTFBMetricsData
from pipecat.observers.loggers.metrics_log_observer import MetricsLogObserver
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
@@ -104,6 +106,7 @@ they will be spoken aloud. Avoid special characters, emojis, or bullet points.""
# Create pipeline task # Create pipeline task
# Use 24kHz sample rate to match Camb.ai TTS output # Use 24kHz sample rate to match Camb.ai TTS output
# Add MetricsLogObserver to track TTFB metrics
task = PipelineTask( task = PipelineTask(
pipeline, pipeline,
params=PipelineParams( params=PipelineParams(
@@ -111,6 +114,7 @@ they will be spoken aloud. Avoid special characters, emojis, or bullet points.""
enable_metrics=True, enable_metrics=True,
enable_usage_metrics=True, enable_usage_metrics=True,
), ),
observers=[MetricsLogObserver(include_metrics={TTFBMetricsData})],
) )
# Start the conversation when the pipeline is ready # Start the conversation when the pipeline is ready

View File

@@ -46,6 +46,9 @@ DEFAULT_TIMEOUT = 60.0 # Seconds (minimum recommended by Camb.ai)
MIN_TEXT_LENGTH = 3 MIN_TEXT_LENGTH = 3
MAX_TEXT_LENGTH = 3000 MAX_TEXT_LENGTH = 3000
# Gender mapping for voice listing
GENDER_MAP = {0: "Not Specified", 1: "Male", 2: "Female", 9: "Not Applicable"}
def language_to_camb_language(language: Language) -> Optional[str]: def language_to_camb_language(language: Language) -> Optional[str]:
"""Convert a Pipecat Language enum to Camb.ai language code. """Convert a Pipecat Language enum to Camb.ai language code.
@@ -132,31 +135,21 @@ class CambTTSService(TTSService):
Example:: Example::
# Using API key (creates internal client) # Basic usage with defaults
tts = CambTTSService(api_key="your-api-key")
# With custom voice and model
tts = CambTTSService( tts = CambTTSService(
api_key="your-api-key", api_key="your-api-key",
voice_id=147320, voice_id=12345,
model="mars-flash", model="mars-pro",
params=CambTTSService.InputParams(
language=Language.EN
)
)
# Using existing SDK client
client = AsyncCambAI(api_key="your-api-key")
tts = CambTTSService(
client=client,
voice_id=147320,
model="mars-flash",
) )
# For mars-instruct with custom instructions: # For mars-instruct with custom instructions:
tts_instruct = CambTTSService( tts = CambTTSService(
api_key="your-api-key", api_key="your-api-key",
voice_id=147320,
model="mars-instruct", model="mars-instruct",
params=CambTTSService.InputParams( params=CambTTSService.InputParams(
language=Language.EN,
user_instructions="Speak with excitement and energy" user_instructions="Speak with excitement and energy"
) )
) )
@@ -182,10 +175,10 @@ class CambTTSService(TTSService):
def __init__( def __init__(
self, self,
*, *,
api_key: Optional[str] = None, api_key: str,
client: Optional[AsyncCambAI] = None,
voice_id: int = DEFAULT_VOICE_ID, voice_id: int = DEFAULT_VOICE_ID,
model: str = DEFAULT_MODEL, model: str = DEFAULT_MODEL,
timeout: float = DEFAULT_TIMEOUT,
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
**kwargs, **kwargs,
@@ -193,29 +186,20 @@ class CambTTSService(TTSService):
"""Initialize the Camb.ai TTS service. """Initialize the Camb.ai TTS service.
Args: Args:
api_key: Camb.ai API key for authentication. Required if client is not provided. api_key: Camb.ai API key for authentication.
client: Existing AsyncCambAI client instance. If provided, api_key is ignored. voice_id: Voice ID to use. Defaults to DEFAULT_VOICE_ID.
voice_id: Voice ID to use. Defaults to 147320.
model: TTS model to use. Options: "mars-flash", "mars-pro", "mars-instruct". model: TTS model to use. Options: "mars-flash", "mars-pro", "mars-instruct".
Defaults to "mars-flash" (fastest). Defaults to DEFAULT_MODEL (mars-flash, fastest).
sample_rate: Audio sample rate in Hz. If None, uses Camb.ai default (24000). timeout: Request timeout in seconds. Defaults to DEFAULT_TIMEOUT (60s).
sample_rate: Audio sample rate in Hz. If None, uses DEFAULT_SAMPLE_RATE (24kHz).
params: Additional voice parameters. If None, uses defaults. params: Additional voice parameters. If None, uses defaults.
**kwargs: Additional arguments passed to parent TTSService. **kwargs: Additional arguments passed to parent TTSService.
""" """
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
if client is None and api_key is None:
raise ValueError("Either 'api_key' or 'client' must be provided")
params = params or CambTTSService.InputParams() params = params or CambTTSService.InputParams()
# Use provided client or create one self._client = AsyncCambAI(api_key=api_key, timeout=timeout)
if client is not None:
self._client = client
self._owns_client = False
else:
self._client = AsyncCambAI(api_key=api_key, timeout=DEFAULT_TIMEOUT)
self._owns_client = True
# Build settings # Build settings
self._settings = { self._settings = {
@@ -344,60 +328,43 @@ class CambTTSService(TTSService):
yield TTSStoppedFrame() yield TTSStoppedFrame()
@staticmethod @staticmethod
async def list_voices( async def list_voices(api_key: str) -> List[Dict[str, Any]]:
api_key: Optional[str] = None,
client: Optional[AsyncCambAI] = None,
) -> List[Dict[str, Any]]:
"""Fetch available voices from Camb.ai API. """Fetch available voices from Camb.ai API.
Args: Args:
api_key: Camb.ai API key for authentication. Required if client is not provided. api_key: Camb.ai API key for authentication.
client: Existing AsyncCambAI client instance. If provided, api_key is ignored.
Returns: Returns:
List of voice dictionaries with id, name, gender, and language fields. List of voice dictionaries with id, name, gender, and language fields.
Raises: Raises:
ValueError: If neither api_key nor client is provided.
Exception: If the API request fails. Exception: If the API request fails.
Example:: Example::
# Using API key
voices = await CambTTSService.list_voices(api_key="your-api-key") voices = await CambTTSService.list_voices(api_key="your-api-key")
for voice in voices: for voice in voices:
print(f"{voice['id']}: {voice['name']}") print(f"{voice['id']}: {voice['name']}")
# Using existing client
client = AsyncCambAI(api_key="your-api-key")
voices = await CambTTSService.list_voices(client=client)
""" """
if client is None and api_key is None: client = AsyncCambAI(api_key=api_key)
raise ValueError("Either 'api_key' or 'client' must be provided") voice_list = await client.voice_cloning.list_voices()
gender_map = { voices = []
0: "Not Specified", for voice in voice_list:
1: "Male", voice_id = voice.get("id")
2: "Female", # Skip voices without an ID
9: "Not Applicable", if voice_id is None:
} continue
# Use provided client or create a temporary one gender_int = voice.get("gender")
if client is not None: gender = GENDER_MAP.get(gender_int) if gender_int is not None else None
sdk_client = client
else:
sdk_client = AsyncCambAI(api_key=api_key)
voices = await sdk_client.voice_cloning.list_voices() voices.append({
return [ "id": voice_id,
{ "name": voice.get("voice_name", ""),
"id": v.id if hasattr(v, "id") else v.get("id"), "gender": gender,
"name": v.voice_name if hasattr(v, "voice_name") else v.get("voice_name", "Unknown"), "age": voice.get("age"),
"gender": gender_map.get( "language": voice.get("language"),
v.gender if hasattr(v, "gender") else v.get("gender"), "Unknown" })
),
"age": v.age if hasattr(v, "age") else v.get("age"), return voices
"language": v.language if hasattr(v, "language") else v.get("language"),
}
for v in voices
]

View File

@@ -9,6 +9,7 @@
These tests mock the Camb.ai SDK client to test the service behavior. These tests mock the Camb.ai SDK client to test the service behavior.
""" """
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -22,7 +23,12 @@ from pipecat.frames.frames import (
TTSStoppedFrame, TTSStoppedFrame,
TTSTextFrame, TTSTextFrame,
) )
from pipecat.services.camb.tts import CambTTSService, language_to_camb_language from pipecat.services.camb.tts import (
CambTTSService,
DEFAULT_SAMPLE_RATE,
DEFAULT_VOICE_ID,
language_to_camb_language,
)
from pipecat.tests.utils import run_test from pipecat.tests.utils import run_test
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
@@ -49,14 +55,10 @@ async def test_run_camb_tts_success():
mock_client.text_to_speech.tts = mock_tts_stream mock_client.text_to_speech.tts = mock_tts_stream
MockAsyncCambAI.return_value = mock_client MockAsyncCambAI.return_value = mock_client
tts_service = CambTTSService( tts_service = CambTTSService(api_key="test-api-key")
api_key="test-api-key",
voice_id=2681,
model="mars-flash",
)
# Manually set sample rate (normally done by StartFrame) # Manually set sample rate (normally done by StartFrame)
tts_service._sample_rate = 24000 tts_service._sample_rate = DEFAULT_SAMPLE_RATE
# Test run_tts directly to avoid frame count variability # Test run_tts directly to avoid frame count variability
text = "Hello world, this is a test." text = "Hello world, this is a test."
@@ -73,9 +75,9 @@ async def test_run_camb_tts_success():
audio_frames = [f for f in frames if isinstance(f, TTSAudioRawFrame)] audio_frames = [f for f in frames if isinstance(f, TTSAudioRawFrame)]
assert len(audio_frames) > 0, "Should have at least one audio frame" assert len(audio_frames) > 0, "Should have at least one audio frame"
# Verify sample rate matches Camb.ai's output (24kHz) # Verify sample rate matches Camb.ai's output
for a_frame in audio_frames: for a_frame in audio_frames:
assert a_frame.sample_rate == 24000, "Sample rate should be 24000 Hz" assert a_frame.sample_rate == DEFAULT_SAMPLE_RATE
assert a_frame.num_channels == 1, "Should be mono audio" assert a_frame.num_channels == 1, "Should be mono audio"
@@ -87,10 +89,7 @@ async def test_run_camb_tts_error():
mock_client.text_to_speech.tts = mock_tts_stream_error mock_client.text_to_speech.tts = mock_tts_stream_error
MockAsyncCambAI.return_value = mock_client MockAsyncCambAI.return_value = mock_client
tts_service = CambTTSService( tts_service = CambTTSService(api_key="invalid-key")
api_key="invalid-key",
voice_id=147320,
)
frames_to_send = [ frames_to_send = [
TTSSpeakFrame(text="This should fail."), TTSSpeakFrame(text="This should fail."),
@@ -114,25 +113,26 @@ async def test_run_camb_tts_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_voices(): async def test_list_voices():
"""Test voice listing endpoint.""" """Test voice listing endpoint with dict responses."""
async def mock_list_voices(*args, **kwargs): async def mock_list_voices(*args, **kwargs):
# Return mock Voice objects # Return mock voice dicts (as returned by the API)
mock_voice1 = MagicMock() return [
mock_voice1.id = 2681 {
mock_voice1.voice_name = "Attic" "id": 2681,
mock_voice1.gender = 1 "voice_name": "Attic",
mock_voice1.age = 25 "gender": 1,
mock_voice1.language = None "age": 25,
"language": None,
mock_voice2 = MagicMock() },
mock_voice2.id = 2682 {
mock_voice2.voice_name = "Cellar" "id": 2682,
mock_voice2.gender = 2 "voice_name": "Cellar",
mock_voice2.age = 30 "gender": 2,
mock_voice2.language = 1 "age": 30,
"language": "en-us",
return [mock_voice1, mock_voice2] },
]
with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI: with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock() mock_client = MagicMock()
@@ -151,6 +151,50 @@ async def test_list_voices():
assert attic_voice["age"] == 25 assert attic_voice["age"] == 25
@pytest.mark.asyncio
async def test_list_voices_skips_none_id():
"""Test that voices without an ID are skipped."""
async def mock_list_voices(*args, **kwargs):
return [
{"id": 123, "voice_name": "Valid", "gender": 1, "age": 25, "language": None},
{"id": None, "voice_name": "NoID", "gender": 2, "age": 30, "language": None},
{"voice_name": "MissingID", "gender": 1, "age": 35, "language": None},
]
with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock()
mock_client.voice_cloning.list_voices = mock_list_voices
MockAsyncCambAI.return_value = mock_client
voices = await CambTTSService.list_voices(api_key="test-api-key")
# Should only return the voice with a valid ID
assert len(voices) == 1
assert voices[0]["id"] == 123
assert voices[0]["name"] == "Valid"
@pytest.mark.asyncio
async def test_list_voices_handles_none_gender():
"""Test that None gender is handled correctly."""
async def mock_list_voices(*args, **kwargs):
return [
{"id": 123, "voice_name": "NoGender", "gender": None, "age": 25, "language": None},
]
with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock()
mock_client.voice_cloning.list_voices = mock_list_voices
MockAsyncCambAI.return_value = mock_client
voices = await CambTTSService.list_voices(api_key="test-api-key")
assert len(voices) == 1
assert voices[0]["gender"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_text_length_validation_too_short(): async def test_text_length_validation_too_short():
"""Test that text shorter than 3 characters is handled gracefully.""" """Test that text shorter than 3 characters is handled gracefully."""
@@ -160,10 +204,7 @@ async def test_text_length_validation_too_short():
mock_client.text_to_speech.tts = AsyncMock(side_effect=AssertionError("TTS should not be called")) mock_client.text_to_speech.tts = AsyncMock(side_effect=AssertionError("TTS should not be called"))
MockAsyncCambAI.return_value = mock_client MockAsyncCambAI.return_value = mock_client
tts_service = CambTTSService( tts_service = CambTTSService(api_key="test-api-key")
api_key="test-api-key",
voice_id=147320,
)
frames_to_send = [ frames_to_send = [
TTSSpeakFrame(text="Hi"), # Only 2 characters TTSSpeakFrame(text="Hi"), # Only 2 characters
@@ -260,39 +301,83 @@ async def test_mars_instruct_model():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_initialization_with_api_key(): async def test_client_initialization():
"""Test that client is created when api_key is provided.""" """Test that client is created with correct parameters."""
with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI: with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock() mock_client = MagicMock()
MockAsyncCambAI.return_value = mock_client MockAsyncCambAI.return_value = mock_client
tts = CambTTSService( CambTTSService(api_key="test-key", timeout=120.0)
api_key="test-key",
voice_id=147320,
)
# Should have created a client # Should have created a client with correct params
MockAsyncCambAI.assert_called_once() MockAsyncCambAI.assert_called_once_with(api_key="test-key", timeout=120.0)
assert tts._owns_client is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_initialization_with_existing_client(): async def test_default_voice_id_used():
"""Test that existing client is used when provided.""" """Test that DEFAULT_VOICE_ID is used when not specified."""
mock_client = MagicMock() with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock()
MockAsyncCambAI.return_value = mock_client
tts = CambTTSService( tts = CambTTSService(api_key="test-key")
client=mock_client,
voice_id=147320,
)
# Should use the provided client assert tts._voice_id_int == DEFAULT_VOICE_ID
assert tts._client is mock_client
assert tts._owns_client is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_client_initialization_requires_api_key_or_client(): async def test_ttfb_metrics_tracked():
"""Test that ValueError is raised when neither api_key nor client is provided.""" """Test that TTFB metrics are properly tracked during TTS generation."""
with pytest.raises(ValueError, match="Either 'api_key' or 'client' must be provided"): import time
CambTTSService(voice_id=147320)
ttfb_start_called = False
ttfb_stop_called = False
start_time = None
stop_time = None
async def mock_tts_with_delay(*args, **kwargs):
# Simulate some network delay
await asyncio.sleep(0.05)
yield b"\x00\x01" * 4800
with patch("pipecat.services.camb.tts.AsyncCambAI") as MockAsyncCambAI:
mock_client = MagicMock()
mock_client.text_to_speech.tts = mock_tts_with_delay
MockAsyncCambAI.return_value = mock_client
tts_service = CambTTSService(api_key="test-api-key")
tts_service._sample_rate = DEFAULT_SAMPLE_RATE
# Patch the metrics methods to track calls
original_start_ttfb = tts_service.start_ttfb_metrics
original_stop_ttfb = tts_service.stop_ttfb_metrics
async def patched_start_ttfb():
nonlocal ttfb_start_called, start_time
ttfb_start_called = True
start_time = time.time()
await original_start_ttfb()
async def patched_stop_ttfb():
nonlocal ttfb_stop_called, stop_time
if not ttfb_stop_called: # Only record first stop
ttfb_stop_called = True
stop_time = time.time()
await original_stop_ttfb()
tts_service.start_ttfb_metrics = patched_start_ttfb
tts_service.stop_ttfb_metrics = patched_stop_ttfb
# Run TTS
frames = []
async for frame in tts_service.run_tts("Hello, this is a TTFB test."):
frames.append(frame)
# Verify TTFB tracking was called
assert ttfb_start_called, "start_ttfb_metrics should be called"
assert ttfb_stop_called, "stop_ttfb_metrics should be called"
assert start_time is not None and stop_time is not None
# TTFB should be >= simulated delay
ttfb = stop_time - start_time
assert ttfb >= 0.05, f"TTFB ({ttfb:.3f}s) should be >= 0.05s (simulated delay)"