feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found #14
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user