fix(frank-tts): make the boot warm pass survive both readers of its env

`WARM_TEXT` reaches the container **with its quotes**. The unit reads the env
file through podman's `--env-file`, which takes the literal remainder of the
line — no shell parsing — while bash `source` strips one layer. The hook
interpolated the raw value into the synthesis body, so with the quotes intact
the JSON was invalid and every voice logged "registered, but the warm synthesis
failed" while a hand-run hook (bash shape) succeeded. Reproduced both ways
against a real `podman run --env-file`, not by reading the man page.

The hook now strips exactly one matched pair — only when both ends are the same
quote AND the inner content carries another quote or a backslash, so a value
that legitimately contains quotes is never mangled — and then JSON-escapes what
is left, which is what makes a sentence like `He said "hi\" once"` arrive as
itself instead of corrupting the body.

The seam gains a test that reads `scripts/frank-tts-engine.env` BOTH ways —
`source` and the way `podman --env-file` does — and asserts the engine receives
the same sentence either way, in both shapes. A test that only sources the file
cannot see this bug, which is how it survived the first deploy.
This commit is contained in:
Frank
2026-10-18 18:06:06 -07:00
parent d135ae7f02
commit d336bdaa66
4 changed files with 95 additions and 8 deletions
+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