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:
@@ -81,7 +81,7 @@ The packs themselves are host state at `/home/dank/voices/<name>/reference.wav`
|
||||
|
||||
## Status visibility
|
||||
|
||||
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). Its up-state is visible in four places, and the runbook uses all of them:
|
||||
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). The pair's public signal is the endpoint in front of it: [[Frank TTS]] carries the card and is what a consumer can dial. Its up-state is visible in four places, and the runbook uses all of them:
|
||||
|
||||
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
|
||||
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
type: reference
|
||||
description: "frank-tts is the fleet's cloned-voice speech endpoint on Dankclaw :7772 — the one voice surface consumers address, in front of frank-tts-engine; repo-owned, not yet deployed."
|
||||
tags:
|
||||
- dankclaw
|
||||
- tts
|
||||
- voice
|
||||
- quadlet
|
||||
---
|
||||
|
||||
# Frank TTS
|
||||
|
||||
`frank-tts` is the fleet's only speech endpoint: an OpenAI-shaped
|
||||
`POST /v1/audio/speech` on **Dankclaw :7772** that answers `voice=<name>` with
|
||||
audio in that cloned voice. Everything else in the voice stack sits behind it —
|
||||
the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers
|
||||
never learn a path.
|
||||
|
||||
**Nothing here is deployed.** No host was touched while this was written: no
|
||||
image built, no container started, nothing listening on 7772. That is also why
|
||||
[[Open Notebook]] cannot render a podcast episode yet — its pipeline calls this
|
||||
endpoint.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
|
||||
| Port | **7772**, published (`PublishPort=7772:7772`); the family is 7770 dragoon, 7771 zealot, 7772 tts |
|
||||
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
|
||||
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
|
||||
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
|
||||
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
|
||||
| Upstream | `http://frank-tts-engine:8880` (`scripts/frank-tts.env`, `TTS_UPSTREAM`) |
|
||||
| Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
|
||||
|
||||
## Naming a voice
|
||||
|
||||
A voice **is** a directory name. `voice=narrator1` resolves to
|
||||
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
|
||||
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
|
||||
shim accepts either.
|
||||
|
||||
The usable shape is `^[a-z0-9][a-z0-9._-]*$`: lowercase, starting with a letter
|
||||
or digit, then letters, digits, `.`, `_`, `-`, at most 64 characters. No slash,
|
||||
no `..`, no space, no uppercase. The lowercase requirement is where two rules
|
||||
meet: `app.py` would accept `[A-Za-z0-9…]`, but `tts-server` **lowercases
|
||||
names**, so a pack called `Narrator1` resolves through the façade and not
|
||||
through the engine. The boot warmer therefore refuses a mixed-case directory
|
||||
loudly instead of letting it half-work.
|
||||
|
||||
Names are user-visible: an unknown voice comes back as
|
||||
`400 unknown voice 'x'; available voices: …`, and that list is the honest
|
||||
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
|
||||
[[Open Notebook]] is configured with exactly those three, so renaming them
|
||||
breaks episode rendering, not just speech. A personal voice gets the person's
|
||||
name, lowercase.
|
||||
|
||||
## Voice packs
|
||||
|
||||
One directory per voice under `/home/dank/voices`:
|
||||
|
||||
```
|
||||
reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio
|
||||
reference.spk engine speaker embedding (8 192 B)
|
||||
reference.rvq reference codec codes (22 B per 12.5 Hz frame)
|
||||
manifest.json source file, offsets, engine, sha256
|
||||
```
|
||||
|
||||
`reference.wav` is the engine-agnostic term the façade gates on; the other two
|
||||
are what makes a voice reusable by this engine, and the pack builder produces
|
||||
them — never by hand, never fabricated from the WAV. Two laws apply to the
|
||||
source material: offsets always start **past the first 10 seconds** of the
|
||||
sample, and packs are host state that stays out of this repo. Because the
|
||||
container runs as uid 10000, files must be world-readable (`644`/`755`), and the
|
||||
directory must exist before the first deploy: rootless podman auto-creates a
|
||||
missing bind source **root-owned**, which reads as "no voices" rather than as an
|
||||
error.
|
||||
|
||||
Adding a voice to a running stack is a two-step thing — drop the pack, then
|
||||
register **and warm** it with
|
||||
`podman exec frank-tts-engine /app/warm-voices.sh` — because registering alone
|
||||
does not warm it. The reason is the cold-voice law in [[Frank TTS Engine]], and
|
||||
the procedure with its proof (the second request under a second) is the
|
||||
`frank-tts-add-cloned-voice` skill.
|
||||
|
||||
## Why the upstream is a container name
|
||||
|
||||
`scripts/frank-tts.env` used to point at `http://host.containers.internal:8880`.
|
||||
That cannot work, and it is the trap this shape exists to avoid: a bridge
|
||||
container reaches the host's network namespace, netavark DNATs **published**
|
||||
ports only, and an engine on the host's loopback is published to nobody — the
|
||||
same shape that 502'd hermes ([[Container Network Namespaces]]). So both units
|
||||
attach to one committed Quadlet network and the façade dials the engine's
|
||||
container DNS name.
|
||||
|
||||
The consequence worth stating before an operator meets it: **the engine is not
|
||||
reachable from the host at all** — not over the LAN, not over loopback. Its
|
||||
health, voice registry and logs are observable only through
|
||||
`podman exec frank-tts-engine …` and `systemctl --user status`. And the façade's
|
||||
own `/health` answers 200 with the backend name and the voice list it resolved
|
||||
without dialing the engine, so "façade healthy, engine down" is a real state: it
|
||||
shows up as a failed synthesis, never as a down probe.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | What |
|
||||
| --- | --- |
|
||||
| `dankclaw/voiceservice/app.py` | the shim: stdlib only — resolves the voice, forwards, re-encodes mp3 |
|
||||
| `dankclaw/voiceservice/Containerfile` | python 3.12-alpine pinned by digest + ffmpeg, uid 10000 |
|
||||
| `dankclaw/voiceservice/quadlet/frank-tts.container` | the unit: two networks, `Restart=always`, `HealthCmd`, `[Install]` |
|
||||
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the shared bridge, named after the engine unit and attached by both |
|
||||
| `scripts/frank-tts.env` | backend, upstream, port, limits — paths and numbers, no credentials |
|
||||
| `tests/frank_tts_unit.bats` | the shim against stub engines over real sockets |
|
||||
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer |
|
||||
|
||||
## Deploying it
|
||||
|
||||
`scripts/deploy-remote.sh dankclaw frank-tts` (or `frank-tts-engine frank-tts`):
|
||||
builds the façade image, installs **both** Quadlet units — including the
|
||||
engine's `.network` file, because a referenced `Network=` file the generator
|
||||
cannot find means the façade unit is not produced at all — creates
|
||||
`/home/dank/voices` as the deploy user, restarts the unit (whose `Wants=` and
|
||||
`After=` pull the engine and its warm pass ahead of it) and gates on podman's
|
||||
recorded health. It never runs `systemctl --user enable`; boot persistence is
|
||||
the `[Install]` section in each unit. Order matters in a full deploy:
|
||||
[[Frank TTS Engine]] is planned before this, and both before [[Open Notebook]].
|
||||
|
||||
## Not scraped, and why
|
||||
|
||||
`shared/prometheus/` gets **no** frank-tts job, and that is a considered
|
||||
absence. The façade exports no `/metrics` — `app.py` serves `/health` plus the
|
||||
speech route only — and this Prometheus 3 install refuses non-metrics content
|
||||
types: [[Prometheus]] records that the existing HTML probes (`comfyui`, the two
|
||||
`services` targets) sit **down** with
|
||||
`received unsupported Content-Type … and no fallback_scrape_protocol specified`.
|
||||
A `/health` probe job would therefore be structurally never-green, and because
|
||||
`LlmEndpointDown` is scoped `up{job=~"frank-.*"} == 0`, a permanently red
|
||||
`frank-tts` target would page on Signal every few minutes for an endpoint that
|
||||
is fine — the frank-archon lesson pointed the other way. The engine cannot be
|
||||
scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
|
||||
`podman healthcheck run`, the two unit journals, and the homepage card, which is
|
||||
gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| `400 unknown voice 'x'; available voices: …` | the name is not a pack: case, spelling, missing `reference.wav`, or an empty/root-owned `/home/dank/voices` |
|
||||
| `(none: no voice pack with a reference.wav in /voices)` | the mount, not the voices: `podman inspect frank-tts --format '{{json .Mounts}}'`, then the directory owner |
|
||||
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
|
||||
| First request of the day takes ~5.5 s | the warm pass did not run — cold-voice law in [[Frank TTS Engine]] |
|
||||
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
|
||||
Reference in New Issue
Block a user