120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
|
|
"""Precompute the small, localized Hermes thinking-cue audio set."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import Callable
|
||
|
|
|
||
|
|
|
||
|
|
CUE_TEXT = {
|
||
|
|
"en": {
|
||
|
|
"thinking": "I'm thinking.",
|
||
|
|
"let_me_think": "Let me think.",
|
||
|
|
"still_working": "Still working on that.",
|
||
|
|
"one_more_moment": "One more moment.",
|
||
|
|
},
|
||
|
|
"ru": {
|
||
|
|
"thinking": "Я думаю.",
|
||
|
|
"let_me_think": "Дайте подумать.",
|
||
|
|
"still_working": "Я всё ещё думаю над этим.",
|
||
|
|
"one_more_moment": "Ещё мгновение.",
|
||
|
|
},
|
||
|
|
"es": {
|
||
|
|
"thinking": "Estoy pensando.",
|
||
|
|
"let_me_think": "Déjame pensar.",
|
||
|
|
"still_working": "Sigo pensando en eso.",
|
||
|
|
"one_more_moment": "Un momento más.",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
LANGUAGE_VOICE = {
|
||
|
|
"en": "en_US-amy-medium",
|
||
|
|
"ru": "ru_RU-irina-medium",
|
||
|
|
"es": "es_MX-claude-high",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class CachedCue:
|
||
|
|
"""One immutable PCM response synthesized before the server becomes ready."""
|
||
|
|
|
||
|
|
cue_id: str
|
||
|
|
language: str
|
||
|
|
voice_name: str
|
||
|
|
sample_rate: int
|
||
|
|
pcm: bytes
|
||
|
|
|
||
|
|
|
||
|
|
def build_cue_cache(
|
||
|
|
voices: dict,
|
||
|
|
synthesis_config_factory: Callable[..., object],
|
||
|
|
normalize_pcm: Callable[[object, int, int], object],
|
||
|
|
pause_for_text: Callable[[str], int],
|
||
|
|
) -> dict[tuple[str, str], CachedCue]:
|
||
|
|
"""Synthesize every allow-listed cue once, failing startup on voice drift."""
|
||
|
|
cache: dict[tuple[str, str], CachedCue] = {}
|
||
|
|
for language, entries in CUE_TEXT.items():
|
||
|
|
voice_name = LANGUAGE_VOICE[language]
|
||
|
|
voice = voices[voice_name]
|
||
|
|
sample_rate = int(voice.config.sample_rate)
|
||
|
|
for cue_id, text in entries.items():
|
||
|
|
chunks: list[bytes] = []
|
||
|
|
for result in voice.synthesize(text, synthesis_config_factory(length_scale=1.0)):
|
||
|
|
if (
|
||
|
|
result.sample_rate != sample_rate
|
||
|
|
or result.sample_width != 2
|
||
|
|
or result.sample_channels != 1
|
||
|
|
):
|
||
|
|
raise RuntimeError("Piper returned an unexpected cue PCM format")
|
||
|
|
chunks.append(
|
||
|
|
bytes(
|
||
|
|
normalize_pcm(
|
||
|
|
result.audio_int16_bytes,
|
||
|
|
sample_rate,
|
||
|
|
pause_for_text(text),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if not chunks:
|
||
|
|
raise RuntimeError(f"Piper returned no audio for cue {language}/{cue_id}")
|
||
|
|
cache[(language, cue_id)] = CachedCue(
|
||
|
|
cue_id, language, voice_name, sample_rate, b"".join(chunks)
|
||
|
|
)
|
||
|
|
return cache
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_cached_cue(payload: object, cache: dict) -> CachedCue | None:
|
||
|
|
"""Resolve only an exact language/cue ID pair; client text is never trusted."""
|
||
|
|
if not isinstance(payload, dict) or "cue_id" not in payload:
|
||
|
|
return None
|
||
|
|
language = payload.get("language")
|
||
|
|
cue_id = payload.get("cue_id")
|
||
|
|
if not isinstance(language, str) or not isinstance(cue_id, str):
|
||
|
|
raise ValueError("invalid thinking cue")
|
||
|
|
cue = cache.get((language, cue_id))
|
||
|
|
if cue is None:
|
||
|
|
raise ValueError("unknown thinking cue")
|
||
|
|
return cue
|
||
|
|
|
||
|
|
|
||
|
|
def write_cached_cue(handler, cue: CachedCue, turn_id: str | None) -> None:
|
||
|
|
"""Return immutable PCM without acquiring the live Piper model lock."""
|
||
|
|
handler.send_response(200)
|
||
|
|
handler.send_header("Content-Type", "audio/pcm")
|
||
|
|
handler.send_header("Content-Length", str(len(cue.pcm)))
|
||
|
|
handler.send_header("Cache-Control", "private, max-age=86400, immutable")
|
||
|
|
handler.send_header("X-Audio-Format", "pcm_s16le")
|
||
|
|
handler.send_header("X-Audio-Sample-Rate", str(cue.sample_rate))
|
||
|
|
handler.send_header("X-Audio-Channels", "1")
|
||
|
|
handler.send_header("X-Audio-Sample-Width", "2")
|
||
|
|
handler.send_header("X-TTS-Voice", cue.voice_name)
|
||
|
|
handler.send_header("X-TTS-Cue-ID", cue.cue_id)
|
||
|
|
handler.send_header("X-TTS-Cache", "HIT")
|
||
|
|
if turn_id is not None:
|
||
|
|
handler.send_header("X-TTS-Turn-ID", turn_id)
|
||
|
|
handler.end_headers()
|
||
|
|
try:
|
||
|
|
handler.wfile.write(cue.pcm)
|
||
|
|
handler.wfile.flush()
|
||
|
|
except (BrokenPipeError, ConnectionResetError, TimeoutError, OSError):
|
||
|
|
handler.close_connection = True
|