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)
This commit is contained in:
67
backend/app/tts/piper.py
Normal file
67
backend/app/tts/piper.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""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)),
|
||||
)
|
||||
Reference in New Issue
Block a user