atlas-iac/dockerfiles/hermes-jetson-tts-server.py

497 lines
19 KiB
Python
Raw Normal View History

#!/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 struct
import threading
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import onnxruntime
from piper import PiperConfig, PiperVoice, SynthesisConfig
from hermes_jetson_tts_cues import build_cue_cache, resolve_cached_cue, write_cached_cue
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")
# Piper deliberately leaves a generous tail after sentence punctuation. That
# sounds natural when one synthesis result is played in isolation, but hands-
# free mode queues many independently synthesized clauses and compounds those
# tails with the browser's punctuation cadence. Only silence after the last
# audible 5 ms window is shortened; voiced samples are never faded or removed.
SILENCE_WINDOW_MS = 5
SILENCE_ABS_THRESHOLD = 32
SILENCE_TRIM_TOLERANCE_MS = 20
CLAUSE_PAUSE_MS = 90
COLON_PAUSE_MS = 110
QUESTION_PAUSE_MS = 170
PERIOD_PAUSE_MS = 190
ELLIPSIS_PAUSE_MS = 220
PARAGRAPH_PAUSE_MS = 280
UNPUNCTUATED_PAUSE_MS = 70
SENTENCE_BOUNDARY_PATTERN = re.compile(
r"(?P<punct>\.{1,3}|[!?…]+)(?:[\"'”’)}\]]+)?(?P<gap>\s+|$)"
)
# 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 trailing_pause_ms(text: str) -> int:
"""Return the natural trailing pause budget for one spoken text unit."""
if re.search(r"\n\s*\n\s*$", text):
return PARAGRAPH_PAUSE_MS
terminal = text.rstrip().rstrip("\"'”’)}]")
if terminal.endswith(("...", "")):
return ELLIPSIS_PAUSE_MS
if terminal.endswith("."):
return PERIOD_PAUSE_MS
if terminal.endswith(("?", "!")):
return QUESTION_PAUSE_MS
if terminal.endswith((":", ";")):
return COLON_PAUSE_MS
if terminal.endswith(","):
return CLAUSE_PAUSE_MS
return UNPUNCTUATED_PAUSE_MS
def sentence_pause_targets(text: str) -> list[int]:
"""Map Piper's likely sentence results to punctuation-aware pause budgets."""
targets: list[int] = []
cursor = 0
for match in SENTENCE_BOUNDARY_PATTERN.finditer(text):
unit = text[cursor : match.end()]
targets.append(trailing_pause_ms(unit))
cursor = match.end()
if text[cursor:].strip() or not targets:
targets.append(trailing_pause_ms(text[cursor:] or text))
return targets
def trim_trailing_pcm_silence(
pcm: bytes | bytearray | memoryview,
sample_rate: int,
pause_ms: int,
) -> memoryview:
"""Shorten only a confirmed PCM silence tail to the requested duration.
The backward scan is restricted to the trailing envelope. Returning a
memoryview avoids copying sentence audio on the latency-sensitive stream
path. All-silent input is retained because it carries no safe phoneme
boundary from which to measure a pause.
"""
raw = memoryview(pcm).cast("B")
if len(raw) < 2 or len(raw) % 2 or sample_rate <= 0:
return raw
window_samples = max(1, sample_rate * SILENCE_WINDOW_MS // 1000)
window_bytes = window_samples * 2
active_end: int | None = None
cursor = len(raw)
while cursor > 0:
start = max(0, cursor - window_bytes)
# Piper PCM is explicitly signed 16-bit little endian. A conservative
# threshold preserves quiet word endings while ignoring quantization
# noise in the generated tail.
if any(
abs(struct.unpack_from("<h", raw, offset)[0]) > SILENCE_ABS_THRESHOLD
for offset in range(start, cursor, 2)
):
active_end = cursor
break
cursor = start
if active_end is None:
return raw
desired_tail_bytes = max(0, sample_rate * pause_ms // 1000) * 2
cutoff = min(len(raw), active_end + desired_tail_bytes)
tolerance_bytes = max(0, sample_rate * SILENCE_TRIM_TOLERANCE_MS // 1000) * 2
if len(raw) - cutoff <= tolerance_bytes:
return raw
return raw[:cutoff]
def normalize_wav_trailing_pause(audio: bytes, text: str) -> bytes:
"""Apply the same safe silence-tail policy to the compatibility WAV path."""
source = io.BytesIO(audio)
try:
with wave.open(source, "rb") as reader:
params = reader.getparams()
if (
params.nchannels != 1
or params.sampwidth != 2
or params.comptype != "NONE"
):
return audio
pcm = reader.readframes(params.nframes)
except (EOFError, wave.Error):
return audio
normalized = trim_trailing_pcm_silence(
pcm,
params.framerate,
trailing_pause_ms(text),
)
if len(normalized) == len(pcm):
return audio
output = io.BytesIO()
with wave.open(output, "wb") as writer:
writer.setparams(params)
writer.writeframes(normalized)
return output.getvalue()
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",
},
"thinking_cues": len(self.server.cue_cache), # type: ignore[attr-defined]
},
)
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))
try:
cue = resolve_cached_cue(
payload,
getattr(self.server, "cue_cache", {}),
)
except ValueError as exc:
_json(self, 400, {"error": str(exc)})
return
if cue is not None:
if self.path != "/v1/audio/speech/stream":
_json(self, 400, {"error": "thinking cues require PCM streaming"})
return
write_cached_cue(self, cue, normalize_turn_id(payload.get("turn_id")))
return
# 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 = normalize_wav_trailing_pause(output.getvalue(), text)
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:
pause_targets = sentence_pause_targets(text)
audio_chunks = iter(
voice.synthesize(
text,
SynthesisConfig(length_scale=1.0 / speed),
)
)
chunk_index = 0
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")
pause_ms = pause_targets[min(chunk_index, len(pause_targets) - 1)]
pcm = trim_trailing_pcm_silence(
audio_chunk.audio_int16_bytes,
sample_rate,
pause_ms,
)
chunk_index += 1
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)
cue_cache = build_cue_cache(
voices,
SynthesisConfig,
trim_trailing_pcm_silence,
trailing_pause_ms,
)
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]
server.cue_cache = cue_cache # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)
if __name__ == "__main__":
main()