feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found #14
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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/` |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user