Files
Frank 7178e1c789 docs(frank-tts): what makes a locally built voice image stale, and what the warm text must survive
Three pages, all from faults found this session:

- `LLM/deployment.md` gains "Locally built images": which images have no
  registry digest, what the `cfhash` skip is for, why it must cover every COPY'd
  file (the silent shape: build skipped, /health green, old code serving), and
  the base-image half of the law — the engine's `useradd`, because the pinned
  ubuntu:24.04 has no `adduser`.
- `Dankclaw/Frank TTS Engine.md` troubleshooting gains the two lines an operator
  actually needs: every voice cold on a fresh boot with a hand-run hook
  succeeding (= `WARM_TEXT` arrived quoted, `podman exec … env | grep WARM_TEXT`
  proves it), and a 502 from the façade beside a 200 `/health` from the engine
  (model loaded ≠ voices registered). The stale-warmer row is re-keyed to the
  new rebuild rule.
- `Dankclaw/Voice Packs.md` step 4 was wrong: it told the next agent to restart
  the engine *instead of* the warm hook because the hook "only registers packs it
  has not seen". It registers every pack on every run and the engine replaces a
  name it already holds — `tools/tts-server.cpp:148` at the pinned commit:
  "Re-registering a name replaces the previous entry." A restart is what an
  engine bump needs (the registry is memory-only), never worse, only slower.
- `Dankclaw/Frank TTS.md` deploy command re-keyed to `./scripts/deploy.sh
  dankclaw --service frank-tts`, the way it is actually deployed; the raw
  `deploy-remote.sh` form is plumbing.
2026-10-18 18:07:19 -07:00

8.4 KiB

type, description, tags
type description tags
reference 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.
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 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/