6.3 KiB
name, description
| name | description |
|---|---|
| frank-tts-add-cloned-voice | Add or repair a cloned voice on the dank fleet: /home/dank/voices/<name> 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, soNarrator1resolves 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 isnarrator1,narrator2, … for the open-notebook podcast speakers — open-notebook is configured with exactlynarrator1/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/<name>/ 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:
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:
- 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.
- The effective crop window is 19.84-25 s, not 18-25 s. An 18.0 s crop is a
legal
reference.wavbut 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. - It never deletes. An existing pack is refused unless
--force, and--forcemoves the old directory to$PACKS_DIR/.AGENTTRASH/<voice>-<UTC>/.
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:
# 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)
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, missingreference.wav, or/home/dank/voicesroot-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:8880over the shared bridge (frank-tts-engine.network); ahost.containers.internal/127.0.0.1:8880upstream 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.