Merge pull request #3585 from pipecat-ai/aleix/improve-piper-tts-support

improve Piper TTS support
This commit is contained in:
Aleix Conchillo Flaqué
2026-01-29 08:36:13 -08:00
committed by GitHub
12 changed files with 349 additions and 32 deletions

View File

@@ -6,7 +6,9 @@
"""Piper TTS service implementation."""
from typing import AsyncGenerator, Optional
import asyncio
from pathlib import Path
from typing import AsyncGenerator, AsyncIterator, Optional
import aiohttp
from loguru import logger
@@ -20,11 +22,128 @@ from pipecat.frames.frames import (
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}")
# This assumes a running TTS service running: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/API_HTTP.md
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.
"""
def __init__(
self,
*,
voice_id: str,
download_dir: Optional[Path] = None,
force_redownload: bool = False,
use_cuda: bool = False,
**kwargs,
):
"""Initialize the Piper TTS service.
Args:
voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`).
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.
**kwargs: Additional arguments passed to the parent `TTSService`.
"""
super().__init__(**kwargs)
self._voice_id = voice_id
download_dir = download_dir or Path.cwd()
model_file = f"{voice_id}.onnx"
model_path = Path(download_dir) / model_file
if not model_path.exists():
logger.debug(f"Downloading Piper '{voice_id}' model")
download_voice(voice_id, download_dir, force_redownload=force_redownload)
logger.debug(f"Loading Piper '{voice_id}' model from {model_path}")
self._voice = PiperVoice.load(model_path, use_cuda=use_cuda)
logger.debug(f"Loaded Piper '{voice_id}' 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
@traced_tts
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using Piper.
Args:
text: The text to convert to speech.
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_ttfb_metrics()
await self.start_tts_usage_metrics(text)
yield TTSStartedFrame()
async for frame in self._stream_audio_frames_from_iterator(
async_iterator(self._voice.synthesize(text)),
in_sample_rate=self._voice.config.sample_rate,
):
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()
yield TTSStoppedFrame()
# 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
#
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.
@@ -35,9 +154,7 @@ class PiperTTSService(TTSService):
*,
base_url: str,
aiohttp_session: aiohttp.ClientSession,
# When using Piper, the sample rate of the generated audio depends on the
# voice model being used.
sample_rate: Optional[int] = None,
voice_id: Optional[str] = None,
**kwargs,
):
"""Initialize the Piper TTS service.
@@ -45,10 +162,10 @@ class PiperTTSService(TTSService):
Args:
base_url: Base URL for the Piper TTS HTTP server.
aiohttp_session: aiohttp ClientSession for making HTTP requests.
sample_rate: Output sample rate. If None, uses the voice model's native rate.
voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`).
**kwargs: Additional arguments passed to the parent TTSService.
"""
super().__init__(sample_rate=sample_rate, **kwargs)
super().__init__(**kwargs)
if base_url.endswith("/"):
logger.warning("Base URL ends with a slash, this is not allowed.")
@@ -56,7 +173,7 @@ class PiperTTSService(TTSService):
self._base_url = base_url
self._session = aiohttp_session
self._settings = {"base_url": base_url}
self._model_id = voice_id
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
@@ -83,9 +200,12 @@ class PiperTTSService(TTSService):
try:
await self.start_ttfb_metrics()
async with self._session.post(
self._base_url, json={"text": text}, headers=headers
) as response:
data = {
"text": text,
"voice": self._model_id,
}
async with self._session.post(self._base_url, json=data, headers=headers) as response:
if response.status != 200:
error = await response.text()
yield ErrorFrame(

View File

@@ -24,6 +24,7 @@ from typing import (
from loguru import logger
from pipecat.audio.utils import create_stream_resampler
from pipecat.frames.frames import (
AggregatedTextFrame,
AggregationType,
@@ -202,6 +203,8 @@ class TTSService(AIService):
)
self._text_filters = [text_filter]
self._resampler = create_stream_resampler()
self._stop_frame_task: Optional[asyncio.Task] = None
self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
@@ -505,12 +508,40 @@ class TTSService(AIService):
await self._stop_frame_queue.put(frame)
async def _stream_audio_frames_from_iterator(
self, iterator: AsyncIterator[bytes], *, strip_wav_header: bool
self,
iterator: AsyncIterator[bytes],
*,
strip_wav_header: bool = False,
in_sample_rate: Optional[int] = None,
) -> AsyncGenerator[Frame, None]:
"""Stream audio frames from an async byte iterator with optional resampling.
For WAV data, use `strip_wav_header=True` to strip the header and
auto-detect the source sample rate. For raw PCM data, pass
`in_sample_rate` directly. Audio is resampled to `self.sample_rate` when
the source rate differs.
Args:
iterator: Async iterator yielding audio bytes.
strip_wav_header: Strip WAV header and parse source sample rate from it.
in_sample_rate: Source sample rate for raw PCM data. Overrides
WAV-detected rate if both are provided.
"""
buffer = bytearray()
source_sample_rate = in_sample_rate
need_to_strip_wav_header = strip_wav_header
async def maybe_resample(audio: bytes) -> bytes:
if source_sample_rate and source_sample_rate != self.sample_rate:
return await self._resampler.resample(audio, source_sample_rate, self.sample_rate)
return audio
async for chunk in iterator:
if need_to_strip_wav_header and chunk.startswith(b"RIFF"):
# Parse sample rate from WAV header (bytes 24-28, little-endian uint32).
if len(chunk) >= 44 and source_sample_rate is None:
source_sample_rate = int.from_bytes(chunk[24:28], "little")
chunk = chunk[44:]
need_to_strip_wav_header = False
@@ -520,19 +551,18 @@ class TTSService(AIService):
# Round to nearest even number.
aligned_length = len(buffer) & ~1 # 111111111...11110
if aligned_length > 0:
aligned_chunk = buffer[:aligned_length]
aligned_chunk = await maybe_resample(bytes(buffer[:aligned_length]))
buffer = buffer[aligned_length:] # keep any leftover byte
if len(aligned_chunk) > 0:
frame = TTSAudioRawFrame(bytes(aligned_chunk), self.sample_rate, 1)
yield frame
yield TTSAudioRawFrame(aligned_chunk, self.sample_rate, 1)
if len(buffer) > 0:
# Make sure we don't need an extra padding byte.
if len(buffer) % 2 == 1:
buffer.extend(b"\x00")
frame = TTSAudioRawFrame(bytes(buffer), self.sample_rate, 1)
yield frame
audio = await maybe_resample(bytes(buffer))
yield TTSAudioRawFrame(audio, self.sample_rate, 1)
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
self._processing_text = False