`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.
90 lines
4.6 KiB
Bash
Executable File
90 lines
4.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# tts-engine-start.sh — the entrypoint of the frank-tts-engine image
|
|
# (dankclaw/voiceservice/Containerfile.engine). It exists for one reason: the
|
|
# knobs of the arm A engine are command-line flags, while the fleet's knobs
|
|
# live in scripts/frank-tts-engine.env and reach the container through
|
|
# EnvironmentFile=. This is the translation layer, and nothing else — no
|
|
# supervision, no retries, no logging of its own. PID 1 stays the server, so
|
|
# `podman stop` and the unit's Restart=always drive it directly.
|
|
#
|
|
# THE FLAGS THAT ARE NOT HERE, and must stay absent:
|
|
#
|
|
# --threads / -t does not exist in this build. src/backend.h computes the
|
|
# CPU thread count as std::thread::hardware_concurrency()/2
|
|
# (the comment there: GEMM shares SIMD units across
|
|
# hyperthreads), and there is no flag to override it. So a
|
|
# CPU affinity pin (CPUQuota=, taskset, a cpuset) silently
|
|
# changes the engine's thread count instead of just its
|
|
# placement — pinning is deliberately not done anywhere in
|
|
# this stack. See the env file header for the same warning
|
|
# aimed at the operator.
|
|
# --device left at its default. Every GPU backend is compiled out of
|
|
# the image, so the default device pick can only land on
|
|
# CPU; naming a device would add a way to be wrong.
|
|
#
|
|
# Everything defaults to the shape the 2026-10-18 trial measured, so an empty
|
|
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with
|
|
# the Q8_0 tokenizer, one request at a time, on the private bridge.
|
|
set -euo pipefail
|
|
|
|
# The binary path. Overridable for one reason: the bats seam runs a stub
|
|
# instead of a 1.2 GiB model. Operators never set it — the image ships exactly
|
|
# one binary at /app/tts-server.
|
|
: "${QWENTTS_BIN:=/app/tts-server}"
|
|
|
|
# Paths are CONTAINER paths (the host directories they come from are named in
|
|
# scripts/frank-tts-engine.env and mounted by the Quadlet unit).
|
|
: "${QWENTTS_MODEL:=/models/qwen-talker-1.7b-base-Q4_K_M.gguf}"
|
|
: "${QWENTTS_CODEC:=/models/qwen-tokenizer-12hz-Q8_0.gguf}"
|
|
|
|
# Reported by GET /v1/models, and what scripts/frank-tts.env's
|
|
# TTS_UPSTREAM_MODEL names. A model id, not a filename: the file name would
|
|
# leak a quantization choice into every consumer's config.
|
|
: "${QWENTTS_ALIAS:=frank-tts-engine}"
|
|
|
|
# 0.0.0.0 inside the container is not "public". tts-server defaults to
|
|
# 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}"
|
|
|
|
# The language label used when a request omits one. `auto` is the upstream
|
|
# default and what the trial ran.
|
|
: "${QWENTTS_LANG:=auto}"
|
|
|
|
# One request at a time. The server-side queue is a serialised FIFO at this
|
|
# setting, which is what keeps the measured per-voice timings true; batching
|
|
# on a CPU build splits the same cores between two syntheses and the warm RTF
|
|
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own
|
|
# default is also 1; it is written here because it is a decision, not an
|
|
# accident.
|
|
: "${QWENTTS_MAX_BATCH:=1}"
|
|
|
|
# Fail loudly BEFORE binding, so the journal says which artifact is missing
|
|
# instead of the unit reporting a container that starts and dies. This is the
|
|
# self-healing path: Restart=always re-runs it every RestartSec, and the same
|
|
# sentence repeats until the operator fetches the weights
|
|
# (scripts/download-frank-tts-engine-qwentts.sh).
|
|
for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
|
|
if [[ ! -r "$artifact" ]]; then
|
|
echo "frank-tts-engine: cannot read ${artifact}; run scripts/download-frank-tts-engine-qwentts.sh on the host (the /models mount is read-only and holds the two GGUF files)" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
exec "$QWENTTS_BIN" \
|
|
--model "$QWENTTS_MODEL" \
|
|
--codec "$QWENTTS_CODEC" \
|
|
--alias "$QWENTTS_ALIAS" \
|
|
--host "$QWENTTS_HOST" \
|
|
--port "$QWENTTS_PORT" \
|
|
--lang "$QWENTTS_LANG" \
|
|
--max-batch "$QWENTTS_MAX_BATCH"
|