diff --git a/dankclaw/voiceservice/tts-engine-warm.sh b/dankclaw/voiceservice/tts-engine-warm.sh index f835ece..e83f4c4 100755 --- a/dankclaw/voiceservice/tts-engine-warm.sh +++ b/dankclaw/voiceservice/tts-engine-warm.sh @@ -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 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 +# 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 diff --git a/tests/frank_tts_engine.bats b/tests/frank_tts_engine.bats index 74d9d95..4290b31 100755 --- a/tests/frank_tts_engine.bats +++ b/tests/frank_tts_engine.bats @@ -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