From 8fefe6e1a968f11bab7d83d3642ba9bc89565e3d Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 17:43:08 -0700 Subject: [PATCH] feat(frank-tts): deploy wiring for the 7772 voice endpoint, and voice naming written down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../voiceservice/quadlet/frank-tts.container | 40 ++-- scripts/deploy-plan.py | 11 ++ scripts/deploy-remote.sh | 48 ++++- scripts/deploy.sh | 2 +- scripts/frank-tts.env | 25 ++- .../Dankclaw/Frank TTS Engine.md | 2 +- serverAppConfigs-docs/Dankclaw/Frank TTS.md | 151 +++++++++++++++ serverAppConfigs-docs/index.md | 1 + serverAppConfigs-docs/log.md | 46 +++++ shared/homepage/img/apps/mono-ft.svg | 1 + shared/homepage/services.json | 13 ++ tests/frank_tts_engine.bats | 175 +++++++++++++++++- 12 files changed, 482 insertions(+), 33 deletions(-) create mode 100644 serverAppConfigs-docs/Dankclaw/Frank TTS.md create mode 100644 shared/homepage/img/apps/mono-ft.svg diff --git a/dankclaw/voiceservice/quadlet/frank-tts.container b/dankclaw/voiceservice/quadlet/frank-tts.container index 138e239..6cdcd72 100644 --- a/dankclaw/voiceservice/quadlet/frank-tts.container +++ b/dankclaw/voiceservice/quadlet/frank-tts.container @@ -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 diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 4dc8acb..8684c14 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -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. diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index e82fe51..3cd81eb 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -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 ;; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0d815c3..35e4ce9 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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|\ diff --git a/scripts/frank-tts.env b/scripts/frank-tts.env index 5c99c28..2fc61ef 100644 --- a/scripts/frank-tts.env +++ b/scripts/frank-tts.env @@ -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 , 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 diff --git a/serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md b/serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md index b588a73..e23aeac 100644 --- a/serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md +++ b/serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md @@ -81,7 +81,7 @@ The packs themselves are host state at `/home/dank/voices//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. diff --git a/serverAppConfigs-docs/Dankclaw/Frank TTS.md b/serverAppConfigs-docs/Dankclaw/Frank TTS.md new file mode 100644 index 0000000..42d5188 --- /dev/null +++ b/serverAppConfigs-docs/Dankclaw/Frank TTS.md @@ -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=` 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/` | diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index ea762c0..64eed31 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -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 diff --git a/serverAppConfigs-docs/log.md b/serverAppConfigs-docs/log.md index 744297f..7fcf7f5 100644 --- a/serverAppConfigs-docs/log.md +++ b/serverAppConfigs-docs/log.md @@ -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. diff --git a/shared/homepage/img/apps/mono-ft.svg b/shared/homepage/img/apps/mono-ft.svg new file mode 100644 index 0000000..44d6e4d --- /dev/null +++ b/shared/homepage/img/apps/mono-ft.svg @@ -0,0 +1 @@ +FT diff --git a/shared/homepage/services.json b/shared/homepage/services.json index 5ae9817..616285c 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -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=, 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" } ] diff --git a/tests/frank_tts_engine.bats b/tests/frank_tts_engine.bats index b7d0988..3e01a63 100755 --- a/tests/frank_tts_engine.bats +++ b/tests/frank_tts_engine.bats @@ -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 — 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" +}