feat(frank-tts): cloned-voice speech endpoint (phase 1) #4

Merged
dank merged 1 commits from wt/20261017-181502 into main 2026-10-18 14:08:13 -07:00
5 changed files with 941 additions and 0 deletions
+43
View File
@@ -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"]
+413
View File
@@ -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=<name>`` 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/<voice>/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())
@@ -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=<name> 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
+71
View File
@@ -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/<name>/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 <reference.wav>, 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
+343
View File
@@ -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=<name> is
# answered with audio in THAT voice pack, where "that voice pack" is exactly
# $VOICES_DIR/<name>/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 <json-payload-inline> — 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"
}