"""Async wrapper around the local Piper CLI (`python -m piper`). Piper runs fully on CPU on this host; a small semaphore keeps concurrent syntheses from stomping each other's onnxruntime thread pools.""" import asyncio import sys import tempfile from pathlib import Path from app.config import settings from app.tts.effects import apply_effects from app.tts.voices import Voice _synth_semaphore = asyncio.Semaphore(2) class PiperTTS: """Wraps the Piper CLI to synthesize speech locally, no cloud calls.""" def __init__(self, voice_model_path: str): self._voice_model_path = voice_model_path async def synthesize(self, text: str) -> bytes: """Synthesize text to raw WAV bytes via the piper CLI.""" async with _synth_semaphore: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: out_path = tmp.name try: process = await asyncio.create_subprocess_exec( sys.executable, "-m", "piper", "--model", self._voice_model_path, "--output_file", out_path, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await asyncio.wait_for( process.communicate(text.encode("utf-8")), timeout=60.0 ) if process.returncode != 0: raise RuntimeError(f"piper exited with {process.returncode}") return Path(out_path).read_bytes() finally: Path(out_path).unlink(missing_ok=True) async def synthesize_spirit_voice( text: str, voice: Voice, voice_profile: dict | None = None ) -> bytes: """One call: Piper synth + the spirit's signature effects chain.""" profile = voice_profile or {} model_path = Path(settings.piper_voices_dir) / voice.model_file wav = await PiperTTS(str(model_path)).synthesize(text) return await asyncio.to_thread( apply_effects, wav, noise_level=float(profile.get("noise", 0.03)), pitch_semitones=float(profile.get("pitch", 0.0)), rate=float(profile.get("rate", 1.0)), bitcrush_bits=int(profile.get("bitcrush", 0)), echo=float(profile.get("echo", 0.2)), )