feat(macstudio): F5 watchdog + cache + safe wav
- voice-bridge main.py: cache disque indexed by sha256(text+ref+steps), GET /tts/cache/stats + DELETE /tts/cache, GET /tts/service_down, F5 timeout default 8s, default steps 4. - watchdog.sh: crontab */2 keepalive, restarts voice-bridge if pgrep fails. - service_down.wav: hardcoded graceful degradation asset (FR, 6.86s). - generate_npc_pool.py: --backend f5 (calls voice-bridge), manifest with cache_key for server-side cache priming. - tools/CLAUDE.md: documents both backends. - .gitignore: hotline_tts/ pool artefacts (reproducible).
This commit is contained in:
@@ -90,3 +90,6 @@ prompt a suivre/
|
||||
memory/
|
||||
site/
|
||||
todos/
|
||||
|
||||
# Generated TTS pool artefacts (reproducible from npc_phrases.yaml + voice ref)
|
||||
hotline_tts/
|
||||
|
||||
+8
-1
@@ -9,7 +9,7 @@ Python tooling for scenario compilation, validation, voice pipeline, MCP hardwar
|
||||
| `scenario/` | Runtime 3 compiler, validator, simulator, pivot verifier, MD/firmware exporters |
|
||||
| `audio/` | Audio manifest validator |
|
||||
| `printables/` | Printables manifest validator |
|
||||
| `tts/` | NPC phrase pool generator (Piper TTS → `hotline_tts/`) |
|
||||
| `tts/` | NPC phrase pool generator (Piper TTS or voice-bridge F5 → `hotline_tts/`) |
|
||||
| `stt/` | Speech-to-text helpers |
|
||||
| `dev/` | TUI dashboard, MCP hardware server, `zacus.sh` CLI (12 actions) |
|
||||
| `playtest/` | Playtest harness |
|
||||
@@ -22,6 +22,13 @@ Python tooling for scenario compilation, validation, voice pipeline, MCP hardwar
|
||||
- Scripts run from repo root; use repo-relative paths via `pathlib.Path(__file__).resolve().parents[N]`.
|
||||
- All scenario tools share `scenario/runtime3_common.py` for IR types — extend it, don't redefine types per script.
|
||||
|
||||
## TTS Pool Backends (`tts/generate_npc_pool.py`)
|
||||
|
||||
Two backends, both idempotent (skip existing files; pass `--force` to rebuild):
|
||||
|
||||
- **`--backend piper`** (default, legacy): hits Tower :8001 OpenAI-compatible API, MP3 output → `hotline_tts/<key>.mp3`. Manifest: `hotline_tts/manifest.json`.
|
||||
- **`--backend f5`** (P1 part9b): hits voice-bridge `/tts` on MacStudio, WAV output → `hotline_tts/f5/<key>.wav`. Manifest: `hotline_tts/f5/manifest.json` carries `cache_key`, `server_backend`, `server_cache_hit`, `server_latency_ms` per entry. Cache key derivation must mirror `tools/macstudio/voice-bridge/main.py::_voice_ref_token` — hash the `voice_ref` string verbatim (or sentinel `"default"` when omitted), never the file content.
|
||||
|
||||
## Validate Changes
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"""voice-bridge — FastAPI daemon (P1 part7, in-process F5-TTS).
|
||||
"""voice-bridge — FastAPI daemon (P1 part7 + part9b on-disk cache).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-03-tts-stt-llm-macstudio-design.md §2.5
|
||||
|
||||
Routes
|
||||
------
|
||||
GET /health → readiness, F5 warm-up status, ref audio used
|
||||
POST /tts → in-process F5-TTS-MLX, fallback Piper Tower:8001
|
||||
GET /health → readiness, F5 warm-up, ref audio, cache stats
|
||||
POST /tts → on-disk cache → F5-TTS-MLX → Piper Tower fallback
|
||||
GET /tts/cache/stats → cache count / size / hit rate (since boot)
|
||||
DELETE /tts/cache → clear the on-disk cache (admin key required)
|
||||
POST /voice/transcribe → multipart proxy → whisper.cpp :8300 /inference
|
||||
POST /voice/intent → forward → LiteLLM npc-fast (model="npc-fast")
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* F5-TTS is loaded **once at boot** (single global F5TTS instance) and warmed
|
||||
up with a short dummy phrase; `generate()` from `f5_tts_mlx.generate` is
|
||||
bypassed because it (a) reloads the model on each call and (b) tries to
|
||||
open a sounddevice when `output_path` is None — both unwanted in a daemon.
|
||||
* Reference audio default: `~/zacus_reference.wav`. If absent, `/tmp/ref.wav`
|
||||
is generated at boot via `say -v Thomas` + afconvert (24 kHz mono).
|
||||
* TTS fallback: F5 wrapped in `asyncio.to_thread()` with 3 s timeout. On
|
||||
timeout / hard error, POST to Piper Tower :8001 /synthesize. If both fail,
|
||||
503.
|
||||
* Master key for LiteLLM: `LITELLM_MASTER_KEY` env var (no hardcoded
|
||||
production secret in source).
|
||||
up with a short dummy phrase.
|
||||
* Reference audio default: ``~/zacus_reference.wav``. If absent, ``/tmp/ref.wav``
|
||||
is generated at boot via ``say -v Thomas`` + afconvert (24 kHz mono).
|
||||
* On-disk cache (P1 part9b): ``~/voice-bridge/cache/<sha16>.wav`` keyed by
|
||||
``sha256(text|ref_path|steps)``. The pool generator (tools/tts/generate_npc_pool.py)
|
||||
uses the same key format so pre-warming is straightforward.
|
||||
* TTS fallback chain on cache miss: F5 → Piper Tower :8001 → ``service_down.wav``
|
||||
if both fail. F5 errors / timeouts are logged then bypassed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,7 +41,7 @@ from typing import Any, Optional
|
||||
import httpx
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
|
||||
from fastapi import FastAPI, File, Header, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# ── config (env-overridable) ────────────────────────────────────────────────
|
||||
@@ -49,9 +49,9 @@ WHISPER_URL = os.getenv("WHISPER_URL", "http://localhost:8300")
|
||||
LITELLM_URL = os.getenv("LITELLM_URL", "http://localhost:4000")
|
||||
LITELLM_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-zacus-local-dev-do-not-share")
|
||||
PIPER_URL = os.getenv("PIPER_URL", "http://192.168.0.120:8001")
|
||||
F5_TIMEOUT_S = float(os.getenv("F5_TIMEOUT_S", "3.0"))
|
||||
F5_TIMEOUT_S = float(os.getenv("F5_TIMEOUT_S", "8.0"))
|
||||
F5_MODEL = os.getenv("F5_MODEL", "lucasnewman/f5-tts-mlx")
|
||||
F5_DEFAULT_STEPS = int(os.getenv("F5_DEFAULT_STEPS", "8"))
|
||||
F5_DEFAULT_STEPS = int(os.getenv("F5_DEFAULT_STEPS", "4"))
|
||||
F5_SAMPLE_RATE = 24_000
|
||||
|
||||
REF_AUDIO_HOME = Path(os.path.expanduser("~/zacus_reference.wav"))
|
||||
@@ -60,6 +60,12 @@ REF_AUDIO_TEXT_DEFAULT = (
|
||||
"Bienvenue dans le mystère du Professeur Zacus."
|
||||
)
|
||||
|
||||
CACHE_DIR = Path(os.path.expanduser(os.getenv("CACHE_DIR", "~/voice-bridge/cache")))
|
||||
SERVICE_DOWN_WAV = Path(os.path.expanduser(
|
||||
os.getenv("SERVICE_DOWN_WAV", "~/voice-bridge/service_down.wav")
|
||||
))
|
||||
ADMIN_KEY = os.getenv("VOICE_BRIDGE_ADMIN_KEY") # None = no auth on DELETE
|
||||
|
||||
# ── logging (single-line JSON) ──────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -81,14 +87,20 @@ def _hash8(text: str) -> str:
|
||||
_F5_MODEL_OBJ: Any = None
|
||||
_REF_AUDIO_PATH: Optional[Path] = None
|
||||
_REF_AUDIO_TEXT: str = REF_AUDIO_TEXT_DEFAULT
|
||||
_REF_AUDIO_HASH: str = "default"
|
||||
_WARMUP_MS: Optional[int] = None
|
||||
_F5_LOAD_ERR: Optional[str] = None
|
||||
# Serialization lock: F5/MLX operations must run in the asyncio main thread
|
||||
# (MLX caches per-thread state at model-load time and breaks if reused from
|
||||
# another thread, even with set_default_stream). The lock prevents two
|
||||
# (MLX caches per-thread state at model-load time). The lock prevents two
|
||||
# concurrent /tts calls from clobbering MLX state.
|
||||
_F5_LOCK: Optional[asyncio.Lock] = None
|
||||
|
||||
# Cache index: cache_key → absolute path (warm in-memory after boot scan).
|
||||
_CACHE_INDEX: dict[str, Path] = {}
|
||||
_CACHE_LOCK: Optional[asyncio.Lock] = None
|
||||
_CACHE_HITS: int = 0
|
||||
_CACHE_MISSES: int = 0
|
||||
|
||||
|
||||
# ── reference audio bootstrap ───────────────────────────────────────────────
|
||||
def _ensure_ref_audio() -> Path:
|
||||
@@ -109,7 +121,6 @@ def _ensure_ref_audio() -> Path:
|
||||
"Cannot bootstrap fallback reference audio: 'say' or 'afconvert' missing"
|
||||
)
|
||||
|
||||
# Generate AIFF then convert to WAV 24 kHz mono PCM 16-bit.
|
||||
subprocess.run(
|
||||
[say_bin, "-v", "Thomas", "-o", str(aiff), REF_AUDIO_TEXT_DEFAULT],
|
||||
check=True,
|
||||
@@ -128,14 +139,57 @@ def _ensure_ref_audio() -> Path:
|
||||
return REF_AUDIO_FALLBACK
|
||||
|
||||
|
||||
def _ref_hash(ref_path: Optional[Path]) -> str:
|
||||
"""Short hash of the reference WAV (file-content based).
|
||||
|
||||
Used in /health to fingerprint the boot-time default reference; NOT used
|
||||
in cache key derivation (see ``_voice_ref_token`` for that), so the pool
|
||||
generator and the daemon agree on cache keys without sharing the ref file.
|
||||
"""
|
||||
if ref_path is None or not ref_path.exists():
|
||||
return "default"
|
||||
h = hashlib.sha256()
|
||||
with open(ref_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def _voice_ref_token(voice_ref: Optional[str]) -> str:
|
||||
"""Cache-key token derived from the request's ``voice_ref`` field.
|
||||
|
||||
Contract (mirrored by ``tools/tts/generate_npc_pool.py``): when the client
|
||||
omits ``voice_ref`` it gets the daemon's boot-time default reference, so
|
||||
both sides hash the literal sentinel ``"default"``. Otherwise both sides
|
||||
hash the ``voice_ref`` string (path or identifier) verbatim — never the
|
||||
file contents — so the client can compute the cache_key locally without
|
||||
needing the actual reference WAV.
|
||||
"""
|
||||
if not voice_ref:
|
||||
return "default"
|
||||
return hashlib.sha256(voice_ref.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _cache_key(text: str, ref_token: str, steps: int) -> str:
|
||||
payload = f"{text}|{ref_token}|{steps}".encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()[:16]
|
||||
|
||||
|
||||
def _scan_cache_dir() -> dict[str, Path]:
|
||||
"""Walk CACHE_DIR and build {cache_key: path} from existing .wav files."""
|
||||
index: dict[str, Path] = {}
|
||||
if not CACHE_DIR.exists():
|
||||
return index
|
||||
for wav in CACHE_DIR.glob("*.wav"):
|
||||
# Use the file stem as key (set when we wrote it).
|
||||
index[wav.stem] = wav
|
||||
return index
|
||||
|
||||
|
||||
# ── F5-TTS in-process synthesizer ───────────────────────────────────────────
|
||||
def _f5_synthesize_sync(text: str, steps: int) -> bytes:
|
||||
"""Run F5 inference on the cached model; returns 24 kHz mono WAV bytes.
|
||||
|
||||
Must execute on a thread that has a thread-local GPU stream (see
|
||||
``_f5_thread_init``). The dedicated ``_F5_EXECUTOR`` guarantees this.
|
||||
"""
|
||||
import mlx.core as mx # noqa: WPS433 (deferred import for clarity)
|
||||
"""Run F5 inference on the cached model; returns 24 kHz mono WAV bytes."""
|
||||
import mlx.core as mx # noqa: WPS433
|
||||
from f5_tts_mlx.utils import convert_char_to_pinyin
|
||||
|
||||
audio_arr, sr = sf.read(str(_REF_AUDIO_PATH))
|
||||
@@ -173,36 +227,35 @@ def _f5_synthesize_sync(text: str, steps: int) -> bytes:
|
||||
|
||||
|
||||
async def _run_f5(text: str, steps: int) -> bytes:
|
||||
"""Run synthesis serialized in the asyncio main thread.
|
||||
|
||||
F5/MLX state is bound to the thread that loaded the model. Running from
|
||||
a worker thread triggers ``RuntimeError: There is no Stream(gpu, 0)`` or
|
||||
``scaled_dot_product_attention(scale: array)`` even with explicit stream
|
||||
setup, so we keep everything on the event-loop thread and use an
|
||||
``asyncio.Lock`` to serialize concurrent requests. This blocks the event
|
||||
loop for the duration of synthesis; that is acceptable for the escape
|
||||
room workload (max 2 concurrent voice clients).
|
||||
"""
|
||||
"""Run synthesis serialized in the asyncio main thread."""
|
||||
assert _F5_LOCK is not None
|
||||
async with _F5_LOCK:
|
||||
return _f5_synthesize_sync(text, steps)
|
||||
|
||||
|
||||
# ── FastAPI app ──────────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="voice-bridge", version="0.2.0")
|
||||
app = FastAPI(title="voice-bridge", version="0.3.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _boot() -> None:
|
||||
global _F5_MODEL_OBJ, _REF_AUDIO_PATH, _WARMUP_MS, _F5_LOAD_ERR
|
||||
global _F5_LOCK
|
||||
global _F5_MODEL_OBJ, _REF_AUDIO_PATH, _REF_AUDIO_HASH, _WARMUP_MS
|
||||
global _F5_LOAD_ERR, _F5_LOCK, _CACHE_INDEX, _CACHE_LOCK
|
||||
|
||||
_F5_LOCK = asyncio.Lock()
|
||||
_CACHE_LOCK = asyncio.Lock()
|
||||
|
||||
# Cache directory + initial index (cheap; just lists .wav stems).
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_INDEX = _scan_cache_dir()
|
||||
_jlog("boot_cache_index", dir=str(CACHE_DIR), entries=len(_CACHE_INDEX))
|
||||
|
||||
try:
|
||||
_REF_AUDIO_PATH = _ensure_ref_audio()
|
||||
_jlog("boot_ref_audio", path=str(_REF_AUDIO_PATH))
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_REF_AUDIO_HASH = _ref_hash(_REF_AUDIO_PATH)
|
||||
_jlog("boot_ref_audio", path=str(_REF_AUDIO_PATH),
|
||||
ref_hash=_REF_AUDIO_HASH)
|
||||
except Exception as exc: # pragma: no cover
|
||||
_F5_LOAD_ERR = f"ref_audio: {exc}"
|
||||
_jlog("boot_ref_audio_failed", err=str(exc))
|
||||
return
|
||||
@@ -215,7 +268,6 @@ async def _boot() -> None:
|
||||
_jlog("boot_f5_loaded", model=F5_MODEL,
|
||||
load_ms=int((time.monotonic() - t0) * 1000))
|
||||
|
||||
# Warm-up: short dummy synth in the same thread that holds the model.
|
||||
t1 = time.monotonic()
|
||||
_ = _f5_synthesize_sync("Test.", steps=F5_DEFAULT_STEPS)
|
||||
_WARMUP_MS = int((time.monotonic() - t1) * 1000)
|
||||
@@ -228,13 +280,23 @@ async def _boot() -> None:
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
cache_size_b = sum(p.stat().st_size for p in _CACHE_INDEX.values()
|
||||
if p.exists())
|
||||
return {
|
||||
"status": "ok" if _F5_MODEL_OBJ is not None else "degraded",
|
||||
"version": app.version,
|
||||
"f5_loaded": _F5_MODEL_OBJ is not None,
|
||||
"f5_load_error": _F5_LOAD_ERR,
|
||||
"ref_audio_used": str(_REF_AUDIO_PATH) if _REF_AUDIO_PATH else None,
|
||||
"ref_hash": _REF_AUDIO_HASH,
|
||||
"model_warmup_ms": _WARMUP_MS,
|
||||
"cache": {
|
||||
"dir": str(CACHE_DIR),
|
||||
"entries": len(_CACHE_INDEX),
|
||||
"size_mb": round(cache_size_b / (1024 * 1024), 3),
|
||||
"hits": _CACHE_HITS,
|
||||
"misses": _CACHE_MISSES,
|
||||
},
|
||||
"backends": {
|
||||
"whisper": WHISPER_URL,
|
||||
"litellm": LITELLM_URL,
|
||||
@@ -243,9 +305,15 @@ async def health() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ── /tts (F5 primary, Piper fallback) ───────────────────────────────────────
|
||||
# ── /tts (cache → F5 primary, Piper fallback, service_down last) ─────────────
|
||||
async def _piper_fallback(text: str) -> Optional[bytes]:
|
||||
"""POST text to Piper Tower :8001/synthesize. Returns WAV bytes or None."""
|
||||
"""POST text to Piper Tower :8001/synthesize. Returns WAV bytes or None.
|
||||
|
||||
NOTE (P1 part9b): the Piper backend is kept as a *fallback only* — F5 is
|
||||
the primary TTS path. This block is intentionally not removed so the
|
||||
daemon stays operational if F5 fails to load. Marked DEPRECATED for
|
||||
primary use; see voice-bridge spec §2.5.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
@@ -260,35 +328,116 @@ async def _piper_fallback(text: str) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def _service_down_response(request_id: str, latency_ms: int,
|
||||
err_kind: Optional[str]) -> Optional[Response]:
|
||||
"""Return service_down.wav when both F5 and Piper are unavailable."""
|
||||
if not SERVICE_DOWN_WAV.exists():
|
||||
return None
|
||||
try:
|
||||
wav_bytes = SERVICE_DOWN_WAV.read_bytes()
|
||||
except OSError as exc:
|
||||
_jlog("service_down_read_err", err=str(exc))
|
||||
return None
|
||||
_jlog("tts", request_id=request_id, tts_backend_used="service_down",
|
||||
latency_ms=latency_ms, error=err_kind)
|
||||
return Response(
|
||||
content=wav_bytes,
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-TTS-Backend": "service_down",
|
||||
"X-TTS-Cache-Hit": "false",
|
||||
"X-TTS-Latency-Ms": str(latency_ms),
|
||||
"X-Request-Id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _write_cache(cache_key: str, wav_bytes: bytes) -> None:
|
||||
"""Persist a freshly synthesized WAV under CACHE_DIR/<key>.wav."""
|
||||
assert _CACHE_LOCK is not None
|
||||
out = CACHE_DIR / f"{cache_key}.wav"
|
||||
try:
|
||||
# Atomic-ish write: tmp file + rename so partial writes never appear.
|
||||
tmp = out.with_suffix(".wav.tmp")
|
||||
tmp.write_bytes(wav_bytes)
|
||||
tmp.replace(out)
|
||||
async with _CACHE_LOCK:
|
||||
_CACHE_INDEX[cache_key] = out
|
||||
_jlog("cache_write", cache_key=cache_key, bytes=len(wav_bytes))
|
||||
except OSError as exc:
|
||||
_jlog("cache_write_err", cache_key=cache_key, err=str(exc))
|
||||
|
||||
|
||||
@app.post("/tts")
|
||||
async def tts(payload: dict[str, Any]) -> Response:
|
||||
global _CACHE_HITS, _CACHE_MISSES
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
text = (payload.get("text") or payload.get("input") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="payload.text is required")
|
||||
steps = int(payload.get("steps", F5_DEFAULT_STEPS))
|
||||
voice_ref = payload.get("voice_ref") # accepted but ignored (single-voice)
|
||||
voice_ref = payload.get("voice_ref") # accepted; reserved for multi-voice
|
||||
|
||||
started = time.monotonic()
|
||||
backend = "unknown"
|
||||
|
||||
# 1) Cache lookup -------------------------------------------------------
|
||||
cache_key = _cache_key(text, _voice_ref_token(voice_ref), steps)
|
||||
cached_path = _CACHE_INDEX.get(cache_key)
|
||||
if cached_path is None:
|
||||
# Fallback: file may have been dropped in by the pool generator after
|
||||
# boot; re-check the dir lazily.
|
||||
candidate = CACHE_DIR / f"{cache_key}.wav"
|
||||
if candidate.exists():
|
||||
cached_path = candidate
|
||||
_CACHE_INDEX[cache_key] = candidate
|
||||
|
||||
if cached_path is not None and cached_path.exists():
|
||||
try:
|
||||
wav_bytes = cached_path.read_bytes()
|
||||
_CACHE_HITS += 1
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
_jlog("tts", request_id=request_id, tts_backend_used="cache",
|
||||
latency_ms=latency_ms, phrase_len=len(text),
|
||||
text_hash=_hash8(text), cache_key=cache_key)
|
||||
return Response(
|
||||
content=wav_bytes,
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-TTS-Backend": "cache",
|
||||
"X-TTS-Cache-Hit": "true",
|
||||
"X-TTS-Cache-Key": cache_key,
|
||||
"X-TTS-Latency-Ms": str(latency_ms),
|
||||
"X-Request-Id": request_id,
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
_jlog("cache_read_err", cache_key=cache_key, err=str(exc))
|
||||
# Fall through and regenerate.
|
||||
|
||||
_CACHE_MISSES += 1
|
||||
err_kind: Optional[str] = None
|
||||
|
||||
# 2) F5 synthesis (cache miss) -----------------------------------------
|
||||
if _F5_MODEL_OBJ is not None and _F5_LOCK is not None:
|
||||
try:
|
||||
wav_bytes = await asyncio.wait_for(
|
||||
_run_f5(text, steps),
|
||||
timeout=F5_TIMEOUT_S,
|
||||
)
|
||||
backend = "f5"
|
||||
await _write_cache(cache_key, wav_bytes)
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
_jlog("tts", request_id=request_id, tts_backend_used=backend,
|
||||
_jlog("tts", request_id=request_id, tts_backend_used="f5",
|
||||
latency_ms=latency_ms, phrase_len=len(text),
|
||||
text_hash=_hash8(text), voice_ref=voice_ref, steps=steps)
|
||||
text_hash=_hash8(text), voice_ref=voice_ref, steps=steps,
|
||||
cache_key=cache_key)
|
||||
return Response(
|
||||
content=wav_bytes,
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-TTS-Backend": "f5",
|
||||
"X-TTS-Cache-Hit": "false",
|
||||
"X-TTS-Cache-Key": cache_key,
|
||||
"X-TTS-Latency-Ms": str(latency_ms),
|
||||
"X-Request-Id": request_id,
|
||||
},
|
||||
@@ -304,12 +453,11 @@ async def tts(payload: dict[str, Any]) -> Response:
|
||||
err_kind = "f5_not_loaded"
|
||||
_jlog("tts_f5_not_loaded", request_id=request_id, load_err=_F5_LOAD_ERR)
|
||||
|
||||
# Fallback: Piper Tower
|
||||
# 3) Piper Tower fallback (DEPRECATED for primary path; safety-net only)
|
||||
wav_fallback = await _piper_fallback(text)
|
||||
if wav_fallback is not None:
|
||||
backend = "piper_fallback"
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
_jlog("tts", request_id=request_id, tts_backend_used=backend,
|
||||
_jlog("tts", request_id=request_id, tts_backend_used="piper_fallback",
|
||||
latency_ms=latency_ms, phrase_len=len(text),
|
||||
text_hash=_hash8(text), error=err_kind)
|
||||
return Response(
|
||||
@@ -317,19 +465,110 @@ async def tts(payload: dict[str, Any]) -> Response:
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-TTS-Backend": "piper_fallback",
|
||||
"X-TTS-Cache-Hit": "false",
|
||||
"X-TTS-Latency-Ms": str(latency_ms),
|
||||
"X-Request-Id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
# 4) service_down.wav last resort --------------------------------------
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
sd = _service_down_response(request_id, latency_ms, err_kind)
|
||||
if sd is not None:
|
||||
return sd
|
||||
|
||||
_jlog("tts_all_backends_down", request_id=request_id,
|
||||
phrase_len=len(text), error=err_kind)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"TTS unavailable: F5 ({err_kind}) and Piper fallback both failed",
|
||||
detail=(
|
||||
f"TTS unavailable: F5 ({err_kind}), Piper fallback, and "
|
||||
"service_down.wav all failed"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── /tts/service_down (direct WAV serve, smoke-test friendly) ───────────────
|
||||
@app.get("/tts/service_down")
|
||||
async def tts_service_down() -> Response:
|
||||
"""Serve the pre-rendered fallback WAV directly, no F5 / Piper call.
|
||||
|
||||
Useful for smoke-testing the resilience asset without forcing a fault.
|
||||
Added in P1 part9a (resilience pass).
|
||||
"""
|
||||
if not SERVICE_DOWN_WAV.exists():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"service_down WAV missing at {SERVICE_DOWN_WAV}",
|
||||
)
|
||||
try:
|
||||
wav_bytes = SERVICE_DOWN_WAV.read_bytes()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"service_down WAV unreadable: {exc}",
|
||||
)
|
||||
return Response(
|
||||
content=wav_bytes,
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-TTS-Backend": "service_down",
|
||||
"X-TTS-Bytes": str(len(wav_bytes)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── /tts/cache/{stats,clear} ────────────────────────────────────────────────
|
||||
@app.get("/tts/cache/stats")
|
||||
async def cache_stats() -> dict[str, Any]:
|
||||
total_b = 0
|
||||
valid_count = 0
|
||||
for p in _CACHE_INDEX.values():
|
||||
try:
|
||||
total_b += p.stat().st_size
|
||||
valid_count += 1
|
||||
except OSError:
|
||||
continue
|
||||
total = _CACHE_HITS + _CACHE_MISSES
|
||||
hit_rate = round(_CACHE_HITS / total, 4) if total else 0.0
|
||||
return {
|
||||
"dir": str(CACHE_DIR),
|
||||
"count": valid_count,
|
||||
"size_mb": round(total_b / (1024 * 1024), 3),
|
||||
"hits": _CACHE_HITS,
|
||||
"misses": _CACHE_MISSES,
|
||||
"hit_rate_since_boot": hit_rate,
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/tts/cache")
|
||||
async def cache_clear(
|
||||
x_admin_key: Optional[str] = Header(default=None, alias="X-Admin-Key"),
|
||||
) -> dict[str, Any]:
|
||||
if ADMIN_KEY and x_admin_key != ADMIN_KEY:
|
||||
raise HTTPException(status_code=403, detail="invalid admin key")
|
||||
|
||||
assert _CACHE_LOCK is not None
|
||||
removed = 0
|
||||
async with _CACHE_LOCK:
|
||||
for p in list(_CACHE_INDEX.values()):
|
||||
try:
|
||||
p.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
_CACHE_INDEX.clear()
|
||||
# Also sweep any stragglers not in the index.
|
||||
for stray in CACHE_DIR.glob("*.wav"):
|
||||
try:
|
||||
stray.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
_jlog("cache_cleared", removed=removed)
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
# ── /voice/transcribe (multipart proxy → whisper.cpp) ───────────────────────
|
||||
@app.post("/voice/transcribe")
|
||||
async def transcribe(request: Request) -> Response:
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# voice-bridge watchdog
|
||||
#
|
||||
# Idempotent: silent exit when service is up; restarts via the same nohup
|
||||
# pattern as the @reboot crontab line when down. Designed to be invoked
|
||||
# every 2 minutes by cron.
|
||||
#
|
||||
# Layout (on studio):
|
||||
# ~/voice-bridge/ venv + main.py + service_down.wav + this script
|
||||
# ~/voice-bridge.log uvicorn stdout/stderr (also reads watchdog notes)
|
||||
# ~/voice-bridge-watchdog.log cron capture (this script's own stdout/stderr)
|
||||
#
|
||||
# Exit codes
|
||||
# 0 service was already up, or restart issued
|
||||
# 2 restart attempted but venv/script missing (operator action required)
|
||||
|
||||
set -u
|
||||
|
||||
VB_DIR="$HOME/voice-bridge"
|
||||
VB_VENV="$VB_DIR/.venv/bin/python"
|
||||
VB_LOG="$HOME/voice-bridge.log"
|
||||
VB_PORT=8200
|
||||
VB_PATTERN="uvicorn main:app.*--port ${VB_PORT}"
|
||||
|
||||
# Service is up: silent exit (no log spam every 2 min).
|
||||
if pgrep -f "${VB_PATTERN}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Down: capture timestamp + log into uvicorn log (operator-visible).
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
REASON="no process matching '${VB_PATTERN}'"
|
||||
|
||||
if [[ ! -x "${VB_VENV}" ]] || [[ ! -f "${VB_DIR}/main.py" ]]; then
|
||||
echo "${TS} watchdog: cannot restart, missing venv or main.py (${VB_DIR})" >> "${VB_LOG}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "${TS} watchdog: restarted at ${TS} (reason: ${REASON})" >> "${VB_LOG}"
|
||||
|
||||
nohup "${VB_VENV}" -m uvicorn main:app \
|
||||
--host 0.0.0.0 --port ${VB_PORT} \
|
||||
--app-dir "${VB_DIR}" \
|
||||
>> "${VB_LOG}" 2>&1 < /dev/null &
|
||||
|
||||
# Detach so cron doesn't wait on the child.
|
||||
disown 2>/dev/null || true
|
||||
exit 0
|
||||
+327
-69
@@ -1,28 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""generate_npc_pool.py — Professor Zacus NPC MP3 pool generator.
|
||||
"""generate_npc_pool.py — Professor Zacus NPC audio pool generator.
|
||||
|
||||
Reads npc_phrases.yaml and synthesizes each phrase via Piper TTS API,
|
||||
then writes MP3 files under --output directory and a manifest.json index.
|
||||
Reads npc_phrases.yaml and synthesizes each phrase via either:
|
||||
- Piper TTS API (legacy backend, MP3 output) — default for backwards compat.
|
||||
- voice-bridge F5-TTS (P1 part9b backend, WAV output) — preferred for the
|
||||
Zacus reference voice.
|
||||
|
||||
Usage:
|
||||
python3 generate_npc_pool.py [options]
|
||||
Both backends write a manifest.json index that maps phrase keys to audio
|
||||
files. Generators are idempotent: existing files are skipped unless
|
||||
``--force`` is passed (cf. tools/CLAUDE.md convention).
|
||||
|
||||
--tts-url Piper TTS base URL (default: http://192.168.0.120:8001)
|
||||
--voice TTS voice name (default: zacus)
|
||||
--output Output directory for MP3 files (default: hotline_tts)
|
||||
--manifest Path to output manifest JSON (default: hotline_tts/manifest.json)
|
||||
--phrases Path to npc_phrases.yaml (default: game/scenarios/npc_phrases.yaml)
|
||||
--dry-run Print keys without calling TTS API
|
||||
Usage (Piper, legacy):
|
||||
python3 generate_npc_pool.py
|
||||
|
||||
Usage (F5 via voice-bridge):
|
||||
python3 generate_npc_pool.py \
|
||||
--backend f5 \
|
||||
--voice-bridge-url http://192.168.0.150:8200 \
|
||||
--steps 8
|
||||
|
||||
Common flags:
|
||||
--output Output directory (default: hotline_tts)
|
||||
--manifest Path to output manifest JSON
|
||||
--phrases Path to npc_phrases.yaml
|
||||
--force Re-synthesize even if cached file exists
|
||||
--dry-run Print phrase keys without calling TTS
|
||||
--delay Delay between requests (default: 0.3 s)
|
||||
|
||||
Backend-specific flags (F5 only):
|
||||
--voice-bridge-url Base URL of voice-bridge daemon
|
||||
--steps F5 inference steps (8 = best quality offline pool)
|
||||
--ref-wav Optional reference WAV path (passed as voice_ref)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from typing import Generator, Optional
|
||||
|
||||
# pyyaml is a soft dependency — error early with a clear message
|
||||
try:
|
||||
@@ -72,33 +91,57 @@ def load_phrases(phrases_path: Path) -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key → safe file name
|
||||
# Filename / cache key helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def key_to_filename(key: str) -> str:
|
||||
def key_to_filename(key: str, ext: str = "mp3") -> str:
|
||||
"""Convert a dotted phrase key to a safe file name.
|
||||
|
||||
Example: "hints.SCENE_LA_DETECTOR.level_1.0" → "hints__SCENE_LA_DETECTOR__level_1__0.mp3"
|
||||
"""
|
||||
safe = key.replace(".", "__")
|
||||
# Remove any chars outside [a-zA-Z0-9_-]
|
||||
safe = "".join(c if c.isalnum() or c in "_-" else "_" for c in safe)
|
||||
return f"{safe}.mp3"
|
||||
return f"{safe}.{ext}"
|
||||
|
||||
|
||||
def f5_cache_key(text: str, ref_hash: str, steps: int) -> str:
|
||||
"""Compute the SHA-256 cache key used by both pool & voice-bridge.
|
||||
|
||||
Mirrors the voice-bridge cache key format so that pool entries can be
|
||||
pre-warmed into the daemon cache (drop the .wav into the cache dir and
|
||||
the daemon will hit it on next request).
|
||||
"""
|
||||
payload = f"{text}|{ref_hash}|{steps}".encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()[:16]
|
||||
|
||||
|
||||
def voice_ref_token(ref_path: Optional[Path]) -> str:
|
||||
"""Cache-key token derived from the optional reference path.
|
||||
|
||||
Mirrors the daemon contract (see ``main.py::_voice_ref_token``):
|
||||
* When ``ref_path`` is None, the pool gen lets the daemon pick its
|
||||
boot-time default reference, so both sides hash the literal
|
||||
sentinel ``"default"``.
|
||||
* Otherwise both sides hash the path string verbatim — never the
|
||||
file content — so the client can compute the cache_key without
|
||||
needing the daemon's reference WAV.
|
||||
"""
|
||||
if ref_path is None:
|
||||
return "default"
|
||||
return hashlib.sha256(str(ref_path).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTS synthesis
|
||||
# Piper TTS synthesis (legacy backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def synthesize(text: str, voice: str, tts_url: str, timeout_s: float = 30.0) -> bytes:
|
||||
"""POST text to OpenAI-compatible TTS API and return raw MP3 bytes.
|
||||
def synthesize_piper(text: str, voice: str, tts_url: str, timeout_s: float = 30.0) -> bytes:
|
||||
"""POST text to OpenAI-compatible Piper API and return raw MP3 bytes.
|
||||
|
||||
openedai-speech on Tower:8001 (OpenAI-compatible):
|
||||
POST /v1/audio/speech
|
||||
Body: {"model": "tts-1", "voice": "...", "input": "...", "response_format": "mp3"}
|
||||
Response: audio/mpeg binary
|
||||
|
||||
Raises requests.HTTPError on non-2xx responses.
|
||||
"""
|
||||
url = tts_url.rstrip("/") + "/v1/audio/speech"
|
||||
payload = {
|
||||
@@ -113,110 +156,166 @@ def synthesize(text: str, voice: str, tts_url: str, timeout_s: float = 30.0) ->
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# voice-bridge F5 synthesis (P1 part9b backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def synthesize_f5(
|
||||
text: str,
|
||||
voice_bridge_url: str,
|
||||
steps: int,
|
||||
ref_wav: Optional[str] = None,
|
||||
timeout_s: float = 60.0,
|
||||
) -> tuple[bytes, dict[str, str]]:
|
||||
"""POST text to voice-bridge /tts and return (WAV bytes, response headers).
|
||||
|
||||
The returned headers include diagnostic info such as ``X-TTS-Backend``
|
||||
(``f5``, ``cache``, ``piper_fallback``), ``X-TTS-Cache-Hit`` and
|
||||
``X-TTS-Latency-Ms`` — these get folded into the manifest for traceability.
|
||||
"""
|
||||
url = voice_bridge_url.rstrip("/") + "/tts"
|
||||
payload: dict[str, object] = {"text": text, "steps": steps}
|
||||
if ref_wav:
|
||||
payload["voice_ref"] = ref_wav
|
||||
resp = requests.post(url, json=payload, timeout=timeout_s)
|
||||
resp.raise_for_status()
|
||||
return resp.content, dict(resp.headers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate MP3 pool for Professor Zacus NPC phrases via Piper TTS."
|
||||
description=(
|
||||
"Generate audio pool for Professor Zacus NPC phrases via Piper TTS "
|
||||
"or the voice-bridge F5 backend."
|
||||
)
|
||||
)
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=("piper", "f5"),
|
||||
default="piper",
|
||||
help="TTS backend (default: piper for backwards compat).",
|
||||
)
|
||||
|
||||
# Piper-specific
|
||||
parser.add_argument(
|
||||
"--tts-url",
|
||||
default="http://192.168.0.120:8001",
|
||||
help="Piper TTS base URL (default: http://192.168.0.120:8001)",
|
||||
help="Piper TTS base URL (default: http://192.168.0.120:8001).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--voice",
|
||||
default="zacus",
|
||||
help="TTS voice name (default: zacus)",
|
||||
help="Piper voice name (default: zacus).",
|
||||
)
|
||||
|
||||
# F5-specific
|
||||
parser.add_argument(
|
||||
"--voice-bridge-url",
|
||||
default="http://192.168.0.150:8200",
|
||||
help="voice-bridge base URL (default: http://192.168.0.150:8200).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--steps",
|
||||
type=int,
|
||||
default=8,
|
||||
help="F5 inference steps (default: 8 = best for offline pool).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref-wav",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Optional reference WAV passed to voice-bridge as voice_ref. "
|
||||
"When absent, voice-bridge uses its boot-time default reference."
|
||||
),
|
||||
)
|
||||
|
||||
# Common
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=repo_root / "hotline_tts",
|
||||
help="Output directory for MP3 files (default: <repo>/hotline_tts)",
|
||||
help="Output directory (default: <repo>/hotline_tts).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Path to output manifest JSON (default: <output>/manifest.json)",
|
||||
help="Path to output manifest JSON (default: <output>/<backend>/manifest.json for f5, <output>/manifest.json for piper).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--phrases",
|
||||
type=Path,
|
||||
default=repo_root / "game" / "scenarios" / "npc_phrases.yaml",
|
||||
help="Path to npc_phrases.yaml",
|
||||
help="Path to npc_phrases.yaml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-synthesize even if the cached audio file already exists.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print phrase keys and texts without calling TTS API",
|
||||
help="Print phrase keys and texts without calling TTS API.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.3,
|
||||
help="Delay in seconds between TTS requests (default: 0.3)",
|
||||
help="Delay in seconds between TTS requests (default: 0.3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Process only the first N phrases (0 = all). Useful for smoke tests.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-backend run loops
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve manifest path default
|
||||
def _run_piper(args: argparse.Namespace, phrases: list[tuple[str, str]]) -> int:
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path: Path = args.manifest or (args.output / "manifest.json")
|
||||
|
||||
# Validate inputs
|
||||
if not args.phrases.exists():
|
||||
print(f"ERROR: phrases file not found: {args.phrases}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
phrases = load_phrases(args.phrases)
|
||||
if not phrases:
|
||||
print("ERROR: no phrases found in YAML file", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Loaded {len(phrases)} phrases from {args.phrases}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--- DRY RUN --- (no TTS calls)")
|
||||
for key, text in phrases:
|
||||
print(f" [{key}] {text[:80]}{'…' if len(text) > 80 else ''}")
|
||||
print(f"\nTotal: {len(phrases)} phrases")
|
||||
return 0
|
||||
|
||||
# Create output directory
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest: dict[str, str] = {}
|
||||
failed: list[tuple[str, str, str]] = [] # (key, text, error)
|
||||
failed: list[tuple[str, str, str]] = []
|
||||
succeeded = 0
|
||||
|
||||
for idx, (key, text) in enumerate(phrases, start=1):
|
||||
filename = key_to_filename(key)
|
||||
filename = key_to_filename(key, ext="mp3")
|
||||
out_path = args.output / filename
|
||||
rel_path = str(out_path.relative_to(args.output.parent)
|
||||
if out_path.is_relative_to(args.output.parent)
|
||||
else out_path)
|
||||
rel_path = (
|
||||
str(out_path.relative_to(args.output.parent))
|
||||
if out_path.is_relative_to(args.output.parent)
|
||||
else str(out_path)
|
||||
)
|
||||
|
||||
print(f"[{idx}/{len(phrases)}] {key} → {filename}", end="", flush=True)
|
||||
|
||||
# Skip if already generated (idempotent re-run)
|
||||
if out_path.exists() and out_path.stat().st_size > 0:
|
||||
if out_path.exists() and out_path.stat().st_size > 0 and not args.force:
|
||||
print(" (cached)")
|
||||
manifest[key] = rel_path
|
||||
succeeded += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
audio_bytes = synthesize(text, args.voice, args.tts_url)
|
||||
t0 = time.monotonic()
|
||||
audio_bytes = synthesize_piper(text, args.voice, args.tts_url)
|
||||
out_path.write_bytes(audio_bytes)
|
||||
manifest[key] = rel_path
|
||||
succeeded += 1
|
||||
print(f" OK ({len(audio_bytes)} bytes)")
|
||||
dt_ms = int((time.monotonic() - t0) * 1000)
|
||||
print(f" OK ({len(audio_bytes)} B in {dt_ms} ms)")
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
print(f" FAIL (connection error: {exc})")
|
||||
failed.append((key, text, f"ConnectionError: {exc}"))
|
||||
@@ -233,22 +332,181 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.delay > 0:
|
||||
time.sleep(args.delay)
|
||||
|
||||
# Write manifest
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
||||
print(f"\nManifest written: {manifest_path} ({len(manifest)} entries)")
|
||||
|
||||
# Summary
|
||||
print(f"\nSummary: {succeeded} succeeded, {len(failed)} failed out of {len(phrases)} phrases")
|
||||
print(
|
||||
f"\nSummary: {succeeded} succeeded, {len(failed)} failed "
|
||||
f"out of {len(phrases)} phrases"
|
||||
)
|
||||
if failed:
|
||||
print("\nFailed phrases:")
|
||||
for key, text, error in failed:
|
||||
print(f" [{key}] {error}")
|
||||
print(f" text: {text[:80]}{'…' if len(text) > 80 else ''}")
|
||||
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
def _run_f5(args: argparse.Namespace, phrases: list[tuple[str, str]]) -> int:
|
||||
f5_dir = args.output / "f5"
|
||||
f5_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path: Path = args.manifest or (f5_dir / "manifest.json")
|
||||
|
||||
ref_hash = voice_ref_token(args.ref_wav)
|
||||
print(
|
||||
f"F5 backend → {args.voice_bridge_url} (steps={args.steps}, "
|
||||
f"ref_hash={ref_hash})"
|
||||
)
|
||||
|
||||
manifest_entries: dict[str, dict[str, object]] = {}
|
||||
failed: list[tuple[str, str, str]] = []
|
||||
succeeded = 0
|
||||
|
||||
for idx, (key, text) in enumerate(phrases, start=1):
|
||||
# Prefer the explicit YAML key for the file name (stable, human-readable);
|
||||
# the SHA-256 cache_key is also recorded for daemon-side cache pinning.
|
||||
filename = key_to_filename(key, ext="wav")
|
||||
out_path = f5_dir / filename
|
||||
rel_path = (
|
||||
str(out_path.relative_to(args.output.parent))
|
||||
if out_path.is_relative_to(args.output.parent)
|
||||
else str(out_path)
|
||||
)
|
||||
cache_key = f5_cache_key(text, ref_hash, args.steps)
|
||||
|
||||
print(
|
||||
f"[{idx}/{len(phrases)}] {key} → {filename} (ck={cache_key})",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if out_path.exists() and out_path.stat().st_size > 0 and not args.force:
|
||||
print(" (cached)")
|
||||
manifest_entries[key] = {
|
||||
"cache_key": cache_key,
|
||||
"audio_path": rel_path,
|
||||
"text": text,
|
||||
"voice_bridge_url": args.voice_bridge_url,
|
||||
"steps": args.steps,
|
||||
"ref_hash": ref_hash,
|
||||
"cached": True,
|
||||
}
|
||||
succeeded += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
wav_bytes, hdrs = synthesize_f5(
|
||||
text,
|
||||
voice_bridge_url=args.voice_bridge_url,
|
||||
steps=args.steps,
|
||||
ref_wav=str(args.ref_wav) if args.ref_wav else None,
|
||||
)
|
||||
out_path.write_bytes(wav_bytes)
|
||||
dt_ms = int((time.monotonic() - t0) * 1000)
|
||||
backend_used = hdrs.get("x-tts-backend") or hdrs.get("X-TTS-Backend") or "?"
|
||||
cache_hit = hdrs.get("x-tts-cache-hit") or hdrs.get("X-TTS-Cache-Hit") or "?"
|
||||
srv_lat = hdrs.get("x-tts-latency-ms") or hdrs.get("X-TTS-Latency-Ms") or "?"
|
||||
manifest_entries[key] = {
|
||||
"cache_key": cache_key,
|
||||
"audio_path": rel_path,
|
||||
"text": text,
|
||||
"voice_bridge_url": args.voice_bridge_url,
|
||||
"steps": args.steps,
|
||||
"ref_hash": ref_hash,
|
||||
"gen_at_ms": int(time.time() * 1000),
|
||||
"server_backend": backend_used,
|
||||
"server_cache_hit": cache_hit,
|
||||
"server_latency_ms": srv_lat,
|
||||
}
|
||||
succeeded += 1
|
||||
print(
|
||||
f" OK ({len(wav_bytes) // 1024} kB in {dt_ms} ms, "
|
||||
f"backend={backend_used}, hit={cache_hit})"
|
||||
)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
print(f" FAIL (connection error: {exc})")
|
||||
failed.append((key, text, f"ConnectionError: {exc}"))
|
||||
print(
|
||||
"\nFATAL: voice-bridge is unreachable; aborting (no point retrying).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
break
|
||||
except requests.exceptions.Timeout:
|
||||
print(" FAIL (timeout)")
|
||||
failed.append((key, text, "Timeout"))
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
body = exc.response.text[:200] if exc.response is not None else ""
|
||||
print(f" FAIL (HTTP {exc.response.status_code}: {body})")
|
||||
failed.append((key, text, f"HTTPError: {exc}"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" FAIL ({type(exc).__name__}: {exc})")
|
||||
failed.append((key, text, str(exc)))
|
||||
|
||||
if args.delay > 0:
|
||||
time.sleep(args.delay)
|
||||
|
||||
manifest = {
|
||||
"backend": "f5",
|
||||
"voice_bridge_url": args.voice_bridge_url,
|
||||
"steps": args.steps,
|
||||
"ref_hash": ref_hash,
|
||||
"ref_wav": str(args.ref_wav) if args.ref_wav else None,
|
||||
"generated_at_ms": int(time.time() * 1000),
|
||||
"entries": manifest_entries,
|
||||
}
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
||||
print(f"\nManifest written: {manifest_path} ({len(manifest_entries)} entries)")
|
||||
print(
|
||||
f"\nSummary: {succeeded} succeeded, {len(failed)} failed "
|
||||
f"out of {len(phrases)} phrases"
|
||||
)
|
||||
if failed:
|
||||
print("\nFailed phrases:")
|
||||
for key, text, error in failed:
|
||||
print(f" [{key}] {error}")
|
||||
print(f" text: {text[:80]}{'…' if len(text) > 80 else ''}")
|
||||
return 0 if not failed else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
if not args.phrases.exists():
|
||||
print(f"ERROR: phrases file not found: {args.phrases}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
phrases = load_phrases(args.phrases)
|
||||
if not phrases:
|
||||
print("ERROR: no phrases found in YAML file", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.limit > 0:
|
||||
phrases = phrases[: args.limit]
|
||||
|
||||
print(
|
||||
f"Loaded {len(phrases)} phrases from {args.phrases} "
|
||||
f"(backend={args.backend})"
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--- DRY RUN --- (no TTS calls)")
|
||||
for key, text in phrases:
|
||||
print(f" [{key}] {text[:80]}{'…' if len(text) > 80 else ''}")
|
||||
print(f"\nTotal: {len(phrases)} phrases")
|
||||
return 0
|
||||
|
||||
if args.backend == "piper":
|
||||
return _run_piper(args, phrases)
|
||||
return _run_f5(args, phrases)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
Reference in New Issue
Block a user