Files
pipecat/src/pipecat/services/piper/tts.py
Paul Kompfner 6b168d6bbb Prefer Service.Settings over raw settings class names across all services
Replace direct references to settings class names (e.g. `FooSettings`) with the nested `Settings` alias form throughout all 87 service files:
- Type annotations: `Settings`
- Runtime code: `self.Settings`
- Docstrings: `ServiceClass.Settings`
- Cross-file inheritance: `ParentService.Settings`

This makes the `Settings` alias the canonical way to reference a service's settings, keeping only the class definition and alias assignment as the remaining hits for each raw settings class name.
2026-03-11 12:15:00 -04:00

311 lines
10 KiB
Python

#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Piper TTS service implementation."""
import asyncio
from dataclasses import dataclass
from pathlib import Path
from typing import Any, AsyncGenerator, AsyncIterator, Optional
import aiohttp
from loguru import logger
from pipecat.frames.frames import (
ErrorFrame,
Frame,
TTSStoppedFrame,
)
from pipecat.services.settings import TTSSettings, _warn_deprecated_param
from pipecat.services.tts_service import TTSService
from pipecat.utils.tracing.service_decorators import traced_tts
try:
from piper import PiperVoice
from piper.download_voices import download_voice
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use Piper, you need to `pip install pipecat-ai[piper]`.")
raise Exception(f"Missing module: {e}")
@dataclass
class PiperTTSSettings(TTSSettings):
"""Settings for PiperTTSService."""
pass
class PiperTTSService(TTSService):
"""Piper TTS service implementation.
Provides local text-to-speech synthesis using Piper voice models. Automatically
downloads voice models if not already present and resamples audio output to
match the configured sample rate.
"""
Settings = PiperTTSSettings
_settings: Settings
def __init__(
self,
*,
voice_id: Optional[str] = None,
download_dir: Optional[Path] = None,
force_redownload: bool = False,
use_cuda: bool = False,
settings: Optional[Settings] = None,
**kwargs,
):
"""Initialize the Piper TTS service.
Args:
voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`).
.. deprecated:: 0.0.105
Use ``settings=PiperTTSService.Settings(voice=...)`` instead.
download_dir: Directory for storing voice model files. Defaults to
the current working directory.
force_redownload: Re-download the voice model even if it already exists.
use_cuda: Use CUDA for GPU-accelerated inference.
settings: Runtime-updatable settings. When provided alongside deprecated
parameters, ``settings`` values take precedence.
**kwargs: Additional arguments passed to the parent `TTSService`.
"""
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(model=None, voice=None, language=None)
# 2. Apply direct init arg overrides (deprecated)
if voice_id is not None:
_warn_deprecated_param("voice_id", self.Settings, "voice")
default_settings.voice = voice_id
# 3. (No step 3, as there's no params object to apply)
# 4. Apply settings delta (canonical API, always wins)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
push_start_frame=True,
push_stop_frames=True,
settings=default_settings,
**kwargs,
)
download_dir = download_dir or Path.cwd()
_voice = self._settings.voice
model_file = f"{_voice}.onnx"
model_path_resolved = Path(download_dir) / model_file
if not model_path_resolved.exists():
logger.debug(f"Downloading Piper '{_voice}' model")
download_voice(_voice, download_dir, force_redownload=force_redownload)
logger.debug(f"Loading Piper '{_voice}' model from {model_path_resolved}")
self._voice = PiperVoice.load(model_path_resolved, use_cuda=use_cuda)
logger.debug(f"Loaded Piper '{_voice}' model")
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
Returns:
True, as Piper service supports metrics generation.
"""
return True
async def _update_settings(self, delta: Settings) -> dict[str, Any]:
"""Apply a settings delta.
Settings are stored but not applied to the active connection.
"""
changed = await super()._update_settings(delta)
if not changed:
return changed
# TODO: voice changes would require re-downloading and loading the model.
self._warn_unhandled_updated_settings(changed)
return changed
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using Piper.
Args:
text: The text to convert to speech.
context_id: Unique identifier for this TTS context.
Yields:
Frame: Audio frames containing the synthesized speech and status frames.
"""
def async_next(it):
try:
return next(it)
except StopIteration:
return None
async def async_iterator(iterator) -> AsyncIterator[bytes]:
while True:
item = await asyncio.to_thread(async_next, iterator)
if item is None:
return
yield item.audio_int16_bytes
logger.debug(f"{self}: Generating TTS [{text}]")
try:
await self.start_tts_usage_metrics(text)
async for frame in self._stream_audio_frames_from_iterator(
async_iterator(self._voice.synthesize(text)),
in_sample_rate=self._voice.config.sample_rate,
context_id=context_id,
):
await self.stop_ttfb_metrics()
yield frame
except Exception as e:
logger.error(f"{self} exception: {e}")
yield ErrorFrame(error=f"Unknown error occurred: {e}")
finally:
logger.debug(f"{self}: Finished TTS [{text}]")
await self.stop_ttfb_metrics()
# This assumes a running TTS service running:
# https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/API_HTTP.md
#
# Usage:
#
# $ uv pip install "piper-tts[http]"
# $ uv run python -m piper.http_server -m en_US-ryan-high
#
@dataclass
class PiperHttpTTSSettings(TTSSettings):
"""Settings for PiperHttpTTSService."""
pass
class PiperHttpTTSService(TTSService):
"""Piper HTTP TTS service implementation.
Provides integration with Piper's HTTP TTS server for text-to-speech
synthesis. Supports streaming audio generation with configurable sample
rates and automatic WAV header removal.
"""
Settings = PiperHttpTTSSettings
_settings: Settings
def __init__(
self,
*,
base_url: str,
aiohttp_session: aiohttp.ClientSession,
voice_id: Optional[str] = None,
settings: Optional[Settings] = None,
**kwargs,
):
"""Initialize the Piper TTS service.
Args:
base_url: Base URL for the Piper TTS HTTP server.
aiohttp_session: aiohttp ClientSession for making HTTP requests.
voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`).
.. deprecated:: 0.0.105
Use ``settings=PiperHttpTTSService.Settings(voice=...)`` instead.
settings: Runtime-updatable settings. When provided alongside deprecated
parameters, ``settings`` values take precedence.
**kwargs: Additional arguments passed to the parent TTSService.
"""
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(model=None, voice=None, language=None)
# 2. Apply direct init arg overrides (deprecated)
if voice_id is not None:
_warn_deprecated_param("voice_id", self.Settings, "voice")
default_settings.voice = voice_id
# 3. (No step 3, as there's no params object to apply)
# 4. Apply settings delta (canonical API, always wins)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
push_start_frame=True,
push_stop_frames=True,
settings=default_settings,
**kwargs,
)
if base_url.endswith("/"):
logger.warning("Base URL ends with a slash, this is not allowed.")
base_url = base_url[:-1]
self._base_url = base_url
self._session = aiohttp_session
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
Returns:
True, as Piper 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 Piper's HTTP API.
Args:
text: The text to convert to speech.
context_id: Unique identifier for this TTS context.
Yields:
Frame: Audio frames containing the synthesized speech and status frames.
"""
logger.debug(f"{self}: Generating TTS [{text}]")
headers = {
"Content-Type": "application/json",
}
try:
data = {
"text": text,
"voice": self._settings.voice,
}
async with self._session.post(self._base_url, json=data, headers=headers) as response:
if response.status != 200:
error = await response.text()
yield ErrorFrame(
error=f"Error getting audio (status: {response.status}, error: {error})"
)
yield TTSStoppedFrame(context_id=context_id)
return
await self.start_tts_usage_metrics(text)
CHUNK_SIZE = self.chunk_size
async for frame in self._stream_audio_frames_from_iterator(
response.content.iter_chunked(CHUNK_SIZE),
strip_wav_header=True,
context_id=context_id,
):
await self.stop_ttfb_metrics()
yield frame
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")
finally:
await self.stop_ttfb_metrics()