--- name: frank-tts-add-cloned-voice description: "Add or repair a cloned voice on the dank fleet: pack builder, the reference.wav contract, the boot warm hook (argv-cap and env-quote laws), and how to prove a voice really answers." --- # Adding (or repairing) a cloned voice on the dank fleet Repo law lives in `serverAppConfigs-docs/Dankclaw/Voice Packs.md` and `Frank TTS Engine.md` — read those for the pack format. This skill is the **operating sequence plus the traps that cost real time on 2026-10-18**. ## Stack, fast - `frank-tts-engine` — arm A `qwentts.cpp tts-server`, container-internal `:8880`, publishes **no port**. Reach it with `podman exec frank-tts-engine curl …`. - `frank-tts` — OpenAI façade on host `:7772`; resolves voice **by name**. - Packs: `/home/dank/voices//` with `reference.wav` (+ manifest, `.spk`, `.rvq`). Builder: `scripts/build-voice-pack.sh` (crop 19.84–25 s, first 10 s of any source is forbidden). - Weights: `/home/dank/models/Qwen3-TTS-GGUF/` — `scripts/download-frank-tts-engine-qwentts.sh [--verify-only]` (1.4 GiB, ~18 s on the LAN; verifies byte count + Hub LFS sha256). ## Sequence 1. Build the pack (dry-run first — sizes are arithmetic and asserted). 2. Deploy the engine only if its files changed: `./scripts/deploy.sh dankclaw --service frank-tts-engine` (first run builds the image: ~10 min recursive source clone). 3. Register/warm: `podman exec frank-tts-engine /app/warm-voices.sh` — registers **every** pack each run (the engine replaces a name it already holds; `tools/tts-server.cpp:148`), then synthesizes twice per voice. 4. Prove from the consumer side, not the config: `curl -s localhost:7772/v1/audio/voices` then a real `POST /v1/audio/speech` and listen to the bytes. ## Trap 1 — the kernel's argument cap (this broke the first deploy) `/usr/bin/curl: Argument list too long` in the warm log ⇒ a request body was passed as **one argv string**. Linux caps a single argument at `MAX_ARG_STRLEN = 131 072 bytes`; a ~1 MB `reference.wav` base64's to ~1.27 MB. curl never runs, and hooks report it as `HTTP no response` while `/health` is green. Fix = write the body to a file and use `--data-binary @file`; feed the payload through a redirection, not `$(base64 …)`. Regression-test shape: **real** curl + real listener + a fixture >128 KB, so the kernel (not a stub) produces the failure. Any pack over ~96 KB trips it. ## Trap 2 — two readers of the env file disagree about quotes `bash set -a; source` strips `"…"`. podman's `--env-file` (how the unit really reads it) does **not**. A value interpolated into a JSON body therefore arrives quoted and invalidates it. Test both readers, not just `source`. ## Trap 3 — locally built images skip rebuilds on a stale hash `build_compose_image` compares a `cfhash` label; it must cover every file the `Containerfile` `COPY`s, or an edited `app.py`/hook deploys "successfully" while the container runs old code. ## Measured numbers (2026-10-18, deployed build — retire the trial's ladder) | thing | value | |---|---| | first synthesis of a freshly registered voice | 1 883 ms | | later short requests | 1 226–1 283 ms | | ~110-word request (repeat) | 9.4 s / 9.8 s — tracks generated audio, not state | | whole boot warm, 3 packs | 15.2 s | | engine resident | ~4.0 GiB | The trial's `6.2–8.0 s cold → 0.5 ms warm` claim does **not** reproduce; warming is for registration (registry is memory-only, re-register ≠ warm) plus ~0.6 s. ## Ops notes - `systemctl --user stop` **removes** the quadlet container — `podman start` then finds nothing. To run the engine without its `ExecStartPost` hook, use a separate container, not stop+start. - `podman cp` of a fixed script into a running container is a **trial only**: the layer dies with the container. Merge + redeploy to make it durable. - Nothing on the voice pair exposes `/metrics`; Prometheus can't scrape it (a blackbox HTTP probe on 7772 is the only honest option).