Files
pipecat/src/pipecat/services/speechmatics/tts.py
Paul Kompfner 6a337f1bc6 fix(types): assert_given at store-mode settings read sites
Apply assert_given across service modules to narrow reads from
store-mode settings fields (self._settings.X, default_settings.X),
where _NotGiven is declared in the field type but should never appear
at runtime (enforced by validate_complete()).

Two idioms used:

- Inline wrap for single uses:
    func(assert_given(self._settings.enable_prompt_caching), ...)

- Extract-and-reuse when the same value is used multiple times:
    thinking = assert_given(self._settings.thinking)
    if thinking:
        params["thinking"] = thinking.model_dump(...)

43 service files touched. Cleared ~172 pyright errors; remaining
_NotGiven-related errors are in adjacent categories (flavor mismatch
between openai/anthropic NotGiven and pipecat _NotGiven, settings
field types that should allow None but don't) that need different
fixes.
2026-04-23 17:39:17 -04:00

298 lines
10 KiB
Python

#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Speechmatics TTS service integration."""
import asyncio
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from urllib.parse import urlencode
import aiohttp
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
ErrorFrame,
Frame,
TTSAudioRawFrame,
)
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven, assert_given
from pipecat.services.tts_service import TTSService
from pipecat.utils.network import exponential_backoff_time
from pipecat.utils.tracing.service_decorators import traced_tts
try:
from speechmatics.rt import __version__
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error(
"In order to use Speechmatics, you need to `pip install pipecat-ai[speechmatics]`."
)
raise Exception(f"Missing module: {e}")
@dataclass
class SpeechmaticsTTSSettings(TTSSettings):
"""Settings for SpeechmaticsTTSService.
Parameters:
max_retries: Maximum number of retries for HTTP requests.
"""
max_retries: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class SpeechmaticsTTSService(TTSService):
"""Speechmatics TTS service implementation.
This service provides text-to-speech synthesis using the Speechmatics HTTP API.
It converts text to speech and returns raw PCM audio data for real-time playback.
"""
Settings = SpeechmaticsTTSSettings
_settings: Settings
SPEECHMATICS_SAMPLE_RATE = 16000
class InputParams(BaseModel):
"""Optional input parameters for Speechmatics TTS configuration.
.. deprecated:: 0.0.105
Use ``settings=SpeechmaticsTTSService.Settings(...)`` instead.
Parameters:
max_retries: Maximum number of retries for TTS requests. Defaults to 5.
"""
max_retries: int = 5
def __init__(
self,
*,
api_key: str,
base_url: str = "https://preview.tts.speechmatics.com",
voice_id: str | None = None,
aiohttp_session: aiohttp.ClientSession,
sample_rate: int | None = SPEECHMATICS_SAMPLE_RATE,
params: InputParams | None = None,
settings: Settings | None = None,
**kwargs,
):
"""Initialize the Speechmatics TTS service.
Args:
api_key: Speechmatics API key for authentication.
base_url: Base URL for Speechmatics TTS API.
voice_id: Voice model to use for synthesis.
.. deprecated:: 0.0.105
Use ``settings=SpeechmaticsTTSService.Settings(voice=...)`` instead.
aiohttp_session: Shared aiohttp session for HTTP requests.
sample_rate: Audio sample rate in Hz.
params: Input parameters for the service.
.. deprecated:: 0.0.105
Use ``settings=SpeechmaticsTTSService.Settings(...)`` instead.
settings: Runtime-updatable settings. When provided alongside deprecated
parameters, ``settings`` values take precedence.
**kwargs: Additional arguments passed to TTSService.
"""
if sample_rate and sample_rate != self.SPEECHMATICS_SAMPLE_RATE:
logger.warning(
f"Speechmatics TTS only supports {self.SPEECHMATICS_SAMPLE_RATE}Hz sample rate. "
f"Current rate of {sample_rate}Hz may cause issues."
)
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(
model=None,
voice="sarah",
language=None,
max_retries=5,
)
# 2. Apply direct init arg overrides (deprecated)
if voice_id is not None:
self._warn_init_param_moved_to_settings("voice_id", "voice")
default_settings.voice = voice_id
# 3. Apply params overrides — only if settings not provided
if params is not None:
self._warn_init_param_moved_to_settings("params")
if not settings:
default_settings.max_retries = params.max_retries
# 4. Apply settings delta (canonical API, always wins)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
sample_rate=sample_rate,
push_start_frame=True,
push_stop_frames=True,
settings=default_settings,
**kwargs,
)
# Service parameters
self._api_key: str = api_key
self._base_url: str = base_url
self._session = aiohttp_session
# Check we have required attributes
if not self._api_key:
raise ValueError("Missing Speechmatics API key")
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
Returns:
True, as Speechmatics service supports metrics generation.
"""
return True
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using Speechmatics' HTTP API.
Args:
text: The text to synthesize into speech.
context_id: The context ID for tracking audio frames.
Yields:
Frame: Audio frames containing the synthesized speech.
"""
# Log the TTS started frame
logger.debug(f"{self}: Generating TTS [{text}]")
# HTTP headers
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
# HTTP payload
payload = {
"text": text,
}
# Complete HTTP URL
url = _get_endpoint_url(
self._base_url, assert_given(self._settings.voice), self.sample_rate
)
try:
# Track attempt
attempt = 0
# Keep retrying until we get a 200 response or timeout
while True:
async with self._session.post(url, json=payload, headers=headers) as response:
"""Evaluate response from TTS service."""
# 503 : Service unavailable
if response.status == 503:
"""Calculate the backoff time and retry."""
try:
# Calculate the backoff time
backoff_time = exponential_backoff_time(
attempt=attempt, min_wait=0.25, max_wait=8.0, multiplier=0.5
)
# Increment attempt
attempt += 1
# Check if we've exceeded the maximum number of attempts
max_retries = assert_given(self._settings.max_retries)
if max_retries is not None and attempt >= max_retries:
raise ValueError()
# Report error frame
yield ErrorFrame(
error=f"Service unavailable [503] (attempt {attempt}, retry in {backoff_time:.2f}s)"
)
# Wait before retrying
await asyncio.sleep(backoff_time)
# Retry
continue
except (ValueError, ArithmeticError):
yield ErrorFrame(
error=f"Service unavailable [503] (attempts {attempt})",
)
return
# != 200 : Error
if response.status != 200:
yield ErrorFrame(error=f"Service unavailable [{response.status}]")
return
# Update Pipecat metrics
await self.start_tts_usage_metrics(text)
# Process the response in streaming chunks
first_chunk = True
buffer = b""
# Iterate over each audio data chunk from the TTS API
async for chunk in response.content.iter_any():
if not chunk:
continue
if first_chunk:
await self.stop_ttfb_metrics()
first_chunk = False
buffer += chunk
# Emit all complete 2-byte int16 samples from buffer
if len(buffer) >= 2:
complete_samples = len(buffer) // 2
complete_bytes = complete_samples * 2
audio_data = buffer[:complete_bytes]
buffer = buffer[complete_bytes:]
# Emit the audio frame
yield TTSAudioRawFrame(
audio=audio_data,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
# Successfully processed the response, break out of retry loop
break
except Exception as e:
yield ErrorFrame(error=f"Error generating TTS: {e}")
finally:
await self.stop_ttfb_metrics()
def _get_endpoint_url(base_url: str, voice: str, sample_rate: int) -> str:
"""Format the TTS endpoint URL with voice, output format, and version params.
Args:
base_url: The base URL for the TTS endpoint.
voice: The voice model to use.
sample_rate: The audio sample rate.
Returns:
str: The formatted TTS endpoint URL.
"""
query_params = {}
query_params["output_format"] = f"pcm_{sample_rate}"
query_params["sm-app"] = f"pipecat/{__version__}"
query = urlencode(query_params)
return f"{base_url}/generate/{voice}?{query}"