feat(frank-tts-engine): the arm A engine as a boot-persistent quadlet unit

Repo state only: nothing deployed, no host touched, no container started.

- `frank-tts-engine` on Dankclaw: `qwentts.cpp` commit
  5916bf7c79dbb4e66fc059cdc507a79a23a28bb4, resident `tts-server`, 1.7 B base
  talker Q4_K_M + Q8_0 tokenizer, container port 8880. It is deliberately NOT a
  fleet endpoint — `frank-tts` on 7772 is the endpoint consumers address.
- Publishes NOTHING. A host-loopback publish would have been useless to the
  façade (netavark DNATs published ports only, so a bridge container cannot
  reach a host loopback bind — the shape that 502'd hermes) and would have been
  the one LAN-reachable surface of an unauthenticated synthesiser. Both voice
  containers sit on one committed quadlet network, `frank-tts-engine.network`
  pinned to 10.89.5.0/24 (next free /24 after open-notebook's 10.89.4.0/24), and
  peers address the engine by container name. Diagnostics and the deployer's
  health gate run inside the netns (`podman exec`, `wait_container_healthy`).
- Cold-voice law honoured in the unit: the first request for a registered voice
  decodes its whole reference to snapshot codec state (6.2-8.0 s, first audio
  byte 5 530 ms), later ones restore in 0.5 ms, and the registry is memory-only.
  `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh`
  with `TimeoutStartSec=900`; the `-` prefix keeps a failed warm from
  crash-looping the endpoint under `Restart=always`. It logs cold=/warm= per
  voice, so the under-a-second second request is proven on every boot.
- Thread law recorded: `tts-server` has no `--threads`; src/backend.h uses
  hardware_concurrency()/2, so a cpuset pin silently changes the thread count.
- Image builds from the pinned commit with the proven line (GGML_BLAS=OFF
  forced by this host, every GPU backend off, -j 8, no -DGGML_NATIVE because
  ggml defaults to -march=native for the building host). Two stages; the runtime
  stage COPYs both the entrypoint and the warmer, because a container that
  publishes nothing is reached with `podman exec`, not a port.
- `scripts/download-frank-tts-engine-qwentts.sh` fetches the two GGUFs with byte
  and LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the
  deployer gates on --verify-only instead of pulling 1.4 GiB mid-deploy.
- Registered in SERVICE_ORDER, DANKCLAW_SERVICES, the deploy-remote.sh dispatch,
  its pre-deploy requirement, deploy.sh's regex, the planner's path map (the
  whole `dankclaw/voiceservice/` tree mapped to nothing before this), and
  test-changed.sh ABOVE the `dankclaw/*.container` case whose `*` crosses `/`.
- New seam tests/frank_tts_engine.bats (19): the Quadlet generator's own output
  for network/publish/boot/warm shape, entrypoint argv driven from the tracked
  env file, the warmer against a stub tts-server over a real socket
  (registration keys, ref_text absent, late engine, hung synthesis, name
  refusal), the fetcher's verifier, the pinned cmake line, and both routing
  tables. Suite: 422 passed.
- Vault: [[Frank TTS Engine]] linked from the index; log.md entry for the day.
This commit is contained in:
Frank
2026-10-18 17:26:50 -07:00
parent e13f875c94
commit 8d76854a1e
15 changed files with 1738 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
# frank-tts-engine — the arm A TTS engine: qwentts.cpp's resident `tts-server`
# (the CPU binary that frank-tts on :7772 forwards to). The runbook is
# [[Frank TTS Engine]]; the fleet-facing endpoint is [[Frank TTS]].
#
# WHAT THIS IS. The upstream source is NOT vendored here: the image clones
# ServeurpersoCom/qwentts.cpp at one pinned commit and builds it. Rebuilding is
# therefore a network operation (github.com plus objects.githubusercontent.com
# for the `ggml` submodule), and build_compose_image in scripts/deploy-remote.sh
# skips it whenever this Containerfile's sha256 label already matches the built
# image — the same offline-safe idiom as loki/alloy/comfyui, so a re-deploy of
# an unchanged engine needs no network.
#
# THE PINNED COMMIT IS THE CONTRACT. 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
# is the commit the 2026-10-18 CPU trial on Dankclaw measured (server-side TTFA
# 94-112 ms, warm RTF 0.55-0.67, run spread +-0.01 — the numbers that won the
# engine decision). Moving it means re-measuring, not just editing this line.
# `ggml` is a submodule of the repo (the only one; vendor/cpp-httplib and
# vendor/yyjson are ordinary directories in the tree), and the submodule commit
# is recorded in the parent tree, so cloning the parent commit recursively is
# reproducible without a second pin.
#
# THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim:
#
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \
# -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \
# -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF \
# -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \
# && cmake --build build -j 8
#
# Every flag is load-bearing, so do not "tidy" it:
# GGML_BLAS=OFF is FORCED, not a preference — Dankclaw has libopenblas.so.0
# but no BLAS dev headers, so the upstream default (BLAS on) fails to
# configure. Upstream's own Dockerfile and buildcpu.sh both pass BLAS on,
# which is why this file does not copy them.
# GGML_CCACHE=OFF because a container build layer has no persistent ccache
# directory to hit; on, it only writes a useless one.
# GGML_OPENMP=ON is what makes the runtime need libgomp1 (installed below).
# GGML_BACKEND_DL=OFF / GGML_CPU_ALL_VARIANTS=OFF: upstream's images build
# every x86 ISA as a loadable backend so one image runs anywhere. This one
# is built on the machine that runs it, so the single native CPU backend is
# what was measured. That is also why no -DGGML_NATIVE is passed: ggml
# defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS
# host — correct while build host == run host, wrong if anyone ever builds
# this image elsewhere, which is why the fleet builds it on Dankclaw.
# CUDA/HIP/VULKAN/METAL/SYCL/RPC all OFF: this is a CPU endpoint (the trial
# ran on CPU; the box's GPUs belong to the text endpoints).
#
# BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so
# tts-server links libggml*.so rather than swallowing them; the runtime stage
# copies the binary and its shared objects out of the build tree and points
# LD_LIBRARY_PATH at them, exactly as upstream's own Dockerfile does (the RPATH
# baked at link time names the build directory, which does not exist here).
#
# BASE PINNED BY DIGEST, like every other image this fleet builds: the digest
# is the multi-arch INDEX of ubuntu:24.04 as resolved from Docker Hub on
# 2026-10-18; Dankclaw takes the amd64 entry. One base for both stages so there
# is exactly one thing to re-resolve.
#
# NO HEALTHCHECK IN THIS FILE. The probe lives in the Quadlet unit
# (HealthCmd=), which is the fleet pattern for anything with a shell — see the
# alertmanager/prometheus units, and the opposite case in dankclaw/loki, whose
# distroless image has no /bin/sh for the generator's CMD-SHELL wrapping.
# `curl` is therefore installed in the runtime stage, for that probe only.
ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac
# ------------------------------------------------------------------ build
FROM ${BASE_IMAGE} AS build
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates cmake g++ git make \
&& rm -rf /var/lib/apt/lists/*
# Checked out detached at a full SHA, then the recorded submodule. No branch
# name anywhere: a branch moves, this commit does not.
ARG QWENTTS_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
RUN git clone --no-checkout https://github.com/ServeurpersoCom/qwentts.cpp /src \
&& git -C /src checkout --detach "${QWENTTS_COMMIT}" \
&& git -C /src submodule update --init --depth 1 \
&& git -C /src rev-parse HEAD \
&& git -C /src submodule status
WORKDIR /src
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DGGML_CCACHE=OFF \
-DGGML_BACKEND_DL=OFF \
-DGGML_CPU_ALL_VARIANTS=OFF \
-DGGML_OPENMP=ON \
-DGGML_BLAS=OFF \
-DGGML_CUDA=OFF \
-DGGML_HIP=OFF \
-DGGML_VULKAN=OFF \
-DGGML_METAL=OFF \
-DGGML_SYCL=OFF \
-DGGML_RPC=OFF \
&& cmake --build build -j 8
# ---------------------------------------------------------------- runtime
FROM ${BASE_IMAGE}
# libgomp1: the OpenMP runtime the build above links against. curl: the unit's
# HealthCmd. ca-certificates: so that curl can speak https if anyone ever
# points a probe at a URL with a scheme this host terminates elsewhere.
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates curl libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
# model directory are host state mounted read-only, and one uid across the pair
# means one thing to remember about what is world-readable.
RUN adduser --system --uid 10000 --home /app --shell /usr/sbin/nologin tts
WORKDIR /app
COPY --from=build /src/build/tts-server /src/build/*.so* /app/
COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh
# The boot warmer lives INSIDE the image because the engine publishes no port:
# the unit reaches it with `podman exec`, and the script needs the same curl,
# base64 and /voices the engine's own namespace has. It is a sibling of
# start.sh, not a second entrypoint — CMD stays the server.
COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh
RUN chmod 755 /app/start.sh /app/warm-voices.sh
# The binary's RPATH points inside the build tree; this is what makes it find
# the libggml*.so copied beside it.
ENV LD_LIBRARY_PATH=/app
USER tts
EXPOSE 8880
CMD ["/app/start.sh"]
@@ -0,0 +1,112 @@
# Quadlet unit: frank-tts-engine (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.container
# Unit name: frank-tts-engine.service (container name: frank-tts-engine)
#
# WHAT IT IS. The arm A TTS engine: qwentts.cpp (pinned commit
# 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4) built into an image by
# dankclaw/voiceservice/Containerfile.engine, running its resident `tts-server`
# with the 1.7 B base talker Q4_K_M and the Q8_0 12 Hz tokenizer loaded ONCE at
# startup. It is deliberately NOT a fleet endpoint: the endpoint consumers
# address is **frank-tts on 7772** ([[Frank TTS]]), which resolves voice names,
# re-encodes to mp3 and owns the timeout budget. Runbook: [[Frank TTS Engine]].
#
# WHY THIS ENGINE (Frank's call, 2026-10-18, criterion "whatever is better for
# live output"): server-side time-to-first-audio 94-112 ms (86-451 ms end to
# end through the façade), streaming that stays ahead of real time at warm RTF
# 0.55-0.67, and a run-to-run spread of +-0.01 — against the mainline CLI path's
# 0.93-0.99 at +-0.25, which crosses 1.0 often enough to stall a live stream.
# The A/B that decided it is "Decision: arm A for live output" in
# [[Local TTS and Voice Cloning]].
#
# IMAGE. Built locally by deploy-remote.sh (build_compose_image), like
# loki/alloy/frank-tts. `Pull=never` plus the `localhost/` prefix are required:
# this host's quadlet path cannot resolve a locally built tag from local storage
# otherwise. The build is a network operation (it clones the pinned commit) and
# is skipped when the Containerfile hash label already matches.
#
# NETWORK, AND WHY IT PUBLISHES NOTHING. The container sits on exactly one
# network: the private `frank-tts-engine.network` (10.89.5.0/24). The façade
# joins the same network and dials `http://frank-tts-engine:8880` — the
# container DNS name, on the network that owns it. There is no `PublishPort` of
# any kind, not even `127.0.0.1:8880`, and the reason is the lesson that 502'd
# hermes: netavark DNATs published ports only, so a host-loopback publish is not
# a path to a peer container — the façade could never have used it, and a bridge
# container cannot reach the host's own loopback bind either (see
# [[Container Network Namespaces]]). A publish would therefore buy exactly one
# thing: an extra reachable surface on an engine that has no authentication, on
# a box whose CPU is also carrying the text endpoints. Diagnostics do not need
# it either — `podman exec frank-tts-engine curl http://127.0.0.1:8880/health`
# speaks to the server from inside its own namespace, which is where the address
# is valid.
#
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident
# (2026-10-18 trial); no memory limit is set because the box has the room and a
# limit would convert a busy moment into an OOM kill mid-utterance. No
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of
# the image, the endpoint is CPU-only by decision, and the GPUs on this host
# belong to the text endpoints.
#
# THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice
# becomes known to the engine only through POST /v1/audio/voices, and the
# registration is MEMORY ONLY — it does not survive a restart, which is exactly
# why the warm hook below runs on every start. That hook runs INSIDE this
# container (see ExecStartPost), so it reads the packs at /voices; `tts-server`
# itself never opens the directory, it receives the reference audio as base64 in
# the registration body. The façade mounts the same host directory read-only for
# its own name list, so one directory stays the single source of a voice name.
#
# COLD-VOICE LAW, THE REASON EXECSTARTPOST EXISTS. The FIRST speech request for
# a registered voice decodes the whole reference clip to snapshot codec state:
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request restores the
# snapshot in 0.5 ms. So the unit warms every voice at boot, serially (~6-8 s
# each), and `TimeoutStartSec=900` is what keeps a growing voice library from
# tripping systemd's default 90 s start deadline. The leading `-` on
# ExecStartPost is load-bearing: with `Restart=always`, a warmer that exits
# non-zero (a pack missing its reference.wav, the engine still loading past the
# warmer's own patience) would restart the whole container and crash-loop the
# endpoint forever. A failed warm must be a journal line, not a restart.
#
# The hook is `podman exec`, not a host script, because there is no address the
# host could dial: this container publishes nothing (above). Exec-ing into it
# also means the warmer inherits this unit's EnvironmentFile, so every knob it
# has lives in scripts/frank-tts-engine.env beside the engine's own, and it sees
# the same /voices and 127.0.0.1:8880 the engine does.
#
# HEALTH. `GET /health` answers `{"status":"ok"}` from the HTTP thread, not the
# synthesis queue, so a busy engine still reads healthy and a dead one does not.
# HealthStartPeriod covers the first model load. The command is argument-only
# and needs /bin/sh — the generator wraps HealthCmd as CMD-SHELL — so the image
# installs curl (see the opposite case in dankclaw/loki, whose distroless base
# has no shell).
[Unit]
Description=frank-tts-engine (arm A qwentts.cpp TTS engine, internal :8880)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=frank-tts-engine
Image=localhost/franksdank/frank-tts-engine:latest
Pull=never
Network=frank-tts-engine.network
# No PublishPort, at all: the only reachable address this server has is the
# bridge address the façade resolves by name. Diagnostics use `podman exec`.
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro
Volume=/home/dank/voices:/voices:ro
HealthCmd=curl -fsS http://127.0.0.1:8880/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=120s
[Service]
Restart=always
RestartSec=10
# Model load (tens of seconds) plus one cold decode per registered voice.
TimeoutStartSec=900
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
[Install]
WantedBy=default.target
@@ -0,0 +1,52 @@
# Quadlet network: the private bridge of the voice pair (rootless).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.network
# Unit name: frank-tts-engine-network.service (podman network name:
# systemd-frank-tts-engine — quadlet prefixes "systemd-", so probe the runtime
# name, never the filename; see [[Container Network Namespaces]]).
#
# WHY A PRIVATE BRIDGE FOR A PAUSE THAT LOOKS PUBLIC-FACING. frank-tts (:7772,
# published, the one endpoint consumers address) and frank-tts-engine (:8880,
# never published, the arm A qwentts.cpp server behind it) could both have
# stayed on the fleet-wide dankclaw_default and resolved each other by name.
# They are split off anyway, for two reasons:
#
# 1. THE HOST-LOOPBACK TRAP. The phase-1 shape in `scripts/frank-tts.env` still
# names `host.containers.internal:8880` as the upstream, on the assumption the
# engine runs on the host and publishes `127.0.0.1:8880`. That cannot work:
# netavark DNATs published ports only, a published port is not on any podman
# bridge, and a container cannot reach the host's own loopback bind — the same
# shape that 502'd hermes, and the reason the signal-relay hop needs a host LAN
# bind instead ([[Container Network Namespaces]], [[Homepage Status Probes]]).
# Putting both containers on one committed network removes the guess: the
# façade dials the engine's container DNS name on the network that owns it, and
# the engine publishes nothing at all.
#
# 2. THE ENGINE HAS NO AUTH AND NO QUEUE LIMIT WORTH THE NAME. `tts-server`
# answers anyone who can open a socket, and at `--max-batch 1` a second
# caller simply waits behind the first. Left on dankclaw_default every
# container on that net — open-webui, hermes, the *arr proxies — could drive
# a CPU synthesiser that occupies every other core on a box that also serves
# two text endpoints. On this bridge the only dialer is frank-tts: the engine
# publishes no port, so not even the host has an address to reach it at, and
# diagnostics go through `podman exec` instead.
#
# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead
# per-container userspace helper left a container that accepted TCP and reset
# every request for two days, logging nothing). A bridge has no such helper.
#
# Subnet registry on dankclaw (rootless):
# 10.88.0.0/16 default "podman" bridge
# 10.89.0.0/24 gitea (systemd-gitea)
# 10.89.1.0/24 compose stack (dankclaw_default)
# 10.89.2.0/24 searxng (systemd-searxng)
# 10.89.3.0/24 open-webui (systemd-open-webui)
# 10.89.4.0/24 open-notebook (systemd-open-notebook)
# 10.89.5.0/24 voice pair (systemd-frank-tts-engine) <- this file
# Pinned so the two container IPs stay deterministic across reboots, like the
# other pinned nets; 10.89.5.0/24 was the next unclaimed /24 in this registry.
[Network]
Driver=bridge
Subnet=10.89.5.0/24
Gateway=10.89.5.1
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# tts-engine-start.sh — the entrypoint of the frank-tts-engine image
# (dankclaw/voiceservice/Containerfile.engine). It exists for one reason: the
# knobs of the arm A engine are command-line flags, while the fleet's knobs
# live in scripts/frank-tts-engine.env and reach the container through
# EnvironmentFile=. This is the translation layer, and nothing else — no
# supervision, no retries, no logging of its own. PID 1 stays the server, so
# `podman stop` and the unit's Restart=always drive it directly.
#
# THE FLAGS THAT ARE NOT HERE, and must stay absent:
#
# --threads / -t does not exist in this build. src/backend.h computes the
# CPU thread count as std::thread::hardware_concurrency()/2
# (the comment there: GEMM shares SIMD units across
# hyperthreads), and there is no flag to override it. So a
# CPU affinity pin (CPUQuota=, taskset, a cpuset) silently
# changes the engine's thread count instead of just its
# placement — pinning is deliberately not done anywhere in
# this stack. See the env file header for the same warning
# aimed at the operator.
# --device left at its default. Every GPU backend is compiled out of
# the image, so the default device pick can only land on
# CPU; naming a device would add a way to be wrong.
#
# Everything defaults to the shape the 2026-10-18 trial measured, so an empty
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with
# the Q8_0 tokenizer, one request at a time, on the private bridge.
set -euo pipefail
# The binary path. Overridable for one reason: the bats seam runs a stub
# instead of a 1.2 GiB model. Operators never set it — the image ships exactly
# one binary at /app/tts-server.
: "${QWENTTS_BIN:=/app/tts-server}"
# Paths are CONTAINER paths (the host directories they come from are named in
# scripts/frank-tts-engine.env and mounted by the Quadlet unit).
: "${QWENTTS_MODEL:=/models/qwen-talker-1.7b-base-Q4_K_M.gguf}"
: "${QWENTTS_CODEC:=/models/qwen-tokenizer-12hz-Q8_0.gguf}"
# Reported by GET /v1/models, and what scripts/frank-tts.env's
# TTS_UPSTREAM_MODEL names. A model id, not a filename: the file name would
# leak a quantization choice into every consumer's config.
: "${QWENTTS_ALIAS:=frank-tts-engine}"
# 0.0.0.0 inside the container is not "public". tts-server defaults to
# 127.0.0.1, which inside a container netns is reachable by nothing — the
# façade dials this container by its bridge address. The container publishes
# 8880 on the host's loopback only (diagnostics and the boot warmer), so the
# only namespace that can reach 0.0.0.0:8880 here is the private voice bridge.
: "${QWENTTS_HOST:=0.0.0.0}"
: "${QWENTTS_PORT:=8880}"
# The language label used when a request omits one. `auto` is the upstream
# default and what the trial ran.
: "${QWENTTS_LANG:=auto}"
# One request at a time. The server-side queue is a serialised FIFO at this
# setting, which is what keeps the measured per-voice timings true; batching
# on a CPU build splits the same cores between two syntheses and the warm RTF
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own
# default is also 1; it is written here because it is a decision, not an
# accident.
: "${QWENTTS_MAX_BATCH:=1}"
# Fail loudly BEFORE binding, so the journal says which artifact is missing
# instead of the unit reporting a container that starts and dies. This is the
# self-healing path: Restart=always re-runs it every RestartSec, and the same
# sentence repeats until the operator fetches the weights
# (scripts/download-frank-tts-engine-qwentts.sh).
for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
if [[ ! -r "$artifact" ]]; then
echo "frank-tts-engine: cannot read ${artifact}; run scripts/download-frank-tts-engine-qwentts.sh on the host (the /models mount is read-only and holds the two GGUF files)" >&2
exit 1
fi
done
exec "$QWENTTS_BIN" \
--model "$QWENTTS_MODEL" \
--codec "$QWENTTS_CODEC" \
--alias "$QWENTTS_ALIAS" \
--host "$QWENTTS_HOST" \
--port "$QWENTTS_PORT" \
--lang "$QWENTTS_LANG" \
--max-batch "$QWENTTS_MAX_BATCH"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# tts-engine-warm.sh — the boot warmer of the arm A TTS engine, shipped inside
# the frank-tts-engine image at /app/warm-voices.sh
# (dankclaw/voiceservice/Containerfile.engine).
#
# WHO RUNS IT. Two callers, same binary:
#
# 1. The Quadlet unit, as
# `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh`
# — after every start of the container, including every boot.
# `podman exec` rather than a host script because the engine publishes no
# port at all, so there is no address the host could dial; and because an
# exec inherits the unit's EnvironmentFile, which is what puts every knob of
# this script in scripts/frank-tts-engine.env next to the engine's own.
# 2. scripts/deploy-remote.sh's deploy_frank_tts_engine, once per deploy, so a
# voice that did not warm becomes a deploy result instead of only a journal
# line (the unit's `-` prefix deliberately cannot fail the unit).
#
# WHY IT EXISTS AT ALL (the cold-voice law). The FIRST speech request for a
# registered voice decodes the whole reference clip to snapshot its codec state:
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
# and re-registering a voice does not warm it — only synthesizing does. So a boot
# that registers the voices and stops leaves the first caller of the day paying
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
# speaks a second one so the journal carries the warm number too: the line
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
# request of a voice answers well under a second.
#
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
# the unit, the same packs frank-tts mounts for its own name list. A pack is a
# directory that contains reference.wav; everything else in it (manifest.json, the
# engine's reference.spk / reference.rvq extraction, a 16 kHz variant) is not read
# here. The registration payload is `{"name": <dir>, "wav_b64": <base64 of
# reference.wav>}` with NO ref_text, which selects the embedding-only (x-vector)
# voice — the shipped default, warm RTF ~0.57-0.63 against ICL's 0.67, and the one
# this fleet can produce honestly because it has no trustworthy transcript for the
# audition crops. Adding ref_text (with the codes) would flip that voice to
# in-context-learning mode: better fidelity on a good transcript, slower, and only
# worth it for batch work like a podcast render. That is a per-voice opt-in this
# script deliberately does NOT exercise, and the door stays open: a pack that
# wants ICL needs its text in the pack plus one key in the payload below.
#
# A lone `spk_b64` is a 400 (src/tts-server.h:480 — it is `wav_b64` or BOTH
# `spk_b64` and `rvq_b64`), which is why the WAV travels and the .spk stays home.
#
# NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes
# are discarded, the synthesized text comes from the env file, and the reference
# clip is base64'd straight into the request body without ever being echoed.
set -euo pipefail
# The engine's own address, from inside its namespace. Loopback is valid HERE:
# this script runs in the container, next to the server. There is deliberately no
# host-visible address to use instead — the unit publishes nothing, because a
# host-loopback publish would be unreachable from the façade anyway (netavark
# DNATs published ports only) while adding a surface on an unauthenticated
# server.
: "${ENGINE_URL:=http://127.0.0.1:8880}"
# Container path; the host directory behind it is named in the Quadlet unit.
: "${VOICES_DIR:=/voices}"
# How long to wait for the engine to answer /health. Model load dominates: the
# two GGUFs (1.2 GiB + 291 MB) are read and the context built before the HTTP
# thread binds, and a boot races every other unit on this box.
: "${HEALTH_WAIT:=300}"
# One short sentence, long enough to exercise the codec snapshot and short enough
# that warming a library of voices is seconds, not minutes. Never varied per run:
# the cold/warm numbers in the journal should mean the same thing every boot. The
# synthesis asks for `wav`, the buffered path — the streamed path (`pcm`) is
# different audio at the same seed, and `wav` is also what frank-tts asks for.
: "${WARM_TEXT:=Warm up, one two three.}"
# Per-request ceiling. A cold voice is 6-8 s by measurement; anything approaching
# this is a stalled engine and should be reported rather than waited on forever —
# systemd counts the whole warm pass against the unit's start deadline.
: "${REQUEST_TIMEOUT:=120}"
fail=0
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
# Wait for /health. It answers from the HTTP thread, so a 200 means the server is
# listening; it says nothing about voices, which is what the rest of this script
# is for.
ready=0
for ((i = 1; i <= HEALTH_WAIT; i++)); do
if curl -fsS --max-time 5 "${ENGINE_URL}/health" >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if (( ready == 0 )); then
log "engine did not answer ${ENGINE_URL}/health within ${HEALTH_WAIT}s; nothing warmed"
exit 1
fi
if [[ ! -d "$VOICES_DIR" ]]; then
log "no voice directory at ${VOICES_DIR}; nothing to warm (frank-tts will answer 400 with an empty voice list until packs are built)"
exit 0
fi
warmed=0
for dir in "$VOICES_DIR"/*/; do
[[ -d "$dir" ]] || continue
name="${dir%/}"
name="${name##*/}"
if [[ ! -f "${dir}reference.wav" ]]; then
continue # not a pack; frank-tts refuses this name too
fi
# The engine lowercases every voice name (tts_voice_name) while frank-tts
# matches the directory name exactly, so a mixed-case pack would work by
# accident on one side and not the other. Refuse it loudly instead. Pack
# names are also JSON string content here, so the character class is what
# keeps a directory name from writing structure into the request body.
if [[ ! "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
log "voice '${name}': name must be lowercase [a-z0-9._-] (the engine lowercases names; the shim does not) — skipped"
fail=1
continue
fi
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "$body" \
"${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
fail=1
continue
fi
# The cold pass. Its output is discarded; the point is the codec snapshot it
# leaves inside the engine.
start=$(date +%s%3N)
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
-H 'Content-Type: application/json' \
--data-binary "{\"input\":\"${WARM_TEXT}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
"${ENGINE_URL}/v1/audio/speech"; then
log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold"
fail=1
continue
fi
cold=$(( $(date +%s%3N) - start ))
# The warm pass, same request. This is the number the fleet actually
# experiences, and it is what "the second request answers under a second" is
# measured against.
start=$(date +%s%3N)
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
-H 'Content-Type: application/json' \
--data-binary "{\"input\":\"${WARM_TEXT}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
"${ENGINE_URL}/v1/audio/speech"; then
log "voice '${name}': warm synthesis failed after a cold one that worked"
fail=1
continue
fi
warm=$(( $(date +%s%3N) - start ))
log "voice ${name} cold=${cold}ms warm=${warm}ms"
warmed=$(( warmed + 1 ))
done
# Failure first, emptiness second, and not the other way round: a pass where
# every voice failed also has `warmed == 0`, and reading that as "nothing to do,
# success" is how an endpoint that answers 400 for every voice name gets deployed
# on a Friday.
if (( fail )); then
log "${warmed} voice(s) warm, at least one failed (see the lines above)"
exit 1
fi
if (( warmed == 0 )); then
log "no voice with a reference.wav under ${VOICES_DIR}; engine is up but speaks nothing"
exit 0
fi
log "${warmed} voice(s) registered and warm"