- 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)
68 lines
2.4 KiB
Python
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)),
|
|
)
|