Repo state only: nothing deployed, no host touched, no container started. - `frank-tts-engine` on Dankclaw: `qwentts.cpp` commit 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4, resident `tts-server`, 1.7 B base talker Q4_K_M + Q8_0 tokenizer, container port 8880. It is deliberately NOT a fleet endpoint — `frank-tts` on 7772 is the endpoint consumers address. - Publishes NOTHING. A host-loopback publish would have been useless to the façade (netavark DNATs published ports only, so a bridge container cannot reach a host loopback bind — the shape that 502'd hermes) and would have been the one LAN-reachable surface of an unauthenticated synthesiser. Both voice containers sit on one committed quadlet network, `frank-tts-engine.network` pinned to 10.89.5.0/24 (next free /24 after open-notebook's 10.89.4.0/24), and peers address the engine by container name. Diagnostics and the deployer's health gate run inside the netns (`podman exec`, `wait_container_healthy`). - Cold-voice law honoured in the unit: the first request for a registered voice decodes its whole reference to snapshot codec state (6.2-8.0 s, first audio byte 5 530 ms), later ones restore in 0.5 ms, and the registry is memory-only. `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` with `TimeoutStartSec=900`; the `-` prefix keeps a failed warm from crash-looping the endpoint under `Restart=always`. It logs cold=/warm= per voice, so the under-a-second second request is proven on every boot. - Thread law recorded: `tts-server` has no `--threads`; src/backend.h uses hardware_concurrency()/2, so a cpuset pin silently changes the thread count. - Image builds from the pinned commit with the proven line (GGML_BLAS=OFF forced by this host, every GPU backend off, -j 8, no -DGGML_NATIVE because ggml defaults to -march=native for the building host). Two stages; the runtime stage COPYs both the entrypoint and the warmer, because a container that publishes nothing is reached with `podman exec`, not a port. - `scripts/download-frank-tts-engine-qwentts.sh` fetches the two GGUFs with byte and LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the deployer gates on --verify-only instead of pulling 1.4 GiB mid-deploy. - Registered in SERVICE_ORDER, DANKCLAW_SERVICES, the deploy-remote.sh dispatch, its pre-deploy requirement, deploy.sh's regex, the planner's path map (the whole `dankclaw/voiceservice/` tree mapped to nothing before this), and test-changed.sh ABOVE the `dankclaw/*.container` case whose `*` crosses `/`. - New seam tests/frank_tts_engine.bats (19): the Quadlet generator's own output for network/publish/boot/warm shape, entrypoint argv driven from the tracked env file, the warmer against a stub tts-server over a real socket (registration keys, ref_text absent, late engine, hung synthesis, name refusal), the fetcher's verifier, the pinned cmake line, and both routing tables. Suite: 422 passed. - Vault: [[Frank TTS Engine]] linked from the index; log.md entry for the day.
177 lines
8.6 KiB
Bash
Executable File
177 lines
8.6 KiB
Bash
Executable File
#!/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 cold-voice law). The FIRST speech request for a
|
|
# registered voice decodes the whole reference clip to snapshot its codec state:
|
|
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
|
|
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
|
|
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
|
|
# and re-registering a voice does not warm it — only synthesizing does. So a boot
|
|
# that registers the voices and stops leaves the first caller of the day paying
|
|
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
|
|
# speaks a second one so the journal carries the warm number too: the line
|
|
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
|
|
# request of a voice answers well under a second.
|
|
#
|
|
# 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": <dir>, "wav_b64": <base64 of
|
|
# reference.wav>}` 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 straight into the request body without ever being echoed.
|
|
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}"
|
|
|
|
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
|
|
|
|
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
|
|
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
|
|
-H 'Content-Type: application/json' --data-binary "$body" \
|
|
"${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_TEXT}\",\"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_TEXT}\",\"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"
|