feat(frank-tts): cloned-voice speech endpoint (phase 1) #4
@@ -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"]
|
||||
Executable
+413
@@ -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
|
||||
@@ -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
|
||||
Executable
+343
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user