The NPU path is a dead end for this payload (verdict: report/tts-npu-spike-verdict-2026-10-19.md); the iGPU is the accelerator that exists on this box today, and it is already shared with frank-dragoon. - Containerfile.engine: -DGGML_VULKAN=ON, plus the three packages the door needs to configure — libvulkan-dev, spirv-headers, and glslc, which Ubuntu's glslang-tools does NOT ship (find_package(Vulkan COMPONENTS glslc REQUIRED) fails without it) — and libvulkan1 + mesa-vulkan-drivers (the RADV ICD) at runtime. No NVIDIA ICD on purpose: one Vulkan device in the enumeration is what makes the NAME Vulkan0 safe to write down. - tts-engine-start.sh: QWENTTS_DEVICE -> --device, default Vulkan0. A value that is set but EMPTY passes no flag at all, which is the CPU rollback; that is why the default is written as an -v test and not with :=, because := also fires on an empty value and would silently undo the rollback. - quadlet: AddDevice=/dev/dri + GroupAdd=keep-groups (a rootless container that drops its supplementary groups enumerates no render device). The node is not named: this host is hybrid, renderD128 is the NVIDIA card and renderD129 the amdgpu iGPU. - env: QWENTTS_DEVICE=Vulkan0 plus the device law in the header, including the rollback line. - tests: the two CPU-only assertions now assert the Vulkan shape, and a new one pins the rollback (empty device => no --device in argv). 451/451 green. Measured before writing any of it: the configure in the digest-pinned base answers "Found Vulkan: ... found components: glslc" and "Including Vulkan backend"; gate 1 (RADV GFX1151 visible to a rootless container off this base) is recorded in the verdict report.
125 lines
6.2 KiB
Bash
Executable File
125 lines
6.2 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 FLAG THAT 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 IS here, but conditionally: it reaches the binary only when
|
|
# QWENTTS_DEVICE is non-empty, and empty is the CPU rollback — the binary then
|
|
# takes its own default device, which is CPU because the CPU backend is always
|
|
# built. One line in scripts/frank-tts-engine.env moves the endpoint between the
|
|
# two shapes, with no rebuild. What must never reach argv is a device NAME the
|
|
# loader does not enumerate: that is not refused by the argument parser, it dies
|
|
# during device lookup with the loader's own error, so the journal reads as a
|
|
# load that failed rather than a configuration refusal.
|
|
#
|
|
# Everything defaults to the shape this endpoint runs: 1.7 B talker Q4_K_M with
|
|
# the Q8_0 tokenizer, one request at a time, on the private bridge, on the
|
|
# gfx1151 iGPU. An empty environment still boots that shape.
|
|
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 per-voice timings predictable. A second
|
|
# concurrent synthesis shares the device with the first — and on this box that
|
|
# device is also carrying frank-dragoon — so it buys no throughput and it
|
|
# doubles the wait for the caller behind it. The engine's own default is also 1;
|
|
# written here because it is a decision, not an accident.
|
|
: "${QWENTTS_MAX_BATCH:=1}"
|
|
|
|
# The compute device, NAMED and not numbered: `Vulkan0` is how ggml enumerates
|
|
# the gfx1151 iGPU on this host. An index would follow whatever order the
|
|
# loader's device list returns; a name says which device this endpoint means.
|
|
# The container sees exactly one Vulkan device because the image carries the
|
|
# Mesa ICD and no NVIDIA one, so the 3090 is not in the enumeration at all.
|
|
#
|
|
# EMPTY IS THE CPU ROLLBACK: a value that is set but EMPTY passes no `--device`,
|
|
# the binary picks its own default, and the CPU backend is always built — which
|
|
# is why moving this endpoint between the iGPU and the CPU is one line in
|
|
# scripts/frank-tts-engine.env and not a rebuild.
|
|
#
|
|
# That is also why the default below is NOT `${QWENTTS_DEVICE:=Vulkan0}`: `:=`
|
|
# fires on a set-but-empty variable as well as an unset one, so the rollback line
|
|
# `QWENTTS_DEVICE=` would be rewritten back to Vulkan0 and the endpoint would sit
|
|
# on the shared GPU with no way out short of a rebuild. Only UNSET takes the
|
|
# default.
|
|
if [[ ! -v QWENTTS_DEVICE ]]; then
|
|
QWENTTS_DEVICE=Vulkan0
|
|
fi
|
|
|
|
# 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
|
|
|
|
ARGS=(
|
|
--model "$QWENTTS_MODEL"
|
|
--codec "$QWENTTS_CODEC"
|
|
--alias "$QWENTTS_ALIAS"
|
|
--host "$QWENTTS_HOST"
|
|
--port "$QWENTTS_PORT"
|
|
--lang "$QWENTTS_LANG"
|
|
--max-batch "$QWENTTS_MAX_BATCH"
|
|
)
|
|
|
|
# Only when a device is named. An empty QWENTTS_DEVICE has to produce NO flag,
|
|
# because "the rollback shape" is argv identical to the pre-iGPU endpoint.
|
|
if [[ -n "$QWENTTS_DEVICE" ]]; then
|
|
ARGS+=(--device "$QWENTTS_DEVICE")
|
|
fi
|
|
|
|
exec "$QWENTTS_BIN" "${ARGS[@]}"
|