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
+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