Files
qtalker---/backend/app/tts/piper.py
Indiana b9110f45de feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry
- WS /ws/session: modes, summon, anomaly fragments, streaming direct contact,
  passive wire-ghost ambient loop, per-user rate limits, event transcript
- entities: anomaly-signature fingerprinting, LLM minting + procedural
  fallback, Codex matching with contact counts and sightings
- tts: 8 local Piper voices (EN/ES), per-entity voice profiles, numpy
  effects chain (pitch/rate/bitcrush/echo/static)
- llm: streaming client, submit_stream in bounded queue, SpiritService
  with offline fallbacks for every channel
- routes: public /api/codex, /api/codex/{id}, /api/stats; /audio static mount
- models: Entity, EntitySighting, Event, ContactSession(entity_id, language)
2026-07-20 20:13:28 +00:00

68 lines
2.4 KiB
Python

"""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)),
)