#!/usr/bin/env bash # tts-engine-warm.sh — the boot warmer of the arm A TTS engine, shipped inside # the frank-tts-engine image at /app/warm-voices.sh # (dankclaw/voiceservice/Containerfile.engine). # # WHO RUNS IT. Two callers, same binary: # # 1. The Quadlet unit, as # `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` # — after every start of the container, including every boot. # `podman exec` rather than a host script because the engine publishes no # port at all, so there is no address the host could dial; and because an # exec inherits the unit's EnvironmentFile, which is what puts every knob of # this script in scripts/frank-tts-engine.env next to the engine's own. # 2. scripts/deploy-remote.sh's deploy_frank_tts_engine, once per deploy, so a # voice that did not warm becomes a deploy result instead of only a journal # line (the unit's `-` prefix deliberately cannot fail the unit). # # WHY IT EXISTS AT ALL (the registry law). The voice registry is MEMORY ONLY: a # restart forgets every voice, and re-registering a voice does not warm it — only # synthesizing does. A boot that registers nothing therefore leaves frank-tts # answering 400 for every name, so this script registers every pack it finds. The # per-voice cold penalty is real but small, and smaller than the trial reported: # measured on the deployed build 2026-10-18 against an empty registry, the FIRST # synthesis of a freshly registered voice cost 1,883 ms against 1,226-1,283 ms for # the requests after it — about 0.6 s, not the trial's 6.2-8.0 s. Each voice gets # two identical requests so the journal carries both numbers, `voice # cold=…ms warm=…ms`; cold ≈ warm is the expected shape, and a large gap is what # would mean the snapshot path is broken. # # THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by # the unit, the same packs frank-tts mounts for its own name list. A pack is a # directory that contains reference.wav; everything else in it (manifest.json, the # engine's reference.spk / reference.rvq extraction, a 16 kHz variant) is not read # here. The registration payload is `{"name": , "wav_b64": }` with NO ref_text, which selects the embedding-only (x-vector) # voice — the shipped default, warm RTF ~0.57-0.63 against ICL's 0.67, and the one # this fleet can produce honestly because it has no trustworthy transcript for the # audition crops. Adding ref_text (with the codes) would flip that voice to # in-context-learning mode: better fidelity on a good transcript, slower, and only # worth it for batch work like a podcast render. That is a per-voice opt-in this # script deliberately does NOT exercise, and the door stays open: a pack that # wants ICL needs its text in the pack plus one key in the payload below. # # A lone `spk_b64` is a 400 (src/tts-server.h:480 — it is `wav_b64` or BOTH # `spk_b64` and `rvq_b64`), which is why the WAV travels and the .spk stays home. # # NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes # are discarded, the synthesized text comes from the env file, and the reference # clip is base64'd into a request body that is written to a temporary file and # handed to curl as `@file` — never echoed, and never placed on a command line, # because of the size law explained at the mktemp below. set -euo pipefail # The engine's own address, from inside its namespace. Loopback is valid HERE: # this script runs in the container, next to the server. There is deliberately no # host-visible address to use instead — the unit publishes nothing, because a # host-loopback publish would be unreachable from the façade anyway (netavark # DNATs published ports only) while adding a surface on an unauthenticated # server. : "${ENGINE_URL:=http://127.0.0.1:8880}" # Container path; the host directory behind it is named in the Quadlet unit. : "${VOICES_DIR:=/voices}" # How long to wait for the engine to answer /health. Model load dominates: the # two GGUFs (1.2 GiB + 291 MB) are read and the context built before the HTTP # thread binds, and a boot races every other unit on this box. : "${HEALTH_WAIT:=300}" # One short sentence, long enough to exercise the codec snapshot and short enough # that warming a library of voices is seconds, not minutes. Never varied per run: # the cold/warm numbers in the journal should mean the same thing every boot. The # synthesis asks for `wav`, the buffered path — the streamed path (`pcm`) is # different audio at the same seed, and `wav` is also what frank-tts asks for. : "${WARM_TEXT:=Warm up, one two three.}" # Per-request ceiling. A cold voice is 6-8 s by measurement; anything approaching # this is a stalled engine and should be reported rather than waited on forever — # systemd counts the whole warm pass against the unit's start deadline. : "${REQUEST_TIMEOUT:=120}" # The warm text goes into a JSON string literal by interpolation. Two steps make # whatever the two readers of scripts/frank-tts-engine.env deliver arrive as the # sentence itself: # # 1. Unwrap ONE shell-style quoting layer. bash (`set -a; source`) strips the # quotes around a value with spaces; podman's `--env-file` does NOT — measured # 2026-10-18 against the image's pinned base, `WARM_TEXT="…"` reaches the # container WITH its quotes. Unwrapped only when the whole value is exactly one # quoted string (starts AND ends with `"` and holds no quote inside), so a # sentence that legitimately contains quotes is never mangled. # 2. Escape the two characters that would corrupt or end a JSON string: a # backslash, and a double quote. # # Without step 1 the body is `{"input":""Warm up…"",…}`, the engine's JSON parser # rejects it, every voice stays cold, and the boot still reads healthy. Voice NAMES # need neither step: the character class checked below admits no quote, no # backslash and no control byte. warm_text="$WARM_TEXT" if (( ${#warm_text} >= 2 )) && [[ "$warm_text" == \"*\" ]]; then inner="${warm_text:1:${#warm_text}-2}" [[ "$inner" != *'"'* ]] && warm_text="$inner" fi warm_json="${warm_text//\\/\\\\}" warm_json="${warm_json//\"/\\\"}" # The registration body is a FILE, never an argument. The kernel caps a single # command-line string at 32 pages — MAX_ARG_STRLEN = 131,072 bytes — and a shipped # reference clip base64's to about 1.27 MB, so passing the body as one argument # fails with E2BIG before curl is even executed. The first real boot of the unit # (2026-10-18, the three shipped packs) proved it end to end: `/usr/bin/curl: # Argument list too long` for every voice, reported as the misleading # "registration failed (HTTP no response)". `mktemp` lands in the container's # /tmp as the unprivileged `tts` user; one file for the whole pass, overwritten # per voice, removed on any exit — it is container scratch, not host state. body_file="$(mktemp -t frank-tts-warm.XXXXXX)" trap 'rm -f "$body_file"' EXIT fail=0 log() { printf 'frank-tts-engine-warm: %s\n' "$*"; } # Wait for /health. It answers from the HTTP thread, so a 200 means the server is # listening; it says nothing about voices, which is what the rest of this script # is for. ready=0 for ((i = 1; i <= HEALTH_WAIT; i++)); do if curl -fsS --max-time 5 "${ENGINE_URL}/health" >/dev/null 2>&1; then ready=1 break fi sleep 1 done if (( ready == 0 )); then log "engine did not answer ${ENGINE_URL}/health within ${HEALTH_WAIT}s; nothing warmed" exit 1 fi if [[ ! -d "$VOICES_DIR" ]]; then log "no voice directory at ${VOICES_DIR}; nothing to warm (frank-tts will answer 400 with an empty voice list until packs are built)" exit 0 fi warmed=0 for dir in "$VOICES_DIR"/*/; do [[ -d "$dir" ]] || continue name="${dir%/}" name="${name##*/}" if [[ ! -f "${dir}reference.wav" ]]; then continue # not a pack; frank-tts refuses this name too fi # The engine lowercases every voice name (tts_voice_name) while frank-tts # matches the directory name exactly, so a mixed-case pack would work by # accident on one side and not the other. Refuse it loudly instead. Pack # names are also JSON string content here, so the character class is what # keeps a directory name from writing structure into the request body. if [[ ! "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then log "voice '${name}': name must be lowercase [a-z0-9._-] (the engine lowercases names; the shim does not) — skipped" fail=1 continue fi # The audio enters through a redirection rather than a command substitution: # the file is several hundred KB, and both a shell variable and an argument of # that size are what this script used to die on. { printf '{"name":"%s","wav_b64":"' "$name" base64 -w0 "${dir}reference.wav" printf '"}' } > "$body_file" if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \ -H 'Content-Type: application/json' --data-binary "@${body_file}" \ "${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name" fail=1 continue fi # The cold pass. Its output is discarded; the point is the codec snapshot it # leaves inside the engine. start=$(date +%s%3N) if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \ -H 'Content-Type: application/json' \ --data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \ "${ENGINE_URL}/v1/audio/speech"; then log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold" fail=1 continue fi cold=$(( $(date +%s%3N) - start )) # The warm pass, same request. This is the number the fleet actually # experiences, and it is what "the second request answers under a second" is # measured against. start=$(date +%s%3N) if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \ -H 'Content-Type: application/json' \ --data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \ "${ENGINE_URL}/v1/audio/speech"; then log "voice '${name}': warm synthesis failed after a cold one that worked" fail=1 continue fi warm=$(( $(date +%s%3N) - start )) log "voice ${name} cold=${cold}ms warm=${warm}ms" warmed=$(( warmed + 1 )) done # Failure first, emptiness second, and not the other way round: a pass where # every voice failed also has `warmed == 0`, and reading that as "nothing to do, # success" is how an endpoint that answers 400 for every voice name gets deployed # on a Friday. if (( fail )); then log "${warmed} voice(s) warm, at least one failed (see the lines above)" exit 1 fi if (( warmed == 0 )); then log "no voice with a reference.wav under ${VOICES_DIR}; engine is up but speaks nothing" exit 0 fi log "${warmed} voice(s) registered and warm"