feat(P01-02-02): Cartesia + Piper TTS adapters behind TTSProvider

server/tts/cartesia_tts.py wraps the raw Cartesia Sonic WebSocket API
(wss://api.cartesia.ai/tts/websocket) — streaming PCM s16le, one voice
(CARTESIA_VOICE_ID, D-006). server/tts/piper_tts.py wraps piper-tts
self-hosted synthesis (R4 mitigation, open-weights). Both implement
TTSProvider (synthesize streaming + synthesize_all). Both degrade
gracefully (no audio, no crash) when the API key / voice model is absent.
PRAXIS_TTS=cartesia|piper selects the adapter via the registry with no
pipeline change (D-014). 7 unit tests pass (mock streams + env selection
+ graceful missing-key/model handling).

---ci---
phase: 1
milestone: v0.1
plan: 02
task: 02-02
status: execute
persona: backend-engineer
requirements:
  covered: [REQ-VOICE-02]
---/ci---
This commit is contained in:
Praxis CI
2026-08-01 13:00:54 +00:00
parent 47e24dbe59
commit f0afc8ef57
4 changed files with 299 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""TTS adapter package — Cartesia (cloud) + Piper (self-hosted) behind TTSProvider."""
from server.services.base import TTSProvider, TTSResult
__all__ = ["TTSProvider", "TTSResult"]
+108
View File
@@ -0,0 +1,108 @@
"""Cartesia Sonic TTS adapter behind the TTSProvider interface (D-014).
Wraps the raw Cartesia WebSocket API (wss://api.cartesia.ai/tts/websocket) for
the probe-style streaming path, and exposes the TTSProvider contract so the
Pipecat pipeline can swap to Piper with no code change (PRAXIS_TTS=piper).
One voice persona (D-006) — CARTESIA_VOICE_ID from env.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from typing import AsyncIterator
from server.services.base import TTSProvider, TTSResult
CARTESIA_WS_URL = "wss://api.cartesia.ai/tts/websocket"
DEFAULT_VOICE_ID = "a3536a36-1d18-4efb-a95a-7c44b7b5e384"
DEFAULT_MODEL = "sonic-2"
class CartesiaTTS(TTSProvider):
"""Cartesia Sonic cloud TTS adapter (D-014 primary)."""
name = "cartesia"
def __init__(
self,
*,
api_key: str | None = None,
voice_id: str | None = None,
model: str | None = None,
sample_rate: int = 24000,
) -> None:
self._api_key = (api_key or os.environ.get("CARTESIA_API_KEY", "")).strip()
self._voice_id = (
voice_id or os.environ.get("CARTESIA_VOICE_ID", DEFAULT_VOICE_ID)
).strip()
self._model = model or DEFAULT_MODEL
self._sample_rate = sample_rate
@property
def voice_id(self) -> str:
return self._voice_id
def _missing(self) -> bool:
return not self._api_key
async def synthesize(self, text: str) -> AsyncIterator[bytes]:
"""Stream PCM s16le audio chunks from Cartesia Sonic."""
if self._missing():
# Graceful no-op: yield silence so the pipeline doesn't crash.
# The code structure is the deliverable; live audio needs a key.
return
import websockets
headers = [
("x-api-key", self._api_key),
("cartesia-version", "2024-06-10"),
]
try:
async with websockets.connect(
CARTESIA_WS_URL, additional_headers=headers, open_timeout=10
) as ws:
req = {
"model_id": self._model,
"transcript": text,
"voice": {"id": self._voice_id},
"output_format": {
"container": "raw",
"encoding": "pcm_s16le",
"sample_rate": self._sample_rate,
},
"stream": True,
}
await ws.send(json.dumps(req))
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=15)
if isinstance(msg, (bytes, bytearray)):
yield bytes(msg)
elif isinstance(msg, str):
data = json.loads(msg)
if data.get("type") == "done":
break
except Exception:
# Live-key/auth errors degrade to no audio; the pipeline stays up.
return
async def synthesize_all(self, text: str) -> tuple[bytes, TTSResult]:
t0 = time.perf_counter()
chunks = bytearray()
first_audio_ms: float | None = None
async for chunk in self.synthesize(text):
if first_audio_ms is None:
first_audio_ms = (time.perf_counter() - t0) * 1000.0
chunks += chunk
return bytes(chunks), TTSResult(
first_audio_ms=first_audio_ms,
chars=len(text),
voice_id=self._voice_id,
sample_rate=self._sample_rate,
)
__all__ = ["CartesiaTTS"]
+85
View File
@@ -0,0 +1,85 @@
"""Piper self-hosted TTS adapter behind the TTSProvider interface (D-014).
Piper is the R4 mitigation (ARCHITECTURE.md): self-hosted, ~80ms first-audio on
CPU, open-weights, $0 marginal cost. Selected via PRAXIS_TTS=piper. A voice
model must be downloaded separately (see docs/latency-report.md §Piper
pre-staging). The adapter degrades gracefully if the voice model is absent.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import AsyncIterator
from server.services.base import TTSProvider, TTSResult
class PiperTTS(TTSProvider):
"""Piper self-hosted TTS adapter (D-014 fallback / R4 mitigation)."""
name = "piper"
def __init__(
self,
*,
voice_model: str | None = None,
voice_id: str | None = None,
sample_rate: int = 22050,
) -> None:
self._voice_model = (
voice_model or os.environ.get("PIPER_VOICE_MODEL", "")
).strip()
self._voice_id = (voice_id or "piper-en_CA-medium").strip()
self._sample_rate = sample_rate
self._voice = None # loaded lazily
@property
def voice_id(self) -> str:
return self._voice_id
def _model_available(self) -> bool:
return bool(self._voice_model) and Path(self._voice_model).exists()
def _load_voice(self):
if self._voice is not None:
return self._voice
if not self._model_available():
return None
try:
from piper import PiperVoice # type: ignore
except ImportError:
return None
self._voice = PiperVoice.load(self._voice_model)
return self._voice
async def synthesize(self, text: str) -> AsyncIterator[bytes]:
"""Stream PCM s16le audio chunks from Piper."""
voice = self._load_voice()
if voice is None:
# Graceful no-op when the voice model isn't provisioned.
return
import io
for chunk in voice.synthesize(text):
# Piper yields AudioChunk with .audio_int16_bytes (PCM s16le).
yield chunk.audio_int16_bytes
async def synthesize_all(self, text: str) -> tuple[bytes, TTSResult]:
t0 = time.perf_counter()
chunks = bytearray()
first_audio_ms: float | None = None
async for chunk in self.synthesize(text):
if first_audio_ms is None:
first_audio_ms = (time.perf_counter() - t0) * 1000.0
chunks += chunk
return bytes(chunks), TTSResult(
first_audio_ms=first_audio_ms,
chars=len(text),
voice_id=self._voice_id,
sample_rate=self._sample_rate,
)
__all__ = ["PiperTTS"]