--- 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 they come from the pack builder ([[Voice Packs]]) — 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.sh dankclaw --service frank-tts` (or `--service frank-tts-engine --service frank-tts` for the pair): 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/` |