fix(frank-tts): the warm hook died on the kernel argument cap; the cold-voice law is retired by measurement #15
@@ -16,17 +16,17 @@
|
||||
# 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.
|
||||
# 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 <name>
|
||||
# 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
|
||||
@@ -47,7 +47,9 @@
|
||||
#
|
||||
# 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.
|
||||
# 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:
|
||||
@@ -103,6 +105,18 @@ 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' "$*"; }
|
||||
|
||||
@@ -146,9 +160,16 @@ for dir in "$VOICES_DIR"/*/; do
|
||||
continue
|
||||
fi
|
||||
|
||||
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
|
||||
# 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" \
|
||||
-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
|
||||
|
||||
@@ -394,6 +394,37 @@ assert {s["response_format"] for s in speech} == {"wav"}, speech
|
||||
PY
|
||||
}
|
||||
|
||||
@test "a reference clip too big for one argument still registers" {
|
||||
# The kernel caps a SINGLE command-line string at 32 pages = 131,072 bytes
|
||||
# (MAX_ARG_STRLEN), and a shipped reference clip base64's to about 1.27 MB —
|
||||
# so a warmer that hands the registration body to curl as an argument dies
|
||||
# before curl runs. That is exactly what the first real boot of 2026-10-18
|
||||
# hit with the real packs: `/usr/bin/curl: Argument list too long`, then the
|
||||
# misleading "registration failed (HTTP no response)" for all three voices.
|
||||
# The stub engine is a real listener and curl is the real binary, so this
|
||||
# test fails for the same kernel reason the host did.
|
||||
start_engine_stub
|
||||
local big="$TEST_TMP/bigvoices/bigvoice"
|
||||
mkdir -p "$big"
|
||||
head -c 300000 /dev/zero | tr '\0' 'A' > "$big/reference.wav"
|
||||
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/bigvoices" \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"voice bigvoice cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
|
||||
|
||||
# And the audio that arrived is the audio that was on disk, whole.
|
||||
python3 - "$TEST_TMP/journal.jsonl" "$big/reference.wav" <<'PY'
|
||||
import hashlib, json, sys
|
||||
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||
regs = [e for e in entries if e["path"] == "/v1/audio/voices"]
|
||||
assert len(regs) == 1, entries
|
||||
assert regs[0]["name"] == "bigvoice", regs[0]
|
||||
want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
|
||||
assert regs[0]["wav_sha256"] == want, (len(regs[0]["wav_sha256"]), want)
|
||||
PY
|
||||
}
|
||||
|
||||
@test "the warm sentence survives both readers of the env file intact" {
|
||||
# scripts/frank-tts-engine.env is read two ways and they disagree about
|
||||
# quotes. bash (`set -a; source`, what the seams and a human do) strips the
|
||||
|
||||
Reference in New Issue
Block a user