--- name: frank-tts-add-cloned-voice description: "Add or repair a cloned voice on the dank fleet: /home/dank/voices/ pack, name rules, boot-warm registration, and the under-a-second proof" --- Use for "give frank-tts a new voice", "voice X is missing/unknown", or "the first request of the day takes 5 s". Runbook of record: vault pages [[Frank TTS]] (voice packs, naming) and [[Frank TTS Engine]] (the warm registry). Everything below runs as the deploy user on **Dankclaw** (`ssh -o BatchMode=yes dankclaw '…'`); the containers are **rootless** (`systemctl --user …`, `podman …` without sudo). ## Naming (the question that gets asked first) A voice is addressed **by its directory name** — `voice=narrator1` on `POST /v1/audio/speech` (omp's TTS device may send `voice_id`, same value). Use: - `^[a-z0-9][a-z0-9._-]*$`, **lowercase**, ≤ 64 chars. No slash, no `..`, no space, no uppercase; must not start with `.`/`_`/`-`. - Why lowercase rather than just "alphanumeric": the shim accepts `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (`app.py`, `VOICE_NAME_RE`) but the **engine lowercases names**, so `Narrator1` resolves on one side and not the other. The boot warmer refuses a mixed-case pack loudly instead of half-working. - Names are user-visible: a 400 carries them in `available_voices`. Fleet convention is `narrator1`, `narrator2`, … for the open-notebook podcast speakers — open-notebook is configured with exactly `narrator1/2/3`, so renaming those breaks podcast rendering, not just TTS — and a lowercase personal name for a person's own voice. ## Pack layout (host state — never commit it) `/home/dank/voices//` is a voice **iff** it holds `reference.wav`: ``` reference.wav 24 kHz mono pcm_s16le, 18-25 s of clean single-speaker audio reference.spk engine x-vector: enc_dim 2048 f32 = 8192 B reference.rvq RVQ codes: 22 B per 12.5 Hz frame (5456-6864 B) manifest.json source path + sha256, crop start/end, engine commit, name ``` Build it; do not hand-assemble and never fabricate `.spk`/`.rvq` from a WAV: ```bash scripts/build-voice-pack.sh --all /home/dank/voices # everything in VOICE_PACKS scripts/build-voice-pack.sh --source SRC --offset SEC --duration SEC --sha256 HEX NAME /home/dank/voices scripts/build-voice-pack.sh --dry-run ... # validate + crop, no extractor ``` Knobs live in `scripts/voice-packs.env` (one `_SOURCE`/`_OFFSET`/`_DURATION`/ `_SHA256` set per voice). Three things it enforces that an operator gets wrong: 1. **The 10-second gate.** A crop starting inside the first 10 s of the source is refused before anything is written (project source-material law), and the manifest is re-checked against the source's sha256 afterwards. 2. **The effective crop window is 19.84-25 s, not 18-25 s.** An 18.0 s crop is a legal `reference.wav` but yields only 22 × 225 = 4 950 B of `.rvq`, under the 5 456 B floor the codec config implies — so the builder says so up front. 3. **It never deletes.** An existing pack is refused unless `--force`, and `--force` moves the old directory to `$PACKS_DIR/.AGENTTRASH/-/`. Files must be world-readable (the container runs as uid 10000): `644` files, `755` dirs. `mkdir -p /home/dank/voices` as the deploy user if it is absent — rootless podman auto-creates a missing bind source **root-owned**, and the endpoint then sees no voices at all. ## Register and warm (the part people forget) The engine keeps its voice registry **in memory**, and the **first synthesis of a registered voice decodes the whole reference clip** to snapshot codec state: 6.2-8.0 s, first audio byte at ~5 530 ms. Later requests restore in 0.5 ms. Re-registering does **not** warm — only synthesizing does. So: ```bash # registers every pack that has a reference.wav, then speaks the warm sentence once podman exec frank-tts-engine /app/warm-voices.sh systemctl --user --no-pager --full status frank-tts-engine.service | tail -20 ``` The unit's `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` does the same at every boot, so a fresh boot is already warm; the explicit call above is for **adding one voice to a running engine** and needs no restart — a restart drops every registration and re-pays the cold cost for the whole library. The `podman exec` form is not cosmetic: the engine **publishes no port**, so there is no host address to curl. ## Proof (do not report "added" without it) ```bash curl -s localhost:7772/v1/audio/voices # name is listed time curl -s -o /tmp/a.wav localhost:7772/v1/audio/speech \ -H 'content-type: application/json' \ -d '{"model":"tts-1","voice":"NAME","input":"Checking the cloned voice.","response_format":"wav"}' time curl -s -o /tmp/b.wav localhost:7772/v1/audio/speech \ -H 'content-type: application/json' \ -d '{"model":"tts-1","voice":"NAME","input":"And the second request.","response_format":"wav"}' file /tmp/a.wav /tmp/b.wav # really audio ``` The warmer's journal lines carry `voice NAME cold=…ms warm=…ms` — quote that pair. Note that streaming and buffered output differ at the same seed (92.2 % of samples, -41.6 dB RMS): never compare two paths or two "identical" renders, only that each is audio and that the second request is fast. ## Failure modes worth naming - `400 unknown voice …; available voices: …` → the name is not a pack: wrong spelling/case, missing `reference.wav`, or `/home/dank/voices` root-owned/empty. Trust the 400's list over any manifest. - 400 with `(none: no voice pack with a reference.wav in /voices)` → mount problem, not a voice problem. - First request slow **after** a boot → the warm pass failed; read the unit's journal (the warmer exits 0 and logs when there is no voice directory, so silence there is not success either). - Endpoint answers but synthesis fails 502 → the façade must dial `http://frank-tts-engine:8880` over the shared bridge (`frank-tts-engine.network`); a `host.containers.internal` / `127.0.0.1:8880` upstream can never work between containers, because netavark DNATs published ports only. ## Never Never write under `/voices` from the endpoint (mounted `:ro` by design), never `git add` a voice pack, never `rm` a pack (repo policy: move to `.AGENTTRASH/`), and never benchmark against this endpoint while it serves — stop and mask the units first.