feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found #14

Merged
dank merged 10 commits from feat/frank-tts-engine into main 2026-10-18 18:30:22 -07:00
4 changed files with 95 additions and 8 deletions
Showing only changes of commit d336bdaa66 - Show all commits
+9 -4
View File
@@ -43,10 +43,15 @@ set -euo pipefail
: "${QWENTTS_ALIAS:=frank-tts-engine}"
# 0.0.0.0 inside the container is not "public". tts-server defaults to
# 127.0.0.1, which inside a container netns is reachable by nothing — the
# façade dials this container by its bridge address. The container publishes
# 8880 on the host's loopback only (diagnostics and the boot warmer), so the
# only namespace that can reach 0.0.0.0:8880 here is the private voice bridge.
# 127.0.0.1 (its own usage prints `--host <ip> Listen address (default:
# 127.0.0.1)`), and inside a container netns a loopback bind is reachable by
# nothing outside that namespace — the façade dials this container by its bridge
# address on the private voice network, which is why the address has to be
# broad. The unit PUBLISHES NOTHING (see
# dankclaw/voiceservice/quadlet/frank-tts-engine.container: no PublishPort at
# all, not even 127.0.0.1:8880), so the only namespaces that can reach
# 0.0.0.0:8880 are that bridge and this container's own loopback — and its own
# loopback is exactly where the boot warmer and the health probe speak.
: "${QWENTTS_HOST:=0.0.0.0}"
: "${QWENTTS_PORT:=8880}"
+27 -2
View File
@@ -78,6 +78,31 @@ set -euo pipefail
# 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//\"/\\\"}"
fail=0
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
@@ -135,7 +160,7 @@ for dir in "$VOICES_DIR"/*/; do
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\"}" \
--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
@@ -149,7 +174,7 @@ for dir in "$VOICES_DIR"/*/; do
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\"}" \
--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
+14 -2
View File
@@ -126,8 +126,20 @@ HEALTH_WAIT=300
# second (warm-timing) pass repeats. Fixed on purpose: the cold=/warm= numbers in
# the journal are comparable across boots only if the input is the same. Kept
# short because warming a library is serial — one cold decode per voice, 6-8 s
# each, inside the unit's start deadline. Quoted because podman's env-file parser
# and a shell `source` must both read the sentence as ONE value.
# each, inside the unit's start deadline.
# QUOTED, and the quoting means two different things to the two readers of this
# file, which is the trap here:
# * bash (`set -a; source`, how the repo's seams and any human read it) needs
# the quotes — an unquoted value with spaces is word-split and the second
# word is executed as a command.
# * podman's `--env-file` (how the unit actually reads it) does NOT strip
# quotes: measured 2026-10-18 against the image's pinned base, the value
# arrives as `"Warm up, one two three."` WITH them. Interpolated into the
# hook's JSON body that is `""Warm up…""`, the engine's parser rejects it,
# every voice stays cold and the boot still looks healthy.
# So the value stays quoted for bash and the hook strips one matched pair before
# it builds the request, then escapes what is left. Both readers now agree, and a
# sentence containing a quote or a backslash still arrives verbatim.
WARM_TEXT="Warm up, one two three."
# Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is
+45
View File
@@ -394,6 +394,51 @@ assert {s["response_format"] for s in speech} == {"wav"}, speech
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
# quotes around a value with spaces. podman's --env-file — how the unit really
# reads it — does NOT: measured 2026-10-18 against the image's pinned base,
# the value lands in the container WITH its quotes. Interpolated raw into the
# hook's JSON body that is `""Warm up…"",` — the engine answers 400, no voice
# warms, and the boot still reads healthy. So the hook unwraps one
# shell-style layer and then escapes, and BOTH shapes must arrive as the
# sentence the operator wrote.
start_engine_stub
# Shape A: what podman delivers for `WARM_TEXT="Warm up, one two three."`.
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
WARM_TEXT='"Warm up, one two three."' bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
import json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
assert not [e for e in entries if e.get("invalid_json")], entries
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
assert len(speech) == 2, entries
assert {s["input"] for s in speech} == {"Warm up, one two three."}, speech
PY
# Shape B: a value that starts AND ends with a quote but carries more quotes
# INSIDE — exactly the shape an unwrap rule must refuse, because peeling the
# outer pair would delete characters the operator wrote. It must arrive
# character for character (trailing quote included), which also proves the
# escaping does not mangle what it is protecting.
: > "$TEST_TMP/journal.jsonl"
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
WARM_TEXT='"He said "hi"\ once"' bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
import json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
assert not [e for e in entries if e.get("invalid_json")], entries
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
assert len(speech) == 2, entries
assert {s["input"] for s in speech} == {'"He said "hi"\\ once"'}, speech
PY
}
@test "a directory without reference.wav is not a voice, and a mixed-case name is refused" {
# `justamanifest` must be invisible; `Narrator2` must be loud. The engine
# lowercases every voice name (tts_voice_name) while frank-tts matches the