diff --git a/dankclaw/voiceservice/tts-engine-start.sh b/dankclaw/voiceservice/tts-engine-start.sh index 105a2e7..fb282ad 100755 --- a/dankclaw/voiceservice/tts-engine-start.sh +++ b/dankclaw/voiceservice/tts-engine-start.sh @@ -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 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}" diff --git a/dankclaw/voiceservice/tts-engine-warm.sh b/dankclaw/voiceservice/tts-engine-warm.sh index 0c4e7bc..f835ece 100755 --- a/dankclaw/voiceservice/tts-engine-warm.sh +++ b/dankclaw/voiceservice/tts-engine-warm.sh @@ -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 diff --git a/scripts/frank-tts-engine.env b/scripts/frank-tts-engine.env index 8d4fe4e..605c1b4 100644 --- a/scripts/frank-tts-engine.env +++ b/scripts/frank-tts-engine.env @@ -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 diff --git a/tests/frank_tts_engine.bats b/tests/frank_tts_engine.bats index 3e01a63..3cbe478 100755 --- a/tests/frank_tts_engine.bats +++ b/tests/frank_tts_engine.bats @@ -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