feat(frank-tts): deploy wiring for the 7772 voice endpoint, and voice naming written down
Stage 2 of the voice stack. Repo state only: nothing deployed, no host touched, no container started. - `frank-tts` is now registered everywhere a dankclaw service has to be: `SERVICE_ORDER` (after `frank-tts-engine`, before the consumers), the planner's dankclaw set, `deploy-remote.sh`'s allowlist + dispatch + a `deploy_frank_tts` handler, and `deploy.sh`'s dispatch regex — plus the `build_compose_image` arm naming `dankclaw/voiceservice/Containerfile`, without which the build silently returns 0 and the unit's `Pull=never` tag never exists. - The unit joins TWO networks, in order: `dankclaw_default` first (default route, where consumers and open-notebook live), then the voice bridge. `TTS_UPSTREAM` moves from `host.containers.internal:8880` to `http://frank-tts-engine:8880` — the actual fix for a shape that could never work, since a bridge container cannot reach a host-loopback bind and the engine publishes nothing at all. - `Wants=`/`After=` on the engine, never `Requires=`: the `llama-tts-cli` rollback backend needs no upstream, so a broken engine may delay this endpoint but must not take its unit down. - The handler installs the engine's quadlet pair too, because a referenced `Network=` file the generator cannot find means no façade unit is produced at all, and `--only frank-tts` has no other way to get it. It creates `/home/dank/voices` as the deploy user (rootless podman would auto-create a missing bind source root-owned, which reads as "no voices"), and gates on podman's recorded health. - Voice naming is now written down where it can be found: a voice is a lowercase directory name matching `^[a-z0-9][a-z0-9._-]*$`, at most 64 chars. That shape is the intersection of two rules that disagree — the shim would accept mixed case, the engine lowercases names — so a mixed-case pack resolves on one side only, and the boot warmer refuses it loudly. `narrator1/2/3` are load-bearing for open-notebook. - Homepage card for the pair, `wip: down` and hidden, probing `127.0.0.1:7772`; the engine gets no card because it publishes nothing and could never be probed. - **No Prometheus job, deliberately.** No `/metrics` exists on the shim, this Prometheus 3 refuses HTML/JSON bodies (the existing `comfyui` and `services` probes are documented down for exactly that), and `LlmEndpointDown` is scoped `up{job=~"frank-.*"}` — so a `/health` job would page on Signal for a healthy endpoint. Recorded in the page instead of quietly skipped. - Vault: new [[Frank TTS]] page linked from the index; [[Frank TTS Engine]] now points at it for the pair's public signal; log entry for the day. - Seam grows to 25 tests: the generator's rendering of the pair (both attachments, first-network order, one publish, Wants-not-Requires, bounded per-unit blocks), the env file's upstream checked as a network JOIN rather than a string, planner separation and order, the handler's steps, the card's gating, routing that selects both seams for either deployable.
This commit is contained in:
@@ -9,9 +9,9 @@
|
|||||||
# /home/dank/voices holding reference.wav; the shim refuses any name without
|
# /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
|
# one (400 + the list that does exist) rather than narrating in a fallback
|
||||||
# voice nobody chose. The engine behind it is a separate decision —
|
# voice nobody chose. The engine behind it is a separate decision —
|
||||||
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an
|
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to the
|
||||||
# OpenAI-shaped upstream on host :8880 and running a local CLI binary per
|
# OpenAI-shaped engine (`frank-tts-engine`, [[Frank TTS Engine]]) and running a
|
||||||
# request.
|
# local CLI binary per request.
|
||||||
#
|
#
|
||||||
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
||||||
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
|
# 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
|
# Pull=never and the localhost/ prefix are required — this host's quadlet
|
||||||
# path cannot resolve a locally built tag from local storage otherwise.
|
# path cannot resolve a locally built tag from local storage otherwise.
|
||||||
#
|
#
|
||||||
# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared
|
# NETWORK. TWO attached networks, and the order is the one the open-notebook
|
||||||
# loopback namespace, and the comfyui/loki/authentik units are bridge too.
|
# pair uses: `dankclaw_default` first (the default route, and where consumers
|
||||||
# Consequences both ways: consumers reach it on the published 7772, and the
|
# live — they reach the published 7772, and open-notebook dials
|
||||||
# shim reaches the host-side TTS engine through host.containers.internal
|
# `http://frank-tts:7772/v1` by container name), then the voice pair's private
|
||||||
# (never the LAN IP — a bridge netns cannot route to the host's own address
|
# bridge, `frank-tts-engine.network`, where the arm A engine lives. The shim's
|
||||||
# for every bind shape, and the fleet's law is the gateway name). NOTE for
|
# upstream is therefore the engine's container DNS name
|
||||||
# whoever starts the :8880 engine: host.containers.internal reaches the host
|
# (`http://frank-tts-engine:8880`, scripts/frank-tts.env) and NOT
|
||||||
# network namespace, so the engine must bind an address that namespace
|
# host.containers.internal: netavark DNATs published ports only, so a bridge
|
||||||
# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop
|
# container cannot reach a host-loopback bind — the shape that 502'd hermes —
|
||||||
# needed the host LAN bind for the same reason).
|
# 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
|
# 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
|
# READ-ONLY: this endpoint has no business writing voice packs. Because the
|
||||||
@@ -41,14 +51,16 @@
|
|||||||
# carries them deliberately.
|
# carries them deliberately.
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
|
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=network-online.target
|
||||||
|
Wants=frank-tts-engine.service
|
||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=frank-tts
|
ContainerName=frank-tts
|
||||||
Image=localhost/franksdank/frank-tts:latest
|
Image=localhost/franksdank/frank-tts:latest
|
||||||
Pull=never
|
Pull=never
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
|
Network=frank-tts-engine.network
|
||||||
PublishPort=7772:7772
|
PublishPort=7772:7772
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
||||||
Volume=/home/dank/voices:/voices:ro
|
Volume=/home/dank/voices:/voices:ro
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ SERVICE_ORDER = (
|
|||||||
# a dependency of the 7772 facade, so it is planned (and therefore
|
# a dependency of the 7772 facade, so it is planned (and therefore
|
||||||
# restarted) first.
|
# restarted) first.
|
||||||
"frank-tts-engine",
|
"frank-tts-engine",
|
||||||
|
"frank-tts",
|
||||||
"frank-dragoon",
|
"frank-dragoon",
|
||||||
"frank-zealot",
|
"frank-zealot",
|
||||||
"frank-archon",
|
"frank-archon",
|
||||||
@@ -102,6 +103,7 @@ DANKCLAW_SERVICES = {
|
|||||||
"frank-dragoon",
|
"frank-dragoon",
|
||||||
"frank-zealot",
|
"frank-zealot",
|
||||||
"frank-tts-engine",
|
"frank-tts-engine",
|
||||||
|
"frank-tts",
|
||||||
}
|
}
|
||||||
|
|
||||||
DANKSERVER_SERVICES = {
|
DANKSERVER_SERVICES = {
|
||||||
@@ -342,6 +344,15 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
"Containerfile.engine", "tts-engine-start.sh",
|
"Containerfile.engine", "tts-engine-start.sh",
|
||||||
"tts-engine-warm.sh"}:
|
"tts-engine-warm.sh"}:
|
||||||
_add(services, "frank-tts-engine")
|
_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
|
return services
|
||||||
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
# 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.
|
# 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:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
||||||
dankclaw:open-notebook|\
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
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:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||||
@@ -550,6 +550,14 @@ build_compose_image() {
|
|||||||
image="franksdank/$service:latest"
|
image="franksdank/$service:latest"
|
||||||
containerfile="dankclaw/$service/Containerfile"
|
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)
|
frank-tts-engine)
|
||||||
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
|
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
|
||||||
# source at a pinned upstream commit, so the build needs network
|
# 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
|
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() {
|
deploy_comfyui() {
|
||||||
# Build first: the unit's Image= resolves against local storage, so the
|
# Build first: the unit's Image= resolves against local storage, so the
|
||||||
# image has to exist before the generator starts a container from it.
|
# image has to exist before the generator starts a container from it.
|
||||||
@@ -1748,6 +1793,7 @@ deploy_service() {
|
|||||||
frank-zealot) deploy_llm_zealot ;;
|
frank-zealot) deploy_llm_zealot ;;
|
||||||
frank-archon) deploy_llm_archon ;;
|
frank-archon) deploy_llm_archon ;;
|
||||||
frank-tts-engine) deploy_frank_tts_engine ;;
|
frank-tts-engine) deploy_frank_tts_engine ;;
|
||||||
|
frank-tts) deploy_frank_tts ;;
|
||||||
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
||||||
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
||||||
jellyfin) deploy_jellyfin ;;
|
jellyfin) deploy_jellyfin ;;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ valid_service() {
|
|||||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
||||||
dankclaw:open-notebook|\
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-tts-engine|\
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
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
|
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
|
||||||
# consumer:
|
# consumer:
|
||||||
# omni-http forward to an OpenAI-shaped upstream on the host (:8880),
|
# omni-http forward to an OpenAI-shaped upstream, carrying the voice along
|
||||||
# carrying the voice along as base64 reference_audio. The
|
# as base64 reference_audio. The engine does the inference; the
|
||||||
# engine does the inference; the shim resolves the voice and
|
# shim resolves the voice and re-encodes to mp3 when asked. The
|
||||||
# re-encodes to mp3 when asked.
|
# 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
|
# llama-tts-cli run LLAMA_TTS_BIN once per request with
|
||||||
# --tts-speaker-file <reference.wav>, nice 19 so a narration
|
# --tts-speaker-file <reference.wav>, nice 19 so a narration
|
||||||
# never starves the text endpoints on this box. Needs
|
# never starves the text endpoints on this box. Needs
|
||||||
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
|
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
|
||||||
# only if that binary takes a projector.
|
# 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
|
# THE UPSTREAM ADDRESS. The engine is a container on the private bridge this
|
||||||
# 127.0.0.1: a bridge netns has neither.
|
# 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
|
VOICES_DIR=/voices
|
||||||
TTS_BACKEND=omni-http
|
TTS_BACKEND=omni-http
|
||||||
TTS_UPSTREAM=http://host.containers.internal:8880
|
TTS_UPSTREAM=http://frank-tts-engine:8880
|
||||||
TTS_UPSTREAM_MODEL=voxcpm2
|
TTS_UPSTREAM_MODEL=voxcpm2
|
||||||
|
|
||||||
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
# 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
|
## 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`.
|
- `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.
|
- `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)
|
- [[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
|
- [[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 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
|
## 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
|
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
|
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.
|
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,
|
"wip": false,
|
||||||
"showLink": true,
|
"showLink": true,
|
||||||
"art": "/img/apps/mono-cu.svg"
|
"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"
|
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
|
||||||
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
|
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"
|
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
|
||||||
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
||||||
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
||||||
@@ -184,19 +188,34 @@ quadlet_generator() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# render_units — run the generator in a throwaway HOME over a copy of the two
|
# render_units — run the generator in a throwaway HOME over a copy of the voice
|
||||||
# source files and print everything it emits. --dryrun prints instead of
|
# stack's source files and print everything it emits. Both units are rendered
|
||||||
# writing, so this neither contacts the podman daemon nor creates a network.
|
# 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() {
|
render_units() {
|
||||||
local gen home
|
local gen home
|
||||||
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
||||||
home="$TEST_TMP/fakehome"
|
home="$TEST_TMP/fakehome"
|
||||||
mkdir -p "$home/.config/containers/systemd"
|
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"
|
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
|
||||||
cat "$TEST_TMP/generated.txt"
|
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
|
# free_port — an address with nothing bound to it, for the tests that need the
|
||||||
# warmer to be dialing before a listener exists.
|
# warmer to be dialing before a listener exists.
|
||||||
free_port() {
|
free_port() {
|
||||||
@@ -251,7 +270,7 @@ s.close()'
|
|||||||
# Boot persistence and self-healing, as the generator actually emits them —
|
# Boot persistence and self-healing, as the generator actually emits them —
|
||||||
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
||||||
# Restart=always plus a failing warmer is a crash loop on the endpoint.
|
# 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$'
|
printf '%s' "$unit" | grep -qE '^Restart=always$'
|
||||||
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
||||||
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
|
[ -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" {
|
@test "the generator renders the bridge creation command with the pinned subnet" {
|
||||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
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$'
|
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:
|
# 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
|
# a referenced .network that the generator cannot find kills the container
|
||||||
# unit outright, and an untied one starts the container before the bridge.
|
# 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 '^Requires=frank-tts-engine-network\.service$'
|
||||||
printf '%s' "$unit" | grep -qE '^After=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" {
|
@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 \
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
||||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network \
|
|
||||||
dankclaw/voiceservice/Containerfile.engine \
|
dankclaw/voiceservice/Containerfile.engine \
|
||||||
dankclaw/voiceservice/tts-engine-start.sh \
|
dankclaw/voiceservice/tts-engine-start.sh \
|
||||||
scripts/frank-tts-engine.env \
|
scripts/frank-tts-engine.env \
|
||||||
@@ -654,3 +675,141 @@ PY
|
|||||||
fi
|
fi
|
||||||
done
|
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