From 31266fb7f364f286a9d9f45598b72f6f1b20b1a7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 14:06:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(frank-tts):=20cloned-voice=20speech=20endp?= =?UTF-8?q?oint=20=E2=80=94=20OpenAI-shaped=20facade,=20voice=20packs=20by?= =?UTF-8?q?=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the voice-over goal (Gitea issue #2). One stdlib-only HTTP facade in front of whichever TTS engine wins the engine decision: POST /v1/audio/speech with voice= answers audio in THAT voice, where a name is real exactly when /voices//reference.wav exists — an unknown name is a 400 listing the voices that are, never a silent fallback (a wrong-voice endpoint stays wrong for weeks). voice_id works as the alias the omp harness sends; response_format=mp3 is really mp3 (ffmpeg re-encode); oversized input is refused with 414 instead of half-narrated; /health reports the backend and the resolved voice list. Two backends behind the one surface, chosen by TTS_BACKEND: omni-http forwards to an OpenAI-shaped upstream carrying the voice as base64 reference_audio, and llama-tts-cli runs a local binary per request with --tts-speaker-file at nice 19 so narration never starves the text endpoints. Both are driven in the seam through stubs — no GPU, no containers. Named frank-tts, a first-class fleet model endpoint: port 7772 in the 7770/7771 family, alias naming per the StarCraft law, rootless boot-persistent self-healing quadlet with a read-only /home/dank/voices mount. Voice-pack law recorded in the env header: reference.wav PLUS the engine's own speaker artifact (required, so a voice is reusable in other runtimes), plus manifest.json provenance with offsets always past the first 10 s of the source file. Not deployed: the engine decision, the pack builder, deployer/homepage/Prometheus registration and the omp models.yml runner row are phase 2. --- dankclaw/voiceservice/Containerfile | 43 ++ dankclaw/voiceservice/app.py | 413 ++++++++++++++++++ .../voiceservice/quadlet/frank-tts.container | 71 +++ scripts/frank-tts.env | 71 +++ tests/frank_tts_unit.bats | 343 +++++++++++++++ 5 files changed, 941 insertions(+) create mode 100644 dankclaw/voiceservice/Containerfile create mode 100755 dankclaw/voiceservice/app.py create mode 100644 dankclaw/voiceservice/quadlet/frank-tts.container create mode 100644 scripts/frank-tts.env create mode 100755 tests/frank_tts_unit.bats diff --git a/dankclaw/voiceservice/Containerfile b/dankclaw/voiceservice/Containerfile new file mode 100644 index 0000000..26f5bdc --- /dev/null +++ b/dankclaw/voiceservice/Containerfile @@ -0,0 +1,43 @@ +# frank-tts — the cloned-voice speech façade (dankclaw/voiceservice/app.py). +# +# Standard library only, so there is nothing to pip-install and nothing to +# compile: the image is a python runtime plus ffmpeg (the one binary the app +# shells out to, for response_format=mp3) plus one COPY of app.py. Rebuild to +# ship a new app.py. +# +# Build on dankclaw (deploy-remote.sh build_compose_image frank-tts does this, +# including --format docker, which the fleet's other local builds also pass): +# podman build --format docker -t franksdank/frank-tts:latest \ +# -f dankclaw/voiceservice/Containerfile . +# +# BASE PINNED BY DIGEST, like every other image this fleet runs from a +# registry: a tag moves under a rebuild and a voice endpoint that changes +# python or ffmpeg mid-week is a debug session nobody asked for. The digest is +# the multi-arch INDEX of python:3.12-alpine as published 2026-10-18 (amd64 + +# arm64 on linux); Dankclaw resolves the amd64 entry. +# +# WHY 3.12: the fleet's ceiling for this app is python <= 3.12 (single file, +# no build step); the alpine flavour also keeps the image in the tens of MB +# and ships busybox `wget`, which is what the quadlet unit's HealthCmd runs — +# so no HEALTHCHECK belongs here (the probe lives in the unit, as in +# alertmanager/prometheus; only distroless bases need it baked in). +FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4 + +# ffmpeg: mp3 output. The app always asks the upstream for wav and re-encodes +# on the way out, so ffmpeg is the only external program in the picture. +RUN apk add --no-cache ffmpeg + +# Unbuffered stdout: the unit's journal must show the "listening on" line (and +# any backend error) the moment it happens, not when the buffer fills. +ENV PYTHONUNBUFFERED=1 + +COPY dankclaw/voiceservice/app.py /app/app.py + +# Non-root. The voice packs are bind-mounted read-only, so this user only ever +# reads them — they must be world-readable (the pack builder writes 644/755). +# The one thing it writes is its per-request temp dir under /tmp. +RUN adduser -D -u 10000 -h /app -s /sbin/nologin shim +USER shim + +EXPOSE 7772 +CMD ["python3", "/app/app.py"] diff --git a/dankclaw/voiceservice/app.py b/dankclaw/voiceservice/app.py new file mode 100755 index 0000000..f6e9416 --- /dev/null +++ b/dankclaw/voiceservice/app.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""frank-tts — the fleet's OpenAI-compatible speech façade (component: the shim). + +One local endpoint that answers ``voice=`` with audio in a cloned voice. +Consumers (the omp harness, OpenWebUI, open-notebook) speak the OpenAI +``POST /v1/audio/speech`` shape and never learn a file path: a voice is a +directory under ``$VOICES_DIR`` and ``reference.wav`` inside it is what makes +the name real. A name that has no reference is not a voice — the shim answers +400 with the list that is, instead of silently narrating in a fallback voice +(silent fallback is how a wrong-voice endpoint stays wrong for weeks). + +Two backends sit behind the one surface, chosen by ``TTS_BACKEND``: + +``omni-http`` + Forward to an OpenAI-shaped upstream TTS server (``$TTS_UPSTREAM``) and + carry the cloned voice with the request as ``reference_audio`` (base64 of + ``reference.wav``). The upstream does the inference; this process only + resolves the voice and, when asked, re-encodes to mp3 with ``ffmpeg``. + +``llama-tts-cli`` + Run a local binary per request (``$LLAMA_TTS_BIN``), hand it the + reference with ``--tts-speaker-file``, read the WAV it writes. ``nice -n + 19`` because this box also serves text and a voice request must never + starve it. + +Everything comes from the environment, read at REQUEST time (no +import-time-only globals): the same module can be imported with an injected +``Config`` or run with ``python3 app.py`` and the unit's env file. + +Runtime law: single process, standard library only, no build step. Audio +bytes never reach the log — request lines and error text only. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +WAV_CONTENT_TYPE = "audio/wav" +MP3_CONTENT_TYPE = "audio/mpeg" + +# A voice name is a plain directory name. Slashes and `..` are refused here so +# a client-supplied name can never escape $VOICES_DIR. +VOICE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + + +class ShimError(Exception): + """A failure with the HTTP status the client should see.""" + + def __init__(self, status, message): + super().__init__(message) + self.status = status + self.message = message + + +class Config: + """Every knob of the shim. Constructed from the environment per request by + default, or injected into ``create_server`` for tests and embedding.""" + + def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin, + llama_model, llama_mmproj, llama_lang, llama_threads, port, + max_chars, timeout): + self.voices_dir = Path(voices_dir) + self.backend = backend + self.upstream = upstream + self.upstream_model = upstream_model + self.llama_bin = llama_bin + self.llama_model = llama_model + self.llama_mmproj = llama_mmproj + self.llama_lang = llama_lang + self.llama_threads = llama_threads + self.port = port + self.max_chars = max_chars + self.timeout = timeout + + @classmethod + def from_env(cls, environ=None): + env = os.environ if environ is None else environ + return cls( + voices_dir=env.get("VOICES_DIR", "/voices"), + backend=(env.get("TTS_BACKEND") or "omni-http").strip().lower(), + upstream=(env.get("TTS_UPSTREAM") or "http://host.containers.internal:8880").rstrip("/"), + upstream_model=env.get("TTS_UPSTREAM_MODEL") or "voxcpm2", + llama_bin=env.get("LLAMA_TTS_BIN") or "", + llama_model=env.get("LLAMA_TTS_MODEL") or "", + llama_mmproj=env.get("LLAMA_TTS_MMPROJ") or "", + llama_lang=env.get("LLAMA_TTS_LANG") or "english", + llama_threads=_int(env, "LLAMA_TTS_THREADS", 8), + port=_int(env, "SHIM_PORT", 7772), + max_chars=_int(env, "SHIM_MAX_CHARS", 15000), + timeout=float(_int(env, "SHIM_TIMEOUT", 600)), + ) + + +def _int(env, name, default): + try: + return int(str(env.get(name, "")).strip()) + except (TypeError, ValueError): + return default + + +def list_voices(voices_dir): + """Voice names in ``voices_dir``: directories that hold a ``reference.wav``. + + A missing or unreadable directory is an empty fleet, not an error — the + health endpoint must still answer so the unit reports its state. + """ + try: + entries = sorted(p.name for p in voices_dir.iterdir() + if p.is_dir() and (p / "reference.wav").is_file()) + except OSError: + return [] + return entries + + +def resolve_voice(voices_dir, voice): + """``$VOICES_DIR//reference.wav`` for a valid name, else ``None``.""" + if not isinstance(voice, str) or not VOICE_NAME_RE.fullmatch(voice): + return None + reference = voices_dir / voice / "reference.wav" + return reference if reference.is_file() else None + + +def to_mp3(wav_bytes, timeout): + """Re-encode WAV bytes to mp3 with the image's ``ffmpeg`` (pipes, no temp + file: the audio never lands on disk to be left behind).""" + argv = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0", + "-f", "mp3", "pipe:1"] + try: + proc = subprocess.run(argv, input=wav_bytes, capture_output=True, + timeout=timeout) + except FileNotFoundError: + raise ShimError(500, "response_format=mp3 needs the ffmpeg binary, which is not on PATH") + except subprocess.TimeoutExpired: + raise ShimError(502, "ffmpeg mp3 conversion timed out after %gs" % timeout) + if proc.returncode != 0 or not proc.stdout: + raise ShimError(502, "ffmpeg mp3 conversion failed (exit %s): %s" + % (proc.returncode, _tail(proc.stderr))) + return proc.stdout + + +def _tail(raw): + """Last line of a subprocess' stderr, for an error message. Text only — + audio bytes are never echoed, and never logged.""" + text = (raw or b"").decode("utf-8", "replace").strip() + return text.splitlines()[-1][:300] if text else "no output" + + +def _error(message, voices=None): + body = {"error": {"message": message, "type": "invalid_request_error"}} + if voices is not None: + body["error"]["available_voices"] = voices + return body + + +class SpeechHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "frank-tts" + sys_version = "" + + # -- plumbing ---------------------------------------------------------- + def config(self): + return getattr(self.server, "config", None) or Config.from_env() + + def log_message(self, fmt, *args): + # One prefix for the journal. Request lines and outcomes only: a + # synthesized text, a base64 reference voice and the audio bytes + # itself never reach stderr, in success or in failure. + sys.stderr.write("frank-tts: %s - %s\n" + % (self.address_string(), fmt % args)) + + def log_error(self, fmt, *args): + # The base class routes refused requests through a separate sink; keep + # them on the same line format so the journal reads uniformly. + self.log_message(fmt, *args) + + def _json(self, status, obj, head=False): + self._bytes(status, json.dumps(obj).encode("utf-8"), "application/json", head) + + def _bytes(self, status, data, content_type, head=False): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if not head: + self.wfile.write(data) + + def _drain(self): + """Read and discard a body this route will not answer. HTTP/1.1 + keep-alive makes an unread body the NEXT request line, so a client + that reuses the connection would see a parse error it never sent.""" + if getattr(self, "_body_read", False): + return # already consumed; a second read would block the response + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + return + if length > 0: + self.rfile.read(length) + + def _body(self): + """Parse the JSON request body. Never returned to a log or an error.""" + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + raise ValueError("Content-Length is not a number") + raw = self.rfile.read(length) if length > 0 else b"" + self._body_read = True + if not raw: + raise ValueError("expected a JSON body") + parsed = json.loads(raw.decode("utf-8")) + if not isinstance(parsed, dict): + raise ValueError("expected a JSON object") + return parsed + + # -- routes ------------------------------------------------------------ + def do_GET(self, head=False): + # HEAD shares the route table: `wget --spider` (the fleet's probe + # shape) asks for HEAD, and a 501 there would read as a dead service. + path = urllib.parse.urlparse(self.path).path + cfg = self.config() + if path == "/health": + self._json(200, {"status": "ok", "backend": cfg.backend, + "voices": list_voices(cfg.voices_dir)}, head) + elif path == "/v1/audio/voices": + self._json(200, list_voices(cfg.voices_dir), head) + else: + self._json(404, _error("no such endpoint: %s" % path), head) + + def do_HEAD(self): + self.do_GET(head=True) + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + if path != "/v1/audio/speech": + self._drain() + self._json(404, _error("no such endpoint: %s" % path)) + return + cfg = self.config() + try: + body = self._body() + except (ValueError, UnicodeDecodeError) as exc: + self._drain() + self._json(400, _error("malformed request body: %s" % exc)) + return + + text = body.get("input") + if not isinstance(text, str) or not text.strip(): + self._json(400, _error("`input` must be a non-empty string")) + return + if len(text) > cfg.max_chars: + self._json(414, _error("input is %d characters, over the %d limit " + "(SHIM_MAX_CHARS); split it or raise the limit" + % (len(text), cfg.max_chars))) + return + + fmt = str(body.get("response_format") or "wav").strip().lower() + if fmt not in ("wav", "mp3"): + self._json(400, _error("unsupported response_format %r; this " + "endpoint serves wav and mp3" % fmt)) + return + + # `voice_id` is the alias the omp harness forwards for `voice`. + voice = body.get("voice") or body.get("voice_id") + reference = resolve_voice(cfg.voices_dir, voice) + if reference is None: + available = list_voices(cfg.voices_dir) + listed = ", ".join(available) if available else "(none: no voice pack with a reference.wav in %s)" % cfg.voices_dir + self._json(400, _error("unknown voice %r; available voices: %s" + % (voice, listed), available)) + return + + model = str(body.get("model") or cfg.upstream_model) + try: + if cfg.backend == "omni-http": + audio = self._speech_http(cfg, model, text, str(voice), reference) + elif cfg.backend == "llama-tts-cli": + audio = self._speech_cli(cfg, text, reference) + else: + raise ShimError(500, "unknown TTS_BACKEND %r (expected omni-http or llama-tts-cli)" % cfg.backend) + if fmt == "mp3": + audio = to_mp3(audio, cfg.timeout) + except ShimError as exc: + self._json(exc.status, _error(exc.message)) + return + + content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE + self._bytes(200, audio, content_type) + self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s", + voice, fmt, len(text), len(audio), content_type) + + # -- backends ---------------------------------------------------------- + def _speech_http(self, cfg, model, text, voice, reference): + """Forward to the upstream with the cloned voice riding along as + base64 ``reference_audio``. The upstream is asked for wav always; mp3 + is our own re-encode on the way out.""" + try: + payload = { + "model": model, + "input": text, + "voice": voice, + "response_format": "wav", + "reference_audio": base64.b64encode(reference.read_bytes()).decode("ascii"), + } + except OSError as exc: + raise ShimError(500, "cannot read %s: %s" % (reference, exc)) + + url = cfg.upstream + "/v1/audio/speech" + request = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(request, timeout=cfg.timeout) as response: + audio = response.read() + except urllib.error.HTTPError as exc: + detail = exc.read(512).decode("utf-8", "replace").strip() + raise ShimError(502, "upstream %s returned HTTP %d: %s" + % (url, exc.code, detail or "no detail")) + except urllib.error.URLError as exc: + raise ShimError(502, "upstream %s unreachable: %s" % (url, exc.reason)) + except (TimeoutError, OSError) as exc: + raise ShimError(502, "upstream %s failed: %s" % (url, exc)) + if not audio: + raise ShimError(502, "upstream %s returned no audio" % url) + return audio + + def _speech_cli(self, cfg, text, reference): + """Run the local TTS binary once, in a temp dir that is always removed + — also on failure, or a crash-looping binary fills /tmp.""" + if not cfg.llama_bin or not cfg.llama_model: + raise ShimError(500, "TTS_BACKEND=llama-tts-cli needs LLAMA_TTS_BIN and LLAMA_TTS_MODEL") + work = tempfile.TemporaryDirectory(prefix="frank-tts-") + try: + out_path = Path(work.name) / "speech.wav" + argv = ["nice", "-n", "19", "env", "OMP_NUM_THREADS=%d" % cfg.llama_threads, + cfg.llama_bin, "-m", cfg.llama_model] + if cfg.llama_mmproj: + argv += ["-mm", cfg.llama_mmproj] + argv += ["--tts-lang", cfg.llama_lang, + "--tts-speaker-file", str(reference), + "-p", text, + "--output", str(out_path)] + try: + proc = subprocess.run(argv, cwd=work.name, capture_output=True, + timeout=cfg.timeout) + except FileNotFoundError as exc: + raise ShimError(500, "cannot run the TTS command: %s" % exc) + except subprocess.TimeoutExpired: + raise ShimError(502, "TTS binary timed out after %gs" % cfg.timeout) + if proc.returncode != 0: + raise ShimError(502, "TTS binary failed (exit %s): %s" + % (proc.returncode, _tail(proc.stderr))) + if not out_path.is_file(): + raise ShimError(502, "TTS binary exited 0 but wrote no %s" % out_path.name) + return out_path.read_bytes() + finally: + work.cleanup() + + +class SpeechServer(ThreadingHTTPServer): + """Threads so a health probe answers while a long synthesis runs; daemon + threads so SIGTERM is never held hostage by a stuck request.""" + + daemon_threads = True + allow_reuse_address = True + config = None + + +def create_server(config=None, host="0.0.0.0", port=None): + """Bind the shim. ``config``/``port`` are the injectable seams: tests pass + a Config with a temp VOICES_DIR and ``port=0`` for an ephemeral port.""" + cfg = Config.from_env() if config is None else config + server = SpeechServer((host, cfg.port if port is None else port), SpeechHandler) + server.config = cfg + return server + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="frank-tts", description="OpenAI-compatible speech endpoint for cloned voice packs") + parser.add_argument("--host", default="0.0.0.0", + help="bind address (default: all interfaces)") + parser.add_argument("--port", type=int, default=None, + help="bind port (default: $SHIM_PORT, 0 = ephemeral)") + args = parser.parse_args(argv) + + server = create_server(host=args.host, port=args.port) + cfg = server.config + bound = server.server_address[1] + print("frank-tts: backend=%s voices_dir=%s listening on http://%s:%d" + % (cfg.backend, cfg.voices_dir, args.host, bound), flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dankclaw/voiceservice/quadlet/frank-tts.container b/dankclaw/voiceservice/quadlet/frank-tts.container new file mode 100644 index 0000000..138e239 --- /dev/null +++ b/dankclaw/voiceservice/quadlet/frank-tts.container @@ -0,0 +1,71 @@ +# Quadlet unit: frank-tts (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/frank-tts.container +# Unit name: frank-tts.service (container name: frank-tts) +# +# WHAT IT IS. The fleet's one OpenAI-compatible speech endpoint: consumers +# (omp, OpenWebUI, open-notebook) POST /v1/audio/speech with voice= and +# get audio in that cloned voice. A voice is a directory under +# /home/dank/voices holding reference.wav; the shim refuses any name without +# one (400 + the list that does exist) rather than narrating in a fallback +# voice nobody chose. The engine behind it is a separate decision — +# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an +# OpenAI-shaped upstream on host :8880 and running a local CLI binary per +# request. +# +# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by +# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine +# pinned by digest + ffmpeg, app.py copied in, running as uid 10000. +# Pull=never and the localhost/ prefix are required — this host's quadlet +# path cannot resolve a locally built tag from local storage otherwise. +# +# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared +# loopback namespace, and the comfyui/loki/authentik units are bridge too. +# Consequences both ways: consumers reach it on the published 7772, and the +# shim reaches the host-side TTS engine through host.containers.internal +# (never the LAN IP — a bridge netns cannot route to the host's own address +# for every bind shape, and the fleet's law is the gateway name). NOTE for +# whoever starts the :8880 engine: host.containers.internal reaches the host +# network namespace, so the engine must bind an address that namespace +# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop +# needed the host LAN bind for the same reason). +# +# VOICES. /home/dank/voices is host state, never repo state, and is mounted +# READ-ONLY: this endpoint has no business writing voice packs. Because the +# container runs as uid 10000, the packs must be world-readable (644 files, +# 755 directories), which is how the pack builder writes them. +# +# NO GPU. The shim is a façade: audio conversion via ffmpeg on the CPU and, +# for the CLI backend, whatever CPU threads LLAMA_TTS_THREADS allows, at nice +# 19. No /dev/kfd, no /dev/dri, no keep-groups — unlike comfyui, whose unit +# carries them deliberately. +[Unit] +Description=frank-tts (cloned-voice OpenAI-compatible TTS facade) +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=frank-tts +Image=localhost/franksdank/frank-tts:latest +Pull=never +Network=dankclaw_default +PublishPort=7772:7772 +EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env +Volume=/home/dank/voices:/voices:ro +# The probe is a probe, not a voice test: /health answers 200 with the backend +# and the resolved voice list, so a mounted-but-empty /voices still reads +# healthy while a dead app does not. busybox wget ships in the alpine base; +# keep the command argument-only — the podman 5.x generator splits HealthCmd +# on spaces (see the comfyui unit for the quoting trap). +HealthCmd=wget --spider -q http://127.0.0.1:7772/health +HealthInterval=30s +HealthTimeout=10s +HealthRetries=3 +HealthStartPeriod=30s + +[Service] +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/scripts/frank-tts.env b/scripts/frank-tts.env new file mode 100644 index 0000000..5c99c28 --- /dev/null +++ b/scripts/frank-tts.env @@ -0,0 +1,71 @@ +# frank-tts.env — tunables for **frank-tts**, the fleet's cloned-voice speech +# endpoint on Dankclaw, host port **7772** (the LLM port family: 7770 +# frank-dragoon, 7771 frank-zealot, 7772 frank-tts). Read by the Quadlet unit +# (EnvironmentFile=), which is why the paths below are CONTAINER paths. +# +# WHAT THIS IS. One OpenAI-compatible /v1/audio/speech façade in front of +# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py +# is a single stdlib-only file in a locally built image). Consumers — omp's +# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME +# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path: +# a name is real exactly when /voices//reference.wav exists, and +# anything else is a 400 that lists the names that are. +# +# THE PACKS. /home/dank/voices on the host, mounted read-only at /voices. One +# directory per voice, and a directory is a PACK only when it carries BOTH +# required members: +# reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio, +# plus the 16 kHz variant some engines want; +# the engine artifact the pinned backend's own speaker file (embedding / +# speaker weights). REQUIRED, beside the WAV and never +# replacing it — a voice that cannot be reused by another +# runtime is not a saved voice. +# plus manifest.json for provenance: source file, offsets (always past the +# first 10 s of that file, the source-material law), engine, sha256. Packs are +# host state and stay out of this repo; they must be world-readable because the +# container runs as uid 10000. Who enforces what: the shim gates on +# reference.wav because that is the only engine-agnostic term, so the pack +# builder is what refuses to emit a pack without its engine artifact — until a +# winning engine pins the format and this gate can name the file. +# +# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the +# consumer: +# omni-http forward to an OpenAI-shaped upstream on the host (:8880), +# carrying the voice along as base64 reference_audio. The +# engine does the inference; the shim resolves the voice and +# re-encodes to mp3 when asked. +# llama-tts-cli run LLAMA_TTS_BIN once per request with +# --tts-speaker-file , nice 19 so a narration +# never starves the text endpoints on this box. Needs +# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ +# only if that binary takes a projector. +# The upstream address is host.containers.internal — the gateway name a bridge +# container uses for the host. It is NOT the LAN IP and it is NOT +# 127.0.0.1: a bridge netns has neither. +VOICES_DIR=/voices +TTS_BACKEND=omni-http +TTS_UPSTREAM=http://host.containers.internal:8880 +TTS_UPSTREAM_MODEL=voxcpm2 + +# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not +# configured": the endpoint answers a 500 that says so instead of running +# `nice env -m ...` with an empty binary name. +LLAMA_TTS_BIN= +LLAMA_TTS_MODEL= +LLAMA_TTS_MMPROJ= +LLAMA_TTS_LANG=english +LLAMA_TTS_THREADS=8 + +# The wire shape. 7772 is frank-tts's host port (the container's too), the next +# slot in the fleet's model-endpoint family; the TTS engine upstream of it +# keeps 8880, which is not a fleet endpoint. +SHIM_PORT=7772 +# Longest single narration, in characters of `input`. Podcast-length text is +# the consumer's job to chunk; one request over this limit is refused with 414 +# rather than silently truncated into a half-sentence of audio. +SHIM_MAX_CHARS=15000 +# Seconds one request may take, covering both an upstream synthesis and a CLI +# run. Voice is slow compared to text — a long paragraph on a CPU engine is +# minutes, not milliseconds — and a client that gave up earlier would only +# retry into a second synthesis competing for the same CPU. +SHIM_TIMEOUT=600 diff --git a/tests/frank_tts_unit.bats b/tests/frank_tts_unit.bats new file mode 100755 index 0000000..f4ae062 --- /dev/null +++ b/tests/frank_tts_unit.bats @@ -0,0 +1,343 @@ +#!/usr/bin/env bats +# Seam: frank-tts, the cloned-voice speech façade on Dankclaw :7772. +# +# The contract this pins is the one every consumer (omp, OpenWebUI, +# open-notebook) codes against: POST /v1/audio/speech with voice= is +# answered with audio in THAT voice pack, where "that voice pack" is exactly +# $VOICES_DIR//reference.wav — the file that makes a name real. A name +# without a reference must come back as a 400 naming the voices that do exist +# (a silent fallback voice is how a wrong-voice endpoint stays wrong for +# weeks), the harness' voice_id must work as an alias for voice, oversized text +# must be refused rather than half-narrated, and the mp3 response_format must +# really be mp3. Whatever engine sits behind it is a separate decision — so +# both backends are driven here through stubs, never against a GPU. +# +# Everything runs against the real app.py over 127.0.0.1 ephemeral ports with +# a temp VOICES_DIR: no Dankclaw, no network, no containers, no pkill — the +# processes this file starts are recorded by pid and killed in teardown. + +load helpers + +setup() { + APP="$REPO_ROOT/dankclaw/voiceservice/app.py" + UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container" + ENV_FILE="$REPO_ROOT/scripts/frank-tts.env" + TEST_TMP="$(mktemp -d)" + PIDS="$TEST_TMP/pids" + : > "$PIDS" + + # One real voice pack and one directory that LOOKS like a pack but has no + # reference.wav — it must never be offered as a voice. + VOICES="$TEST_TMP/voices" + mkdir -p "$VOICES/narrator1" "$VOICES/nopack" + python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(256)) * 4)' \ + > "$VOICES/narrator1/reference.wav" + printf 'a manifest is not a voice\n' > "$VOICES/nopack/manifest.json" + + # The audio the stub upstream "synthesises": distinct bytes, so a response + # can be proven to be the engine's output and not something the shim made + # up on its way past. + python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(reversed(range(128))) * 3)' \ + > "$TEST_TMP/upstream.wav" + + write_stub +} + +teardown() { + # Kill only what this file started, by recorded pid. + while IFS= read -r pid; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done < "$PIDS" + wait 2>/dev/null || true + rm -rf "$TEST_TMP" +} + +# write_stub — the upstream TTS server arm A forwards to: records the JSON body +# it was handed (so "did the reference voice actually travel?" is observable) +# and answers with $STUB_WAV. Prints its bound port, chosen by the kernel. +write_stub() { + cat > "$TEST_TMP/stub_upstream.py" <<'PY' +import json, os +from http.server import BaseHTTPRequestHandler, HTTPServer + +class Stub(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get('content-length') or 0) + with open(os.environ['STUB_CAPTURE'], 'w') as fh: + json.dump(json.loads(self.rfile.read(length) or b'{}'), fh) + with open(os.environ['STUB_WAV'], 'rb') as fh: + audio = fh.read() + self.send_response(200) + self.send_header('content-type', 'audio/wav') + self.send_header('content-length', str(len(audio))) + self.end_headers() + self.wfile.write(audio) + + def log_message(self, *args): + pass + +server = HTTPServer(('127.0.0.1', 0), Stub) +print(server.server_address[1], flush=True) +server.serve_forever() +PY +} + +start_stub() { + : > "$TEST_TMP/stub.log" + STUB_WAV="$TEST_TMP/upstream.wav" STUB_CAPTURE="$TEST_TMP/capture.json" \ + python3 "$TEST_TMP/stub_upstream.py" > "$TEST_TMP/stub.log" 2>&1 & + echo "$!" >> "$PIDS" + local waited=0 + while [ "$waited" -lt 100 ]; do + STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")" + [ -n "$STUB_PORT" ] && return 0 + sleep 0.05 + waited=$((waited + 1)) + done + echo "stub upstream never bound a port:" + cat "$TEST_TMP/stub.log" + return 1 +} + +# start_app — boot app.py on 127.0.0.1 with an ephemeral port and read that +# port back off its own startup line: no port guessing, no race. The caller +# exports the env the run needs first; app.py reads its knobs at request time. +start_app() { + : > "$TEST_TMP/app.log" + python3 "$APP" --host 127.0.0.1 --port 0 > "$TEST_TMP/app.log" 2>&1 & + echo "$!" >> "$PIDS" + local waited=0 + while [ "$waited" -lt 200 ]; do + APP_PORT="$(sed -nE 's|.*listening on http://127\.0\.0\.1:([0-9]+)|\1|p' "$TEST_TMP/app.log" | head -n1)" + [ -n "$APP_PORT" ] && return 0 + sleep 0.05 + waited=$((waited + 1)) + done + echo "frank-tts never came up:" + cat "$TEST_TMP/app.log" + return 1 +} + +# arm_omni — the shim forwarding to an OpenAI-shaped upstream (arm A). +arm_omni() { + start_stub + export VOICES_DIR="$VOICES" + export TTS_BACKEND=omni-http + export TTS_UPSTREAM="http://127.0.0.1:$STUB_PORT" +} + +speech() { + # speech — the HTTP code lands in $output, the body + # in $TEST_TMP/out.bin and the response headers in head.txt. + local data="$1" + run curl -sS -o "$TEST_TMP/out.bin" -D "$TEST_TMP/head.txt" -w '%{http_code}' \ + -H 'Content-Type: application/json' --data-binary "$data" \ + "http://127.0.0.1:$APP_PORT/v1/audio/speech" +} + +@test "app.py parses as python the runtime can import" { + run env PYTHONPYCACHEPREFIX="$TEST_TMP/pycache" python3 -m py_compile "$APP" + [ "$status" -eq 0 ] || { echo "$output"; false; } +} + +@test "voice=narrator1 returns the engine's audio with the pack's reference attached" { + arm_omni + start_app + speech '{"model":"tts-1","input":"hello world","voice":"narrator1"}' + [ "$status" -eq 0 ] || { cat "$TEST_TMP/app.log"; false; } + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt" + cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav" + # The voice only arrives at the engine as base64 reference_audio — prove it + # is the pack's bytes, not a path and not the engine's own default voice. + python3 - "$TEST_TMP/capture.json" "$VOICES/narrator1/reference.wav" <<'PY' +import base64, json, sys +body = json.load(open(sys.argv[1])) +reference = open(sys.argv[2], 'rb').read() +assert body['voice'] == 'narrator1', body +assert body['input'] == 'hello world', body +assert body['response_format'] == 'wav', body +assert base64.b64decode(body['reference_audio']) == reference, 'reference_audio is not the pack wav' +PY + # What a consumer discovers before it speaks: /health and the voice list. + # A directory with a manifest but no reference.wav is not a voice. + run curl -sS "http://127.0.0.1:$APP_PORT/v1/audio/voices" + [ "$output" = '["narrator1"]' ] + run curl -sS "http://127.0.0.1:$APP_PORT/health" + [[ "$output" == *'"status": "ok"'* ]] + [[ "$output" == *'"backend": "omni-http"'* ]] + [[ "$output" == *'"voices": ["narrator1"]'* ]] +} + +@test "voice_id works as the alias the omp harness sends" { + arm_omni + start_app + speech '{"model":"tts-1","input":"hello","voice_id":"narrator1"}' + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav" + python3 - "$TEST_TMP/capture.json" <<'PY' +import json, sys +body = json.load(open(sys.argv[1])) +assert body['voice'] == 'narrator1', 'voice_id never became the upstream voice' +assert 'voice_id' not in body, body +PY +} + +@test "an unknown voice is a 400 naming the voices that exist" { + arm_omni + start_app + speech '{"model":"tts-1","input":"hello","voice":"nosuch"}' + [ "$output" = "400" ] + grep -q 'narrator1' "$TEST_TMP/out.bin" + # Refused before the engine saw anything: no audio, no upstream call. + [ ! -s "$TEST_TMP/capture.json" ] +} + +@test "oversized input is refused outright, not narrated half-way" { + arm_omni + # The limit is a knob the operator sets, not a constant in the seam: the + # app reads its knobs at request time, so exporting it before boot works. + export SHIM_MAX_CHARS=10 + start_app + python3 - "$TEST_TMP/payload.json" <<'PY' +import json, sys +json.dump({'model': 'tts-1', 'input': 'A' * 11, 'voice': 'narrator1'}, + open(sys.argv[1], 'w')) +PY + run curl -sS -o "$TEST_TMP/out.bin" -w '%{http_code}' \ + -H 'Content-Type: application/json' --data-binary "@$TEST_TMP/payload.json" \ + "http://127.0.0.1:$APP_PORT/v1/audio/speech" + [ "$output" = "414" ] + [ ! -s "$TEST_TMP/capture.json" ] +} + +@test "every other route is a 404, GET or POST" { + arm_omni + start_app + run curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$APP_PORT/v1/audio/speeck" + [ "$output" = "404" ] + run curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H 'Content-Type: application/json' -d '{"input":"hi","voice":"narrator1"}' \ + "http://127.0.0.1:$APP_PORT/v1/speech" + [ "$output" = "404" ] +} + +@test "an unrouted POST does not poison a reused connection" { + # The shim speaks HTTP/1.1, so a client may keep one TCP connection alive + # across requests. A body left unread on the way to a 404 is parsed as the + # NEXT request line — the consumer would blame the following, perfectly + # valid call. So: 404 first, then /health on the same connection. + arm_omni + start_app + python3 - "$APP_PORT" <<'PY' +import http.client, json, sys +conn = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=15) +conn.request("POST", "/v1/audio/speeck", + body=json.dumps({"input": "hi", "voice": "narrator1"}), + headers={"Content-Type": "application/json"}) +first = conn.getresponse() +assert first.status == 404, first.status +first.read() +conn.request("GET", "/health") +second = conn.getresponse() +assert second.status == 200, second.status +assert json.loads(second.read())["status"] == "ok" +conn.close() +PY +} + +@test "the CLI backend gets the resolved reference as --tts-speaker-file" { + # A fake binary standing in for llama-tts: it records its argv, writes a + # WAV where --output points, and reports the temp dir it was asked to work + # in so the cleanup promise is observable too. + cat > "$TEST_TMP/fake-tts" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$ARGS_FILE" +prev="" +for arg in "$@"; do + if [ "$prev" = "--output" ]; then + printf 'CLI-AUDIO' > "$arg" + dirname "$arg" > "$WORKDIR_FILE" + fi + prev="$arg" +done +exit 0 +FAKE + chmod +x "$TEST_TMP/fake-tts" + printf 'CLI-AUDIO' > "$TEST_TMP/expected.bin" + + export VOICES_DIR="$VOICES" + export TTS_BACKEND=llama-tts-cli + export LLAMA_TTS_BIN="$TEST_TMP/fake-tts" + export LLAMA_TTS_MODEL="$TEST_TMP/model.bin" + export ARGS_FILE="$TEST_TMP/argv.txt" + export WORKDIR_FILE="$TEST_TMP/workdir.txt" + start_app + speech '{"model":"tts-1","input":"narrate this","voice":"narrator1"}' + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + grep -qi '^content-type: audio/wav' "$TEST_TMP/head.txt" + cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected.bin" + # The speaker file must be the pack's reference under VOICES_DIR — that is + # the whole product; anything else and the clone never happens. + speaker="$(awk 'p { print; exit } $0 == "--tts-speaker-file" { p = 1 }' "$TEST_TMP/argv.txt")" + [ "$speaker" = "$VOICES/narrator1/reference.wav" ] + prompt="$(awk 'p { print; exit } $0 == "-p" { p = 1 }' "$TEST_TMP/argv.txt")" + [ "$prompt" = "narrate this" ] + grep -qx -- '--tts-lang' "$TEST_TMP/argv.txt" + # A per-request temp dir that survives is how a TTS box fills its /tmp. + workdir="$(cat "$TEST_TMP/workdir.txt")" + [ -n "$workdir" ] + [ ! -d "$workdir" ] +} + +@test "response_format=mp3 ships mp3, converted from the engine's wav" { + arm_omni + # A fake ffmpeg on PATH: whatever wav arrives on stdin is tapped (so the + # conversion provably ran on the engine's audio) and mp3 bytes come out. + mkdir -p "$TEST_TMP/bin" + cat > "$TEST_TMP/bin/ffmpeg" <<'FF' +#!/usr/bin/env bash +cat > "$STDIN_TAP" +printf 'MP3-BYTES' +FF + chmod +x "$TEST_TMP/bin/ffmpeg" + export STDIN_TAP="$TEST_TMP/ffmpeg-in.wav" + # The fake must reach the APP's PATH: the shim runs `ffmpeg` by name. + export PATH="$TEST_TMP/bin:$PATH" + start_app + speech '{"model":"tts-1","input":"hello","voice":"narrator1","response_format":"mp3"}' + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + grep -qi '^content-type: audio/mpeg' "$TEST_TMP/head.txt" + printf 'MP3-BYTES' > "$TEST_TMP/expected-mp3.bin" + cmp "$TEST_TMP/out.bin" "$TEST_TMP/expected-mp3.bin" + cmp "$TEST_TMP/ffmpeg-in.wav" "$TEST_TMP/upstream.wav" + # The upstream is still asked for wav: mp3 is this shim's re-encode. + python3 - "$TEST_TMP/capture.json" <<'PY' +import json, sys +assert json.load(open(sys.argv[1]))['response_format'] == 'wav' +PY +} + +@test "the shipped env file boots the shim with its declared backend" { + # Not a grep of the env file: source it the way systemd's EnvironmentFile + # would, point the two machine-local values at the sandbox, and require the + # endpoint to actually speak. A typo, a shell-ism or an expansion systemd + # cannot parse shows up here as a dead process, not a green grep. + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a + arm_omni + start_app + speech '{"model":"tts-1","input":"hello","voice":"narrator1"}' + [ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; } + cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav" +} + +@test "the quadlet unit keeps boot persistence and a read-only voice mount" { + grep -qE '^Restart=always$' "$UNIT" + grep -qE '^RestartSec=[0-9]+$' "$UNIT" + grep -qE '^WantedBy=default.target$' "$UNIT" + grep -qE '^PublishPort=([0-9.]+:)?7772:7772$' "$UNIT" + grep -qE '^Volume=/home/dank/voices:/voices:ro$' "$UNIT" +}