feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found #14

Merged
dank merged 10 commits from feat/frank-tts-engine into main 2026-10-18 18:30:22 -07:00
12 changed files with 482 additions and 33 deletions
Showing only changes of commit 8fefe6e1a9 - Show all commits
@@ -9,9 +9,9 @@
# /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.
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to the
# OpenAI-shaped engine (`frank-tts-engine`, [[Frank TTS Engine]]) 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
@@ -19,16 +19,26 @@
# 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).
# NETWORK. TWO attached networks, and the order is the one the open-notebook
# pair uses: `dankclaw_default` first (the default route, and where consumers
# live — they reach the published 7772, and open-notebook dials
# `http://frank-tts:7772/v1` by container name), then the voice pair's private
# bridge, `frank-tts-engine.network`, where the arm A engine lives. The shim's
# upstream is therefore the engine's container DNS name
# (`http://frank-tts-engine:8880`, scripts/frank-tts.env) and NOT
# host.containers.internal: netavark DNATs published ports only, so a bridge
# container cannot reach a host-loopback bind — the shape that 502'd hermes —
# and the engine publishes nothing at all. That file MUST be installed beside
# this unit (the engine's handler does it; the façade handler re-installs it for
# `--only frank-tts`), because with a referenced Network= file missing the v1
# generator refuses to produce this container unit at all.
#
# ORDERING. Wants+After on frank-tts-engine.service, not Requires: the façade
# has a second backend (`TTS_BACKEND=llama-tts-cli`) that needs no upstream, and
# an engine that will not start must not take this endpoint's unit down with it.
# When both boot, this comes up after the engine, whose ExecStartPost warm pass
# is part of its start — so the first request of the day meets a warm voice
# registry instead of paying the 5.5 s cold decode.
#
# 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
@@ -41,14 +51,16 @@
# carries them deliberately.
[Unit]
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
After=network-online.target
After=network-online.target frank-tts-engine.service
Wants=network-online.target
Wants=frank-tts-engine.service
[Container]
ContainerName=frank-tts
Image=localhost/franksdank/frank-tts:latest
Pull=never
Network=dankclaw_default
Network=frank-tts-engine.network
PublishPort=7772:7772
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
Volume=/home/dank/voices:/voices:ro
+11
View File
@@ -66,6 +66,7 @@ SERVICE_ORDER = (
# a dependency of the 7772 facade, so it is planned (and therefore
# restarted) first.
"frank-tts-engine",
"frank-tts",
"frank-dragoon",
"frank-zealot",
"frank-archon",
@@ -102,6 +103,7 @@ DANKCLAW_SERVICES = {
"frank-dragoon",
"frank-zealot",
"frank-tts-engine",
"frank-tts",
}
DANKSERVER_SERVICES = {
@@ -342,6 +344,15 @@ def services_for_path(path: str, target: str) -> set[str]:
"Containerfile.engine", "tts-engine-start.sh",
"tts-engine-warm.sh"}:
_add(services, "frank-tts-engine")
# The 7772 façade is the other deployable in this directory. The
# bridge file is named after the ENGINE unit (install_quadlet copies
# `$unit.network` beside `$unit.container`), yet both units attach
# to it, so a subnet change is a change to both. Anything else here
# belongs to neither deployable and plans nothing.
if parts[-1] in {"frank-tts.container", "app.py", "Containerfile"}:
_add(services, "frank-tts")
if parts[-1] == "frank-tts-engine.network":
_add(services, "frank-tts")
return services
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
# build, root unit): its files live in scripts/ and map there.
+47 -1
View File
@@ -92,7 +92,7 @@ service_is_allowed() {
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
dankclaw:open-notebook|\
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
dankclaw:frank-tts-engine|\
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
@@ -550,6 +550,14 @@ build_compose_image() {
image="franksdank/$service:latest"
containerfile="dankclaw/$service/Containerfile"
;;
frank-tts)
# The 7772 façade: stdlib python + ffmpeg, app.py copied in. Listed
# explicitly because build_compose_image's `*)` arm returns 0 — an
# unlisted service builds nothing and the unit's Pull=never then
# fails on a tag that was never made.
image=franksdank/frank-tts:latest
containerfile=dankclaw/voiceservice/Containerfile
;;
frank-tts-engine)
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
# source at a pinned upstream commit, so the build needs network
@@ -801,6 +809,43 @@ deploy_frank_tts_engine() {
podman exec frank-tts-engine /app/warm-voices.sh
}
deploy_frank_tts() {
# The 7772 façade in front of frank-tts-engine. Weights and the engine build
# are not this handler's business, but the BRIDGE FILE is: this unit
# references frank-tts-engine.network, and with a referenced Network= file
# absent the v1 generator refuses to produce the container unit at all. So
# `--only frank-tts` installs the engine's quadlet pair too — idempotent, and
# it is what makes the façade deployable on a host where the engine was
# planned separately or not at all.
#
# The voice packs are host state (the pack builder writes them), but the
# directory is created here as the deploy user on purpose: rootless podman
# would otherwise auto-create a missing bind source as root-owned, and the
# container runs as uid 10000.
mkdir -p /home/dank/voices
build_compose_image frank-tts
install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine
install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts
systemctl --user start frank-tts-engine-network.service
# No `systemctl --user enable` (generated Quadlet units cannot be enabled on
# this host); boot persistence is the [Install] section in each unit. The
# façade unit carries Wants=/After= on frank-tts-engine.service, so this
# restart pulls the engine — and its ExecStartPost warm pass — ahead of it:
# an engine that will not start delays this gate instead of taking the
# endpoint down with it.
systemctl --user restart frank-tts.service
# The gate is podman's recorded health, driven by the unit's own HealthCmd
# (`wget --spider -q http://127.0.0.1:7772/health`, inside the container's
# namespace). /health answers 200 with the backend name and the resolved
# voice list, so an empty /voices still reads healthy while a dead app does
# not — and note what this gate cannot see: the engine publishes no port on
# purpose, so an engine that is down shows up in the /health payload and in
# the first synthesis, never here.
wait_container_healthy frank-tts 120
}
deploy_comfyui() {
# Build first: the unit's Image= resolves against local storage, so the
# image has to exist before the generator starts a container from it.
@@ -1748,6 +1793,7 @@ deploy_service() {
frank-zealot) deploy_llm_zealot ;;
frank-archon) deploy_llm_archon ;;
frank-tts-engine) deploy_frank_tts_engine ;;
frank-tts) deploy_frank_tts ;;
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
jellyfin) deploy_jellyfin ;;
+1 -1
View File
@@ -142,7 +142,7 @@ valid_service() {
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
dankclaw:open-notebook|\
dankclaw:frank-tts-engine|\
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
+17 -8
View File
@@ -30,21 +30,30 @@
#
# 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.
# omni-http forward to an OpenAI-shaped upstream, 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. The
# shipped engine (frank-tts-engine) speaks a voice it has
# REGISTERED on itself, from the same /home/dank/voices packs,
# and warms them at boot — see [[Frank TTS Engine]] for why an
# unwarmed voice costs the first caller 5.5 s.
# 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.
# THE UPSTREAM ADDRESS. The engine is a container on the private bridge this
# unit also joins (dankclaw/voiceservice/quadlet/frank-tts-engine.network), so
# this is its container DNS name. It used to read
# `http://host.containers.internal:8880`, which is a shape that cannot work: a
# bridge container reaches the host's network namespace, netavark DNATs PUBLISHED
# ports only, and an engine on the host's loopback is published to nobody
# ([[Container Network Namespaces]] — the trap that 502'd hermes). Inert under
# TTS_BACKEND=llama-tts-cli, which needs no upstream at all.
VOICES_DIR=/voices
TTS_BACKEND=omni-http
TTS_UPSTREAM=http://host.containers.internal:8880
TTS_UPSTREAM=http://frank-tts-engine:8880
TTS_UPSTREAM_MODEL=voxcpm2
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
@@ -81,7 +81,7 @@ The packs themselves are host state at `/home/dank/voices/<name>/reference.wav`
## Status visibility
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). Its up-state is visible in four places, and the runbook uses all of them:
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). The pair's public signal is the endpoint in front of it: [[Frank TTS]] carries the card and is what a consumer can dial. Its up-state is visible in four places, and the runbook uses all of them:
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
+151
View File
@@ -0,0 +1,151 @@
---
type: reference
description: "frank-tts is the fleet's cloned-voice speech endpoint on Dankclaw :7772 — the one voice surface consumers address, in front of frank-tts-engine; repo-owned, not yet deployed."
tags:
- dankclaw
- tts
- voice
- quadlet
---
# Frank TTS
`frank-tts` is the fleet's only speech endpoint: an OpenAI-shaped
`POST /v1/audio/speech` on **Dankclaw :7772** that answers `voice=<name>` with
audio in that cloned voice. Everything else in the voice stack sits behind it —
the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers
never learn a path.
**Nothing here is deployed.** No host was touched while this was written: no
image built, no container started, nothing listening on 7772. That is also why
[[Open Notebook]] cannot render a podcast episode yet — its pipeline calls this
endpoint.
| | |
| --- | --- |
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
| Port | **7772**, published (`PublishPort=7772:7772`); the family is 7770 dragoon, 7771 zealot, 7772 tts |
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
| Upstream | `http://frank-tts-engine:8880` (`scripts/frank-tts.env`, `TTS_UPSTREAM`) |
| Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
## Naming a voice
A voice **is** a directory name. `voice=narrator1` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
shim accepts either.
The usable shape is `^[a-z0-9][a-z0-9._-]*$`: lowercase, starting with a letter
or digit, then letters, digits, `.`, `_`, `-`, at most 64 characters. No slash,
no `..`, no space, no uppercase. The lowercase requirement is where two rules
meet: `app.py` would accept `[A-Za-z0-9…]`, but `tts-server` **lowercases
names**, so a pack called `Narrator1` resolves through the façade and not
through the engine. The boot warmer therefore refuses a mixed-case directory
loudly instead of letting it half-work.
Names are user-visible: an unknown voice comes back as
`400 unknown voice 'x'; available voices: …`, and that list is the honest
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
[[Open Notebook]] is configured with exactly those three, so renaming them
breaks episode rendering, not just speech. A personal voice gets the person's
name, lowercase.
## Voice packs
One directory per voice under `/home/dank/voices`:
```
reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio
reference.spk engine speaker embedding (8 192 B)
reference.rvq reference codec codes (22 B per 12.5 Hz frame)
manifest.json source file, offsets, engine, sha256
```
`reference.wav` is the engine-agnostic term the façade gates on; the other two
are what makes a voice reusable by this engine, and the pack builder produces
them — never by hand, never fabricated from the WAV. Two laws apply to the
source material: offsets always start **past the first 10 seconds** of the
sample, and packs are host state that stays out of this repo. Because the
container runs as uid 10000, files must be world-readable (`644`/`755`), and the
directory must exist before the first deploy: rootless podman auto-creates a
missing bind source **root-owned**, which reads as "no voices" rather than as an
error.
Adding a voice to a running stack is a two-step thing — drop the pack, then
register **and warm** it with
`podman exec frank-tts-engine /app/warm-voices.sh` — because registering alone
does not warm it. The reason is the cold-voice law in [[Frank TTS Engine]], and
the procedure with its proof (the second request under a second) is the
`frank-tts-add-cloned-voice` skill.
## Why the upstream is a container name
`scripts/frank-tts.env` used to point at `http://host.containers.internal:8880`.
That cannot work, and it is the trap this shape exists to avoid: a bridge
container reaches the host's network namespace, netavark DNATs **published**
ports only, and an engine on the host's loopback is published to nobody — the
same shape that 502'd hermes ([[Container Network Namespaces]]). So both units
attach to one committed Quadlet network and the façade dials the engine's
container DNS name.
The consequence worth stating before an operator meets it: **the engine is not
reachable from the host at all** — not over the LAN, not over loopback. Its
health, voice registry and logs are observable only through
`podman exec frank-tts-engine …` and `systemctl --user status`. And the façade's
own `/health` answers 200 with the backend name and the voice list it resolved
without dialing the engine, so "façade healthy, engine down" is a real state: it
shows up as a failed synthesis, never as a down probe.
## Files
| Path | What |
| --- | --- |
| `dankclaw/voiceservice/app.py` | the shim: stdlib only — resolves the voice, forwards, re-encodes mp3 |
| `dankclaw/voiceservice/Containerfile` | python 3.12-alpine pinned by digest + ffmpeg, uid 10000 |
| `dankclaw/voiceservice/quadlet/frank-tts.container` | the unit: two networks, `Restart=always`, `HealthCmd`, `[Install]` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the shared bridge, named after the engine unit and attached by both |
| `scripts/frank-tts.env` | backend, upstream, port, limits — paths and numbers, no credentials |
| `tests/frank_tts_unit.bats` | the shim against stub engines over real sockets |
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer |
## Deploying it
`scripts/deploy-remote.sh dankclaw frank-tts` (or `frank-tts-engine frank-tts`):
builds the façade image, installs **both** Quadlet units — including the
engine's `.network` file, because a referenced `Network=` file the generator
cannot find means the façade unit is not produced at all — creates
`/home/dank/voices` as the deploy user, restarts the unit (whose `Wants=` and
`After=` pull the engine and its warm pass ahead of it) and gates on podman's
recorded health. It never runs `systemctl --user enable`; boot persistence is
the `[Install]` section in each unit. Order matters in a full deploy:
[[Frank TTS Engine]] is planned before this, and both before [[Open Notebook]].
## Not scraped, and why
`shared/prometheus/` gets **no** frank-tts job, and that is a considered
absence. The façade exports no `/metrics` — `app.py` serves `/health` plus the
speech route only — and this Prometheus 3 install refuses non-metrics content
types: [[Prometheus]] records that the existing HTML probes (`comfyui`, the two
`services` targets) sit **down** with
`received unsupported Content-Type … and no fallback_scrape_protocol specified`.
A `/health` probe job would therefore be structurally never-green, and because
`LlmEndpointDown` is scoped `up{job=~"frank-.*"} == 0`, a permanently red
`frank-tts` target would page on Signal every few minutes for an endpoint that
is fine — the frank-archon lesson pointed the other way. The engine cannot be
scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
`podman healthcheck run`, the two unit journals, and the homepage card, which is
gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| `400 unknown voice 'x'; available voices: …` | the name is not a pack: case, spelling, missing `reference.wav`, or an empty/root-owned `/home/dank/voices` |
| `(none: no voice pack with a reference.wav in /voices)` | the mount, not the voices: `podman inspect frank-tts --format '{{json .Mounts}}'`, then the directory owner |
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
| First request of the day takes ~5.5 s | the warm pass did not run — cold-voice law in [[Frank TTS Engine]] |
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
+1
View File
@@ -81,6 +81,7 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
- [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; repo-owned config, not deployed
- [[Frank TTS]] — the 7772 cloned-voice endpoint consumers address: voice naming, pack layout, why the upstream is a container name; repo-owned config, not deployed
## Infrastructure
+46
View File
@@ -2490,3 +2490,49 @@ every restart since the cutover.
nothing and exposes no metrics, so there is nothing to probe, which is said out
loud in the page rather than papered over. No ICL/`ref_text` work: the packs
keep `reference.spk`/`reference.rvq` so it stays a per-voice opt-in.
## 2026-10-18 — frank-tts: the 7772 endpoint gets its deploy wiring, and voice naming is written down
- Stage 2 of the voice stack: the **façade** in front of the engine is now a
deployable service too. Nothing deployed, no host touched. Runbook:
[[Frank TTS]].
- **Two attached networks, in a meaningful order.** `dankclaw_default` first — it
owns the default route and is where consumers live (open-notebook dials
`http://frank-tts:7772/v1` by container name) — then the voice pair's private
bridge. `scripts/frank-tts.env`'s `TTS_UPSTREAM` moves from
`host.containers.internal:8880` to `http://frank-tts-engine:8880`, which is the
actual fix for a shape that could never have worked: a bridge container cannot
reach a host-loopback bind, and the engine publishes nothing.
- **`Wants=` + `After=` on the engine, never `Requires=`:** the façade still
carries the `llama-tts-cli` rollback backend, which needs no upstream, so an
engine that will not boot must delay this endpoint, not take it down.
- The façade handler installs the **engine's** quadlet pair as well. Not tidiness:
with a referenced `Network=` file absent, the v1 Quadlet generator produces no
container unit at all, and `--only frank-tts` has no other way to obtain it. It
also `mkdir -p /home/dank/voices` as the deploy user, because rootless podman
auto-creates a missing bind source **root-owned** and the endpoint then reports
"no voices" instead of an error.
- **A voice is a lowercase directory name.** `^[a-z0-9][a-z0-9._-]*$`, ≤ 64 chars,
no slash/`..`/space. The shape is the intersection of two rules that do not
agree: `app.py` would accept mixed case, `tts-server` lowercases names — so a
pack named `Narrator1` resolves through the façade and not through the engine,
and the boot warmer refuses such a directory loudly. `narrator1/2/3` are
load-bearing for open-notebook, and `frank-tts-add-cloned-voice` is the skill
that walks the pack → register → warm → prove-a-second-request flow.
- Registered everywhere the open-notebook entry named as law (planner order puts
the engine before the façade, so a warm registry precedes the first synthesis),
plus the homepage card — `wip: down` and hidden, because nothing listens on
7772 yet.
- **No Prometheus job, on purpose.** The façade exports no `/metrics`, Prometheus 3
refuses HTML/JSON content types (the `comfyui` and `services` probes are
documented down for exactly that), and `LlmEndpointDown` is scoped
`up{job=~"frank-.*"}` — so a `/health` scrape would page on Signal every few
minutes for a healthy endpoint. That is the frank-archon never-green lesson
pointed the other way, and it is written in the page instead of being quietly
ignored.
- Tests added to the voice seam: the generator's own rendering of the pair (both
attachments, first-network order, one publish, Wants-not-Requires), the
env-file upstream checked as a **network join** rather than a string
comparison, planner separation and order, the façade handler's steps, the
homepage card's gating, and routing that selects both seams for either
deployable's paths.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#101a12" stroke="#7ee787" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#7ee787" text-anchor="middle">FT</text></svg>

After

Width:  |  Height:  |  Size: 293 B

+13
View File
@@ -536,5 +536,18 @@
"wip": false,
"showLink": true,
"art": "/img/apps/mono-cu.svg"
},
{
"name": "frank-tts",
"category": "admin",
"section": "AI",
"description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Public on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. Hidden until deployed.",
"url": "",
"docs": "",
"probe": "127.0.0.1:7772",
"method": "http",
"wip": "down",
"showLink": false,
"art": "/img/apps/mono-ft.svg"
}
]
+167 -8
View File
@@ -20,6 +20,10 @@ load helpers
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
# The façade this engine serves: its wiring is tested here because the bridge
# both units attach to is what makes the pair one deployable shape.
FACADE="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
FACADE_ENV="$REPO_ROOT/scripts/frank-tts.env"
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
@@ -184,19 +188,34 @@ quadlet_generator() {
return 1
}
# render_units — run the generator in a throwaway HOME over a copy of the two
# source files and print everything it emits. --dryrun prints instead of
# writing, so this neither contacts the podman daemon nor creates a network.
# render_units — run the generator in a throwaway HOME over a copy of the voice
# stack's source files and print everything it emits. Both units are rendered
# together on purpose: the façade references the engine's .network, and a
# referenced file that is not in the directory makes the generator drop that
# container unit outright — which is the failure this seam must be able to see.
# --dryrun prints instead of writing, so this neither contacts the podman daemon
# nor creates a network.
render_units() {
local gen home
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
home="$TEST_TMP/fakehome"
mkdir -p "$home/.config/containers/systemd"
cp "$NETF" "$UNIT" "$home/.config/containers/systemd/"
cp "$NETF" "$UNIT" "$FACADE" "$home/.config/containers/systemd/"
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
cat "$TEST_TMP/generated.txt"
}
# unit_block <name> — one unit's block from the last render_units output. The
# end of the block is the next `---` marker: two units are rendered now, and a
# slice that ran to the end of the output would read the façade's RestartSec
# into the engine's assertions, which is exactly how a generator test starts
# asserting the wrong container.
unit_block() {
awk -v u="$1" 'index($0, "---" u "---") == 1 { f = 1; next }
/^---/ { f = 0 }
f' "$TEST_TMP/generated.txt"
}
# free_port — an address with nothing bound to it, for the tests that need the
# warmer to be dialing before a listener exists.
free_port() {
@@ -251,7 +270,7 @@ s.close()'
# Boot persistence and self-healing, as the generator actually emits them —
# and the warm hook: ExecStartPost must carry the `-` prefix, because
# Restart=always plus a failing warmer is a crash loop on the endpoint.
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
unit="$(unit_block frank-tts-engine.service)"
printf '%s' "$unit" | grep -qE '^Restart=always$'
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
@@ -266,12 +285,12 @@ s.close()'
@test "the generator renders the bridge creation command with the pinned subnet" {
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
net="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine-network.service---$/,$p')"
net="$(unit_block frank-tts-engine-network.service)"
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
# The container unit must be tied to the network unit, not merely mention it:
# a referenced .network that the generator cannot find kills the container
# unit outright, and an untied one starts the container before the bridge.
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
unit="$(unit_block frank-tts-engine.service)"
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
}
@@ -562,9 +581,11 @@ PY
}
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
# Everything named after the engine, EXCEPT the bridge file: both units
# attach to it, so a subnet change is a change to two deployables and that
# pairing is asserted in the façade's planner test below.
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
dankclaw/voiceservice/quadlet/frank-tts-engine.network \
dankclaw/voiceservice/Containerfile.engine \
dankclaw/voiceservice/tts-engine-start.sh \
scripts/frank-tts-engine.env \
@@ -654,3 +675,141 @@ PY
fi
done
}
@test "the generator puts the façade on both bridges and behind the engine" {
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts ')"
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
# Two attachments, and the fleet-wide bridge FIRST: that is the network
# consumers address (open-notebook dials `http://frank-tts:7772/v1` by
# container name), so it owns the default route. The engine's private bridge
# is second and exists so the shim can resolve the engine at all.
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | head -1)" = "--network dankclaw_default" ]
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
# Published exactly once, on the fleet port — and this is the only publish in
# the voice stack.
[ "$(printf '%s' "$line" | grep -oE -- '--publish [^ ]+' | wc -l)" = "1" ]
printf '%s' "$line" | grep -qE -- '--publish (\[::\]:)?7772:7772( |$)'
unit="$(unit_block frank-tts.service)"
printf '%s' "$unit" | grep -qE '^Wants=frank-tts-engine\.service$'
printf '%s' "$unit" | grep -qE '^After=.*frank-tts-engine\.service'
# Wants, never Requires: the CLI rollback backend needs no upstream, and an
# engine that will not start must not take this endpoint's unit down.
! printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine\.service$'
# And it is tied to the bridge as well, or the container would be started
# before netavark had created the network it dials over.
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
}
@test "the upstream the env file names is only resolvable on a network the unit joins" {
# The pair's one real wiring contract, checked as a join rather than as a
# string: take the hostname out of TTS_UPSTREAM the way the container will
# get it, and require the façade unit to attach to the Quadlet network file
# named after that hostname. A revert to host.containers.internal, or a unit
# that drops the second Network= line, breaks here — both mistakes leave the
# endpoint answering and every synthesis failing.
set -a
# shellcheck disable=SC1090
source "$FACADE_ENV"
set +a
host="${TTS_UPSTREAM#*://}"
host="${host%%:*}"
host="${host%%/*}"
[ -n "$host" ] || { echo "TTS_UPSTREAM names no host: $TTS_UPSTREAM"; false; }
[ "$host" != localhost ] || { echo "localhost is not reachable across netns"; false; }
if ! grep -qE "^Network=${host}\.network$" "$FACADE"; then
echo "frank-tts.container does not attach to ${host}.network"
false
fi
if ! [ -f "$REPO_ROOT/dankclaw/voiceservice/quadlet/${host}.network" ]; then
echo "no Quadlet network file for $host — the generator would drop the unit"
false
fi
}
@test "the planner keeps the two voice deployables separate and ordered" {
# `--only` on the façade must not drag the engine's rebuild along, and a
# bridge change must restart both: the network file is named after the engine
# unit only because install_quadlet copies `$unit.network` beside the unit.
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/app.py
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/Containerfile
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts.container
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container
[ "$output" = "frank-tts-engine" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.network
printf '%s\n' "$output" | grep -qx frank-tts
printf '%s\n' "$output" | grep -qx frank-tts-engine
# Order: dependency before consumer, so a full dankclaw deploy starts a warm
# engine and then the endpoint that forwards into it.
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts.container \
dankclaw/voiceservice/quadlet/frank-tts-engine.container
local e f
e="$(printf '%s\n' "$output" | grep -n '^frank-tts-engine$' | cut -d: -f1)"
f="$(printf '%s\n' "$output" | grep -n '^frank-tts$' | cut -d: -f1)"
[ -n "$e" ] && [ -n "$f" ] && [ "$e" -lt "$f" ]
}
@test "the façade handler builds its image, installs the bridge file, and gates on recorded health" {
grep -qF 'dankclaw:frank-tts|' "$REMOTE"
grep -qF 'dankclaw:frank-tts|' "$REPO_ROOT/scripts/deploy.sh"
grep -qF 'frank-tts) deploy_frank_tts ;;' "$REMOTE"
# build_compose_image's default arm returns 0 without building: an unlisted
# service means a unit whose Pull=never image was never made.
grep -qE '^\s+containerfile=dankclaw/voiceservice/Containerfile$' "$REMOTE"
handler="$(sed -n '/^deploy_frank_tts()/,/^}/p' "$REMOTE")"
[ -n "$handler" ] || { echo "no deploy_frank_tts handler"; false; }
printf '%s' "$handler" | grep -qF 'build_compose_image frank-tts'
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts'
# The engine's quadlet pair is installed by this handler too: with the
# referenced .network file missing the generator produces no façade unit at
# all, and `--only frank-tts` has no other way to get it onto the host.
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine'
printf '%s' "$handler" | grep -qE 'mkdir -p /home/dank/voices'
printf '%s' "$handler" | grep -qE 'systemctl --user restart frank-tts\.service'
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts [0-9]+'
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
}
@test "the voice stack's homepage state: one card for the pair, and it is hidden while undeployed" {
# The engine is not on the homepage and must not be: it publishes no port, so
# there is no address the checker could dial, and a card that can never be
# green is worse than no card. The façade carries the pair's signal.
python3 - "$REPO_ROOT" <<'PY'
import json, os, sys
root = sys.argv[1]
cards = {c["name"]: c for c in json.load(open(f"{root}/shared/homepage/services.json"))}
assert "frank-tts-engine" not in cards, \
"the engine publishes no port: a card for it could never be probed green"
fts = cards["frank-tts"]
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
assert fts["wip"] == "down" and fts["showLink"] is False, \
"nothing listens on 7772 until the deploy, so a visible card reads as down forever"
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
PY
}
@test "either deployable's paths select both voice seams" {
# A change to the bridge or the env file can break the other half of the
# pair, and the seams are cheap; routing them together is what keeps a
# façade-only commit from landing without the engine's wiring tests.
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankclaw/voiceservice/quadlet/frank-tts.container
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/voiceservice/app.py
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
}