#!/usr/bin/env python3 """CPU-only Piper service shared with the routing Jetson.""" from __future__ import annotations import io import json import os import re import threading import wave from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import onnxruntime from piper import PiperConfig, PiperVoice, SynthesisConfig HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0") PORT = int(os.getenv("HERMES_TTS_PORT", "9001")) CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper")) MAX_TEXT_CHARS = 5000 ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4"))) STREAM_WRITE_BYTES = 32 * 1024 STREAM_WRITE_TIMEOUT_SECONDS = max( 1.0, float(os.getenv("HERMES_TTS_STREAM_WRITE_TIMEOUT_SECONDS", "15")), ) VOICE_LOCK = threading.Lock() TURN_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}\Z") # Fixed, allow-listed language -> baked voice mapping. This is the ONLY path # from a client-supplied string to a model name: client input is looked up # here and never used to build a filesystem path directly. Both "-" and "_" # separators and any case are accepted; anything not present here falls back # to DEFAULT_VOICE_NAME (safe English default), never an error and never an # unbaked model. LANGUAGE_VOICE_MAP = { "en": "en_US-amy-medium", "en-us": "en_US-amy-medium", "ru": "ru_RU-irina-medium", "ru-ru": "ru_RU-irina-medium", "es": "es_MX-claude-high", "es-mx": "es_MX-claude-high", "es-es": "es_MX-claude-high", } BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values()) DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium") def normalize_language(value: object) -> str | None: """Lowercase and fold "_"/"-" separators; reject non-string/blank input.""" if not isinstance(value, str): return None normalized = value.strip().lower().replace("_", "-") return normalized or None def resolve_voice_name(language: object) -> str: """Map a client-supplied language to one of the baked policy voices. Unknown, missing, or malformed language always resolves to the safe default rather than raising, and the result is always a member of BAKED_VOICE_NAMES. """ normalized = normalize_language(language) if normalized is None: return DEFAULT_VOICE_NAME return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME) def normalize_turn_id(value: object) -> str | None: """Return a bounded header-safe turn identifier, or discard it.""" if not isinstance(value, str): return None normalized = value.strip() if not TURN_ID_PATTERN.fullmatch(normalized): return None return normalized def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(body) class SpeechHandler(BaseHTTPRequestHandler): """Serve health and bounded local speech synthesis.""" server_version = "AtlasPiper/1" protocol_version = "HTTP/1.1" def log_message(self, message: str, *args: object) -> None: print(f"[tts] {self.address_string()} {message % args}", flush=True) def do_GET(self) -> None: if self.path != "/health": _json(self, 404, {"error": "not found"}) return _json( self, 200, { "ok": True, "voices": sorted(self.server.voices), # type: ignore[attr-defined] "default_voice": self.server.default_voice_name, # type: ignore[attr-defined] "device": "cpu", "streaming": { "path": "/v1/audio/speech/stream", "format": "pcm_s16le", }, }, ) def do_POST(self) -> None: if self.path not in {"/v1/audio/speech", "/v1/audio/speech/stream"}: _json(self, 404, {"error": "not found"}) return try: content_length = int(self.headers.get("Content-Length", "0") or "0") except (TypeError, ValueError): _json(self, 400, {"error": "invalid Content-Length"}) return if content_length <= 0 or content_length > 64 * 1024: _json(self, 413, {"error": "request is missing or too large"}) return try: payload = json.loads(self.rfile.read(content_length).decode("utf-8")) if not isinstance(payload, dict): raise TypeError("request must be an object") text = str(payload.get("input") or payload.get("text") or "").strip() speed = float(payload.get("speed") or 1.0) except (TypeError, ValueError, json.JSONDecodeError): _json(self, 400, {"error": "invalid JSON request"}) return if not text: _json(self, 400, {"error": "input is required"}) return if len(text) > MAX_TEXT_CHARS: _json(self, 400, {"error": "input is too long"}) return speed = min(2.0, max(0.5, speed)) # Policy is driven ONLY by "language". A client-supplied "voice" # field is deliberately never read here; it cannot override the # allow-listed mapping. voice_name = resolve_voice_name(payload.get("language")) voice = self.server.voices[voice_name] # type: ignore[attr-defined] if self.path == "/v1/audio/speech/stream": self._stream_pcm( text, speed, voice_name, voice, normalize_turn_id(payload.get("turn_id")), ) return self._synthesize_wav(text, speed, voice_name, voice) def _synthesize_wav(self, text: str, speed: float, voice_name: str, voice: PiperVoice) -> None: """Return the compatibility WAV response after complete synthesis.""" output = io.BytesIO() try: with VOICE_LOCK, wave.open(output, "wb") as wav_file: voice.synthesize_wav( text, wav_file, SynthesisConfig(length_scale=1.0 / speed), ) audio = output.getvalue() self.send_response(200) self.send_header("Content-Type", "audio/wav") self.send_header("Content-Length", str(len(audio))) self.send_header("Cache-Control", "no-store") self.send_header("X-TTS-Voice", voice_name) self.end_headers() self.wfile.write(audio) except Exception as exc: print(f"[tts] synthesis failed: {exc}", flush=True) _json(self, 500, {"error": "speech synthesis failed"}) def _write_stream_chunk(self, audio: memoryview) -> bool: """Write one bounded HTTP chunk, returning false after disconnect.""" try: self.wfile.write(f"{len(audio):X}\r\n".encode("ascii")) self.wfile.write(audio) self.wfile.write(b"\r\n") self.wfile.flush() return True except (BrokenPipeError, ConnectionResetError, TimeoutError, OSError): self.close_connection = True return False def _stream_pcm( self, text: str, speed: float, voice_name: str, voice: PiperVoice, turn_id: str | None, ) -> None: """Progressively stream sentence PCM with bounded transport writes. Piper yields one PCM result per sentence. Each result is split further before writing so the HTTP socket, rather than application buffers, provides back-pressure. Aborting the browser fetch closes the socket; the server then drops the current turn at the next synthesis yield or bounded write and releases the voice lock. """ sample_rate = int(voice.config.sample_rate) self.send_response(200) self.send_header( "Content-Type", f"audio/pcm;rate={sample_rate};channels=1;encoding=signed-integer;bits=16;endian=little", ) self.send_header("Transfer-Encoding", "chunked") self.send_header("Cache-Control", "no-store") self.send_header("X-Audio-Format", "pcm_s16le") self.send_header("X-Audio-Sample-Rate", str(sample_rate)) self.send_header("X-Audio-Channels", "1") self.send_header("X-Audio-Sample-Width", "2") self.send_header("X-TTS-Voice", voice_name) if turn_id is not None: self.send_header("X-TTS-Turn-ID", turn_id) self.end_headers() try: self.connection.settimeout(STREAM_WRITE_TIMEOUT_SECONDS) except (AttributeError, OSError): # Unit-test handlers and already-closed clients may not expose a # live socket. The writes below remain the cancellation boundary. pass connected = True try: audio_chunks = iter( voice.synthesize( text, SynthesisConfig(length_scale=1.0 / speed), ) ) while connected: # Serialize Piper/ONNX access, but never hold the model lock # while a slow browser applies network back-pressure. with VOICE_LOCK: try: audio_chunk = next(audio_chunks) except StopIteration: break if ( audio_chunk.sample_rate != sample_rate or audio_chunk.sample_width != 2 or audio_chunk.sample_channels != 1 ): raise RuntimeError("Piper returned an unexpected PCM format") pcm = memoryview(audio_chunk.audio_int16_bytes) for offset in range(0, len(pcm), STREAM_WRITE_BYTES): if not self._write_stream_chunk(pcm[offset : offset + STREAM_WRITE_BYTES]): connected = False break except Exception as exc: # Headers are already committed, so a JSON error would corrupt the # PCM stream. Closing produces an incomplete chunked response that # the browser can discard or replace with the compatibility path. print(f"[tts] streaming synthesis failed: {exc}", flush=True) self.close_connection = True return if connected: try: self.wfile.write(b"0\r\n\r\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError, TimeoutError, OSError): self.close_connection = True def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice: model_path = cache_dir / f"{voice_name}.onnx" config_path = cache_dir / f"{voice_name}.onnx.json" if not model_path.exists() or not config_path.exists(): raise RuntimeError(f"baked Piper voice is missing: {voice_name}") with config_path.open("r", encoding="utf-8") as config_file: config = PiperConfig.from_dict(json.load(config_file)) session_options = onnxruntime.SessionOptions() session_options.intra_op_num_threads = threads session_options.inter_op_num_threads = 1 session = onnxruntime.InferenceSession( str(model_path), sess_options=session_options, providers=["CPUExecutionProvider"], ) return PiperVoice(session=session, config=config, download_dir=cache_dir) def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]: """Eagerly load all three policy voices. Preload (not lazy-load-on-first-use) was chosen deliberately: measured RSS on this model set is ~88MB for one voice and ~243MB for all three (~+155MB versus the previous single-voice baseline), which comfortably fits the pod's memory budget on the CPU-only voice node. Preloading avoids a slow, request-serializing first synthesis per language and keeps the fail-closed missing-model check (below) at process start rather than deferring a possible crash to a live user request. """ return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)} def main() -> None: """Load the checksum-pinned policy voices from the image and serve them on CPU.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES: raise RuntimeError( f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}" ) voices = load_voices(CACHE_DIR, ONNX_THREADS) print( f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: " + ", ".join(sorted(voices)), flush=True, ) server = ThreadingHTTPServer((HOST, PORT), SpeechHandler) server.voices = voices # type: ignore[attr-defined] server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined] print(f"[tts] ready on {HOST}:{PORT}", flush=True) server.serve_forever(poll_interval=0.25) if __name__ == "__main__": main()