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.
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. |
|
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/ |