Merge pull request #3585 from pipecat-ai/aleix/improve-piper-tts-support
improve Piper TTS support
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user