Files
command-center/agent/managed-skills/frank-tts-add-cloned-voice/SKILL.md
T

3.9 KiB
Raw Blame History

name, description
name description
frank-tts-add-cloned-voice 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/<name>/ 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 COPYs, 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).