Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d76854a1e |
No files matched your search
@@ -0,0 +1,131 @@
|
||||
# frank-tts-engine — the arm A TTS engine: qwentts.cpp's resident `tts-server`
|
||||
# (the CPU binary that frank-tts on :7772 forwards to). The runbook is
|
||||
# [[Frank TTS Engine]]; the fleet-facing endpoint is [[Frank TTS]].
|
||||
#
|
||||
# WHAT THIS IS. The upstream source is NOT vendored here: the image clones
|
||||
# ServeurpersoCom/qwentts.cpp at one pinned commit and builds it. Rebuilding is
|
||||
# therefore a network operation (github.com plus objects.githubusercontent.com
|
||||
# for the `ggml` submodule), and build_compose_image in scripts/deploy-remote.sh
|
||||
# skips it whenever this Containerfile's sha256 label already matches the built
|
||||
# image — the same offline-safe idiom as loki/alloy/comfyui, so a re-deploy of
|
||||
# an unchanged engine needs no network.
|
||||
#
|
||||
# THE PINNED COMMIT IS THE CONTRACT. 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
|
||||
# is the commit the 2026-10-18 CPU trial on Dankclaw measured (server-side TTFA
|
||||
# 94-112 ms, warm RTF 0.55-0.67, run spread +-0.01 — the numbers that won the
|
||||
# engine decision). Moving it means re-measuring, not just editing this line.
|
||||
# `ggml` is a submodule of the repo (the only one; vendor/cpp-httplib and
|
||||
# vendor/yyjson are ordinary directories in the tree), and the submodule commit
|
||||
# is recorded in the parent tree, so cloning the parent commit recursively is
|
||||
# reproducible without a second pin.
|
||||
#
|
||||
# THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim:
|
||||
#
|
||||
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \
|
||||
# -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \
|
||||
# -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF \
|
||||
# -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \
|
||||
# && cmake --build build -j 8
|
||||
#
|
||||
# Every flag is load-bearing, so do not "tidy" it:
|
||||
# GGML_BLAS=OFF is FORCED, not a preference — Dankclaw has libopenblas.so.0
|
||||
# but no BLAS dev headers, so the upstream default (BLAS on) fails to
|
||||
# configure. Upstream's own Dockerfile and buildcpu.sh both pass BLAS on,
|
||||
# which is why this file does not copy them.
|
||||
# GGML_CCACHE=OFF because a container build layer has no persistent ccache
|
||||
# directory to hit; on, it only writes a useless one.
|
||||
# GGML_OPENMP=ON is what makes the runtime need libgomp1 (installed below).
|
||||
# GGML_BACKEND_DL=OFF / GGML_CPU_ALL_VARIANTS=OFF: upstream's images build
|
||||
# every x86 ISA as a loadable backend so one image runs anywhere. This one
|
||||
# is built on the machine that runs it, so the single native CPU backend is
|
||||
# what was measured. That is also why no -DGGML_NATIVE is passed: ggml
|
||||
# defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS
|
||||
# host — correct while build host == run host, wrong if anyone ever builds
|
||||
# this image elsewhere, which is why the fleet builds it on Dankclaw.
|
||||
# CUDA/HIP/VULKAN/METAL/SYCL/RPC all OFF: this is a CPU endpoint (the trial
|
||||
# ran on CPU; the box's GPUs belong to the text endpoints).
|
||||
#
|
||||
# BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so
|
||||
# tts-server links libggml*.so rather than swallowing them; the runtime stage
|
||||
# copies the binary and its shared objects out of the build tree and points
|
||||
# LD_LIBRARY_PATH at them, exactly as upstream's own Dockerfile does (the RPATH
|
||||
# baked at link time names the build directory, which does not exist here).
|
||||
#
|
||||
# BASE PINNED BY DIGEST, like every other image this fleet builds: the digest
|
||||
# is the multi-arch INDEX of ubuntu:24.04 as resolved from Docker Hub on
|
||||
# 2026-10-18; Dankclaw takes the amd64 entry. One base for both stages so there
|
||||
# is exactly one thing to re-resolve.
|
||||
#
|
||||
# NO HEALTHCHECK IN THIS FILE. The probe lives in the Quadlet unit
|
||||
# (HealthCmd=), which is the fleet pattern for anything with a shell — see the
|
||||
# alertmanager/prometheus units, and the opposite case in dankclaw/loki, whose
|
||||
# distroless image has no /bin/sh for the generator's CMD-SHELL wrapping.
|
||||
# `curl` is therefore installed in the runtime stage, for that probe only.
|
||||
|
||||
ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac
|
||||
|
||||
# ------------------------------------------------------------------ build
|
||||
FROM ${BASE_IMAGE} AS build
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates cmake g++ git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Checked out detached at a full SHA, then the recorded submodule. No branch
|
||||
# name anywhere: a branch moves, this commit does not.
|
||||
ARG QWENTTS_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
|
||||
RUN git clone --no-checkout https://github.com/ServeurpersoCom/qwentts.cpp /src \
|
||||
&& git -C /src checkout --detach "${QWENTTS_COMMIT}" \
|
||||
&& git -C /src submodule update --init --depth 1 \
|
||||
&& git -C /src rev-parse HEAD \
|
||||
&& git -C /src submodule status
|
||||
|
||||
WORKDIR /src
|
||||
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
-DGGML_CCACHE=OFF \
|
||||
-DGGML_BACKEND_DL=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=OFF \
|
||||
-DGGML_OPENMP=ON \
|
||||
-DGGML_BLAS=OFF \
|
||||
-DGGML_CUDA=OFF \
|
||||
-DGGML_HIP=OFF \
|
||||
-DGGML_VULKAN=OFF \
|
||||
-DGGML_METAL=OFF \
|
||||
-DGGML_SYCL=OFF \
|
||||
-DGGML_RPC=OFF \
|
||||
&& cmake --build build -j 8
|
||||
|
||||
# ---------------------------------------------------------------- runtime
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# libgomp1: the OpenMP runtime the build above links against. curl: the unit's
|
||||
# HealthCmd. ca-certificates: so that curl can speak https if anyone ever
|
||||
# points a probe at a URL with a scheme this host terminates elsewhere.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
|
||||
# model directory are host state mounted read-only, and one uid across the pair
|
||||
# means one thing to remember about what is world-readable.
|
||||
RUN adduser --system --uid 10000 --home /app --shell /usr/sbin/nologin tts
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/build/tts-server /src/build/*.so* /app/
|
||||
COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh
|
||||
# The boot warmer lives INSIDE the image because the engine publishes no port:
|
||||
# the unit reaches it with `podman exec`, and the script needs the same curl,
|
||||
# base64 and /voices the engine's own namespace has. It is a sibling of
|
||||
# start.sh, not a second entrypoint — CMD stays the server.
|
||||
COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh
|
||||
RUN chmod 755 /app/start.sh /app/warm-voices.sh
|
||||
|
||||
# The binary's RPATH points inside the build tree; this is what makes it find
|
||||
# the libggml*.so copied beside it.
|
||||
ENV LD_LIBRARY_PATH=/app
|
||||
|
||||
USER tts
|
||||
EXPOSE 8880
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Quadlet unit: frank-tts-engine (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.container
|
||||
# Unit name: frank-tts-engine.service (container name: frank-tts-engine)
|
||||
#
|
||||
# WHAT IT IS. The arm A TTS engine: qwentts.cpp (pinned commit
|
||||
# 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4) built into an image by
|
||||
# dankclaw/voiceservice/Containerfile.engine, running its resident `tts-server`
|
||||
# with the 1.7 B base talker Q4_K_M and the Q8_0 12 Hz tokenizer loaded ONCE at
|
||||
# startup. It is deliberately NOT a fleet endpoint: the endpoint consumers
|
||||
# address is **frank-tts on 7772** ([[Frank TTS]]), which resolves voice names,
|
||||
# re-encodes to mp3 and owns the timeout budget. Runbook: [[Frank TTS Engine]].
|
||||
#
|
||||
# WHY THIS ENGINE (Frank's call, 2026-10-18, criterion "whatever is better for
|
||||
# live output"): server-side time-to-first-audio 94-112 ms (86-451 ms end to
|
||||
# end through the façade), streaming that stays ahead of real time at warm RTF
|
||||
# 0.55-0.67, and a run-to-run spread of +-0.01 — against the mainline CLI path's
|
||||
# 0.93-0.99 at +-0.25, which crosses 1.0 often enough to stall a live stream.
|
||||
# The A/B that decided it is "Decision: arm A for live output" in
|
||||
# [[Local TTS and Voice Cloning]].
|
||||
#
|
||||
# IMAGE. Built locally by deploy-remote.sh (build_compose_image), like
|
||||
# loki/alloy/frank-tts. `Pull=never` plus the `localhost/` prefix are required:
|
||||
# this host's quadlet path cannot resolve a locally built tag from local storage
|
||||
# otherwise. The build is a network operation (it clones the pinned commit) and
|
||||
# is skipped when the Containerfile hash label already matches.
|
||||
#
|
||||
# NETWORK, AND WHY IT PUBLISHES NOTHING. The container sits on exactly one
|
||||
# network: the private `frank-tts-engine.network` (10.89.5.0/24). The façade
|
||||
# joins the same network and dials `http://frank-tts-engine:8880` — the
|
||||
# container DNS name, on the network that owns it. There is no `PublishPort` of
|
||||
# any kind, not even `127.0.0.1:8880`, and the reason is the lesson that 502'd
|
||||
# hermes: netavark DNATs published ports only, so a host-loopback publish is not
|
||||
# a path to a peer container — the façade could never have used it, and a bridge
|
||||
# container cannot reach the host's own loopback bind either (see
|
||||
# [[Container Network Namespaces]]). A publish would therefore buy exactly one
|
||||
# thing: an extra reachable surface on an engine that has no authentication, on
|
||||
# a box whose CPU is also carrying the text endpoints. Diagnostics do not need
|
||||
# it either — `podman exec frank-tts-engine curl http://127.0.0.1:8880/health`
|
||||
# speaks to the server from inside its own namespace, which is where the address
|
||||
# is valid.
|
||||
#
|
||||
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident
|
||||
# (2026-10-18 trial); no memory limit is set because the box has the room and a
|
||||
# limit would convert a busy moment into an OOM kill mid-utterance. No
|
||||
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of
|
||||
# the image, the endpoint is CPU-only by decision, and the GPUs on this host
|
||||
# belong to the text endpoints.
|
||||
#
|
||||
# THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice
|
||||
# becomes known to the engine only through POST /v1/audio/voices, and the
|
||||
# registration is MEMORY ONLY — it does not survive a restart, which is exactly
|
||||
# why the warm hook below runs on every start. That hook runs INSIDE this
|
||||
# container (see ExecStartPost), so it reads the packs at /voices; `tts-server`
|
||||
# itself never opens the directory, it receives the reference audio as base64 in
|
||||
# the registration body. The façade mounts the same host directory read-only for
|
||||
# its own name list, so one directory stays the single source of a voice name.
|
||||
#
|
||||
# COLD-VOICE LAW, THE REASON EXECSTARTPOST EXISTS. The FIRST speech request for
|
||||
# a registered voice decodes the whole reference clip to snapshot codec state:
|
||||
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request restores the
|
||||
# snapshot in 0.5 ms. So the unit warms every voice at boot, serially (~6-8 s
|
||||
# each), and `TimeoutStartSec=900` is what keeps a growing voice library from
|
||||
# tripping systemd's default 90 s start deadline. The leading `-` on
|
||||
# ExecStartPost is load-bearing: with `Restart=always`, a warmer that exits
|
||||
# non-zero (a pack missing its reference.wav, the engine still loading past the
|
||||
# warmer's own patience) would restart the whole container and crash-loop the
|
||||
# endpoint forever. A failed warm must be a journal line, not a restart.
|
||||
#
|
||||
# The hook is `podman exec`, not a host script, because there is no address the
|
||||
# host could dial: this container publishes nothing (above). Exec-ing into it
|
||||
# also means the warmer inherits this unit's EnvironmentFile, so every knob it
|
||||
# has lives in scripts/frank-tts-engine.env beside the engine's own, and it sees
|
||||
# the same /voices and 127.0.0.1:8880 the engine does.
|
||||
#
|
||||
# HEALTH. `GET /health` answers `{"status":"ok"}` from the HTTP thread, not the
|
||||
# synthesis queue, so a busy engine still reads healthy and a dead one does not.
|
||||
# HealthStartPeriod covers the first model load. The command is argument-only
|
||||
# and needs /bin/sh — the generator wraps HealthCmd as CMD-SHELL — so the image
|
||||
# installs curl (see the opposite case in dankclaw/loki, whose distroless base
|
||||
# has no shell).
|
||||
|
||||
[Unit]
|
||||
Description=frank-tts-engine (arm A qwentts.cpp TTS engine, internal :8880)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-tts-engine
|
||||
Image=localhost/franksdank/frank-tts-engine:latest
|
||||
Pull=never
|
||||
Network=frank-tts-engine.network
|
||||
# No PublishPort, at all: the only reachable address this server has is the
|
||||
# bridge address the façade resolves by name. Diagnostics use `podman exec`.
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env
|
||||
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro
|
||||
Volume=/home/dank/voices:/voices:ro
|
||||
HealthCmd=curl -fsS http://127.0.0.1:8880/health
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=120s
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Model load (tens of seconds) plus one cold decode per registered voice.
|
||||
TimeoutStartSec=900
|
||||
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,52 @@
|
||||
# Quadlet network: the private bridge of the voice pair (rootless).
|
||||
#
|
||||
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.network
|
||||
# Unit name: frank-tts-engine-network.service (podman network name:
|
||||
# systemd-frank-tts-engine — quadlet prefixes "systemd-", so probe the runtime
|
||||
# name, never the filename; see [[Container Network Namespaces]]).
|
||||
#
|
||||
# WHY A PRIVATE BRIDGE FOR A PAUSE THAT LOOKS PUBLIC-FACING. frank-tts (:7772,
|
||||
# published, the one endpoint consumers address) and frank-tts-engine (:8880,
|
||||
# never published, the arm A qwentts.cpp server behind it) could both have
|
||||
# stayed on the fleet-wide dankclaw_default and resolved each other by name.
|
||||
# They are split off anyway, for two reasons:
|
||||
#
|
||||
# 1. THE HOST-LOOPBACK TRAP. The phase-1 shape in `scripts/frank-tts.env` still
|
||||
# names `host.containers.internal:8880` as the upstream, on the assumption the
|
||||
# engine runs on the host and publishes `127.0.0.1:8880`. That cannot work:
|
||||
# netavark DNATs published ports only, a published port is not on any podman
|
||||
# bridge, and a container cannot reach the host's own loopback bind — the same
|
||||
# shape that 502'd hermes, and the reason the signal-relay hop needs a host LAN
|
||||
# bind instead ([[Container Network Namespaces]], [[Homepage Status Probes]]).
|
||||
# Putting both containers on one committed network removes the guess: the
|
||||
# façade dials the engine's container DNS name on the network that owns it, and
|
||||
# the engine publishes nothing at all.
|
||||
#
|
||||
# 2. THE ENGINE HAS NO AUTH AND NO QUEUE LIMIT WORTH THE NAME. `tts-server`
|
||||
# answers anyone who can open a socket, and at `--max-batch 1` a second
|
||||
# caller simply waits behind the first. Left on dankclaw_default every
|
||||
# container on that net — open-webui, hermes, the *arr proxies — could drive
|
||||
# a CPU synthesiser that occupies every other core on a box that also serves
|
||||
# two text endpoints. On this bridge the only dialer is frank-tts: the engine
|
||||
# publishes no port, so not even the host has an address to reach it at, and
|
||||
# diagnostics go through `podman exec` instead.
|
||||
#
|
||||
# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead
|
||||
# per-container userspace helper left a container that accepted TCP and reset
|
||||
# every request for two days, logging nothing). A bridge has no such helper.
|
||||
#
|
||||
# Subnet registry on dankclaw (rootless):
|
||||
# 10.88.0.0/16 default "podman" bridge
|
||||
# 10.89.0.0/24 gitea (systemd-gitea)
|
||||
# 10.89.1.0/24 compose stack (dankclaw_default)
|
||||
# 10.89.2.0/24 searxng (systemd-searxng)
|
||||
# 10.89.3.0/24 open-webui (systemd-open-webui)
|
||||
# 10.89.4.0/24 open-notebook (systemd-open-notebook)
|
||||
# 10.89.5.0/24 voice pair (systemd-frank-tts-engine) <- this file
|
||||
# Pinned so the two container IPs stay deterministic across reboots, like the
|
||||
# other pinned nets; 10.89.5.0/24 was the next unclaimed /24 in this registry.
|
||||
|
||||
[Network]
|
||||
Driver=bridge
|
||||
Subnet=10.89.5.0/24
|
||||
Gateway=10.89.5.1
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/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, 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.
|
||||
: "${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"
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# tts-engine-warm.sh — the boot warmer of the arm A TTS engine, shipped inside
|
||||
# the frank-tts-engine image at /app/warm-voices.sh
|
||||
# (dankclaw/voiceservice/Containerfile.engine).
|
||||
#
|
||||
# WHO RUNS IT. Two callers, same binary:
|
||||
#
|
||||
# 1. The Quadlet unit, as
|
||||
# `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh`
|
||||
# — after every start of the container, including every boot.
|
||||
# `podman exec` rather than a host script because the engine publishes no
|
||||
# port at all, so there is no address the host could dial; and because an
|
||||
# exec inherits the unit's EnvironmentFile, which is what puts every knob of
|
||||
# this script in scripts/frank-tts-engine.env next to the engine's own.
|
||||
# 2. scripts/deploy-remote.sh's deploy_frank_tts_engine, once per deploy, so a
|
||||
# voice that did not warm becomes a deploy result instead of only a journal
|
||||
# line (the unit's `-` prefix deliberately cannot fail the unit).
|
||||
#
|
||||
# WHY IT EXISTS AT ALL (the cold-voice law). The FIRST speech request for a
|
||||
# registered voice decodes the whole reference clip to snapshot its codec state:
|
||||
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
|
||||
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
|
||||
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
|
||||
# and re-registering a voice does not warm it — only synthesizing does. So a boot
|
||||
# that registers the voices and stops leaves the first caller of the day paying
|
||||
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
|
||||
# speaks a second one so the journal carries the warm number too: the line
|
||||
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
|
||||
# request of a voice answers well under a second.
|
||||
#
|
||||
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
|
||||
# the unit, the same packs frank-tts mounts for its own name list. A pack is a
|
||||
# directory that contains reference.wav; everything else in it (manifest.json, the
|
||||
# engine's reference.spk / reference.rvq extraction, a 16 kHz variant) is not read
|
||||
# here. The registration payload is `{"name": <dir>, "wav_b64": <base64 of
|
||||
# reference.wav>}` with NO ref_text, which selects the embedding-only (x-vector)
|
||||
# voice — the shipped default, warm RTF ~0.57-0.63 against ICL's 0.67, and the one
|
||||
# this fleet can produce honestly because it has no trustworthy transcript for the
|
||||
# audition crops. Adding ref_text (with the codes) would flip that voice to
|
||||
# in-context-learning mode: better fidelity on a good transcript, slower, and only
|
||||
# worth it for batch work like a podcast render. That is a per-voice opt-in this
|
||||
# script deliberately does NOT exercise, and the door stays open: a pack that
|
||||
# wants ICL needs its text in the pack plus one key in the payload below.
|
||||
#
|
||||
# A lone `spk_b64` is a 400 (src/tts-server.h:480 — it is `wav_b64` or BOTH
|
||||
# `spk_b64` and `rvq_b64`), which is why the WAV travels and the .spk stays home.
|
||||
#
|
||||
# NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes
|
||||
# are discarded, the synthesized text comes from the env file, and the reference
|
||||
# clip is base64'd straight into the request body without ever being echoed.
|
||||
set -euo pipefail
|
||||
|
||||
# The engine's own address, from inside its namespace. Loopback is valid HERE:
|
||||
# this script runs in the container, next to the server. There is deliberately no
|
||||
# host-visible address to use instead — the unit publishes nothing, because a
|
||||
# host-loopback publish would be unreachable from the façade anyway (netavark
|
||||
# DNATs published ports only) while adding a surface on an unauthenticated
|
||||
# server.
|
||||
: "${ENGINE_URL:=http://127.0.0.1:8880}"
|
||||
|
||||
# Container path; the host directory behind it is named in the Quadlet unit.
|
||||
: "${VOICES_DIR:=/voices}"
|
||||
|
||||
# How long to wait for the engine to answer /health. Model load dominates: the
|
||||
# two GGUFs (1.2 GiB + 291 MB) are read and the context built before the HTTP
|
||||
# thread binds, and a boot races every other unit on this box.
|
||||
: "${HEALTH_WAIT:=300}"
|
||||
|
||||
# One short sentence, long enough to exercise the codec snapshot and short enough
|
||||
# that warming a library of voices is seconds, not minutes. Never varied per run:
|
||||
# the cold/warm numbers in the journal should mean the same thing every boot. The
|
||||
# synthesis asks for `wav`, the buffered path — the streamed path (`pcm`) is
|
||||
# different audio at the same seed, and `wav` is also what frank-tts asks for.
|
||||
: "${WARM_TEXT:=Warm up, one two three.}"
|
||||
|
||||
# Per-request ceiling. A cold voice is 6-8 s by measurement; anything approaching
|
||||
# this is a stalled engine and should be reported rather than waited on forever —
|
||||
# systemd counts the whole warm pass against the unit's start deadline.
|
||||
: "${REQUEST_TIMEOUT:=120}"
|
||||
|
||||
fail=0
|
||||
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
||||
|
||||
# Wait for /health. It answers from the HTTP thread, so a 200 means the server is
|
||||
# listening; it says nothing about voices, which is what the rest of this script
|
||||
# is for.
|
||||
ready=0
|
||||
for ((i = 1; i <= HEALTH_WAIT; i++)); do
|
||||
if curl -fsS --max-time 5 "${ENGINE_URL}/health" >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if (( ready == 0 )); then
|
||||
log "engine did not answer ${ENGINE_URL}/health within ${HEALTH_WAIT}s; nothing warmed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$VOICES_DIR" ]]; then
|
||||
log "no voice directory at ${VOICES_DIR}; nothing to warm (frank-tts will answer 400 with an empty voice list until packs are built)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
warmed=0
|
||||
for dir in "$VOICES_DIR"/*/; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
name="${dir%/}"
|
||||
name="${name##*/}"
|
||||
if [[ ! -f "${dir}reference.wav" ]]; then
|
||||
continue # not a pack; frank-tts refuses this name too
|
||||
fi
|
||||
# The engine lowercases every voice name (tts_voice_name) while frank-tts
|
||||
# matches the directory name exactly, so a mixed-case pack would work by
|
||||
# accident on one side and not the other. Refuse it loudly instead. Pack
|
||||
# names are also JSON string content here, so the character class is what
|
||||
# keeps a directory name from writing structure into the request body.
|
||||
if [[ ! "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
log "voice '${name}': name must be lowercase [a-z0-9._-] (the engine lowercases names; the shim does not) — skipped"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
|
||||
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' --data-binary "$body" \
|
||||
"${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then
|
||||
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# The cold pass. Its output is discarded; the point is the codec snapshot it
|
||||
# leaves inside the engine.
|
||||
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\"}" \
|
||||
"${ENGINE_URL}/v1/audio/speech"; then
|
||||
log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
cold=$(( $(date +%s%3N) - start ))
|
||||
|
||||
# The warm pass, same request. This is the number the fleet actually
|
||||
# experiences, and it is what "the second request answers under a second" is
|
||||
# measured against.
|
||||
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\"}" \
|
||||
"${ENGINE_URL}/v1/audio/speech"; then
|
||||
log "voice '${name}': warm synthesis failed after a cold one that worked"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
warm=$(( $(date +%s%3N) - start ))
|
||||
|
||||
log "voice ${name} cold=${cold}ms warm=${warm}ms"
|
||||
warmed=$(( warmed + 1 ))
|
||||
done
|
||||
|
||||
# Failure first, emptiness second, and not the other way round: a pass where
|
||||
# every voice failed also has `warmed == 0`, and reading that as "nothing to do,
|
||||
# success" is how an endpoint that answers 400 for every voice name gets deployed
|
||||
# on a Friday.
|
||||
if (( fail )); then
|
||||
log "${warmed} voice(s) warm, at least one failed (see the lines above)"
|
||||
exit 1
|
||||
fi
|
||||
if (( warmed == 0 )); then
|
||||
log "no voice with a reference.wav under ${VOICES_DIR}; engine is up but speaks nothing"
|
||||
exit 0
|
||||
fi
|
||||
log "${warmed} voice(s) registered and warm"
|
||||
@@ -1,483 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-voice-pack.sh — turn a SOURCE recording plus a CROP OFFSET into a
|
||||
# deployable frank-tts voice pack: the reference WAV, the engine's own speaker
|
||||
# artifacts made from it, and manifest.json provenance.
|
||||
#
|
||||
# THE PACK. $PACKS_DIR/<voice>/ carries the three members the pack law in
|
||||
# scripts/frank-tts.env names:
|
||||
#
|
||||
# reference.wav the crop: 24 kHz mono PCM (pcm_s16le), 18-25 s of clean
|
||||
# single-speaker audio. This is what makes a voice NAME real
|
||||
# (dankclaw/voiceservice/app.py lists and resolves exactly
|
||||
# this file), so it is written LAST, once everything else in
|
||||
# the pack has been verified.
|
||||
# reference.spk the engine's x-vector: enc_dim 2048 f32 = 8,192 B.
|
||||
# reference.rvq the engine's RVQ code stream: 16 codebooks x 11 bits = 22 B
|
||||
# per 12.5 Hz frame = 22 * floor(samples / 1920).
|
||||
# manifest.json provenance: source path and sha256, crop start/end, sample
|
||||
# rate, duration, the tool + engine commit that made the
|
||||
# artifacts, and the voice name.
|
||||
#
|
||||
# Both artifacts come from the pinned engine build's speaker extractor — the
|
||||
# `qwen-codec` tool of qwentts.cpp at VOICE_PACK_ENGINE_COMMIT — run on the pack's
|
||||
# own WAV, and the extractor names them by swapping the input's extension, so
|
||||
# they land beside reference.wav with nothing to rename. Required, never a
|
||||
# replacement for the WAV: a voice another runtime cannot reuse is not saved.
|
||||
#
|
||||
# THE 10-SECOND GATE — THE REASON THIS SCRIPT EXISTS. Project-wide source-material
|
||||
# invariant: the first 10 s of any sample file used to generate voices is
|
||||
# forbidden material. An offset that starts inside that region is refused with a
|
||||
# nonzero exit BEFORE anything is created — no pack dir, no WAV, no artifact — and
|
||||
# the same gate is asserted a second time from the manifest that was just written
|
||||
# (plus the recorded sha256 against the source it claims). A pack carrying a
|
||||
# forbidden crop therefore cannot survive a build.
|
||||
#
|
||||
# THE ARTIFACT SIZES ARE CHECKED, NOT ASSUMED. They are a property of the codec
|
||||
# config inside the GGUF, so an engine bump that changes enc_dim or the codebook
|
||||
# count fails the build instead of shipping a pack the engine will reject at
|
||||
# synthesis time. Note the interaction with the WAV window: an 18.0 s crop is a
|
||||
# legal reference.wav but only 22 * 225 = 4,950 B of .rvq, under the recorded
|
||||
# 5,456 B floor, so the effective crop window is 19.84-25 s. That is a real
|
||||
# constraint and the builder says so up front rather than failing after a build;
|
||||
# serverAppConfigs-docs/Dankclaw/Voice Packs.md covers what to do on an engine bump.
|
||||
#
|
||||
# WHERE IT RUNS: the host that holds the engine build (Dankclaw). The extractor is
|
||||
# a CPU ggml binary — no GPU, no container, no fleet endpoint, no network. ffmpeg
|
||||
# does the crop; every spawned process runs `nice -n 19` so a pack build never
|
||||
# competes with the text endpoints on that box.
|
||||
#
|
||||
# IT NEVER DELETES. An existing pack is refused unless --force, and --force moves
|
||||
# the whole old directory aside into $PACKS_DIR/.AGENTTRASH/<voice>-<UTC>/ rather
|
||||
# than overwriting or unlinking a single byte.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-voice-pack.sh [OPTIONS] VOICE PACKS_DIR
|
||||
# scripts/build-voice-pack.sh [OPTIONS] --all PACKS_DIR
|
||||
#
|
||||
# Options (anything not given comes from scripts/voice-packs.env):
|
||||
# --env FILE tunables file (default scripts/voice-packs.env)
|
||||
# --source FILE source recording for this voice
|
||||
# --offset SEC crop start, in seconds into the source (must be >= 10)
|
||||
# --duration SEC crop length; reference.wav comes out this long
|
||||
# --sha256 HEX expected sha256 of the source file
|
||||
# --tool PATH the pinned speaker extractor (qwen-codec)
|
||||
# --all build every voice in VOICE_PACKS, skipping none
|
||||
# --dry-run everything except the extractor: validate, cut the crop,
|
||||
# probe it, print the plan and the manifest it would write
|
||||
# --force retire an existing pack to .AGENTTRASH/ and rebuild
|
||||
# -h, --help this text
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
|
||||
SOURCE_FORBIDDEN_SECONDS=10 # the source-material invariant; not a knob
|
||||
WORK_DIR="" # scratch dir a dry run owns, removed on exit
|
||||
|
||||
# Only ever removes the scratch directory this script created for itself; pack
|
||||
# bytes on disk are retired to .AGENTTRASH/, never deleted (see --force).
|
||||
cleanup_work_dir() {
|
||||
if [ -n "$WORK_DIR" ]; then
|
||||
rm -rf -- "$WORK_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup_work_dir EXIT
|
||||
|
||||
die() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# fcmp A OP B — float comparison (awk); OP is one of < <= > >= == and A/B are
|
||||
# already validated as plain decimals, so nothing user-shaped reaches awk as
|
||||
# program text.
|
||||
fcmp() {
|
||||
awk -v a="$1" -v b="$3" "BEGIN { exit !(a $2 b) }"
|
||||
}
|
||||
|
||||
# floorn X — greatest integer <= X, printed. Only ever called on positives.
|
||||
floorn() {
|
||||
awk -v x="$1" 'BEGIN { printf "%d\n", int(x) }'
|
||||
}
|
||||
|
||||
is_seconds() {
|
||||
[[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||
}
|
||||
|
||||
is_hex64() {
|
||||
[[ "$1" =~ ^[0-9a-f]{64}$ ]]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- arguments --
|
||||
VOICE=""
|
||||
PACKS_DIR=""
|
||||
ARG_SOURCE=""
|
||||
ARG_OFFSET=""
|
||||
ARG_DURATION=""
|
||||
ARG_SHA=""
|
||||
ARG_TOOL=""
|
||||
DRY_RUN=0
|
||||
FORCE=0
|
||||
BUILD_ALL=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--env) ENV_FILE="${2:?--env needs a path}"; shift 2 ;;
|
||||
--source) ARG_SOURCE="${2:?--source needs a file}"; shift 2 ;;
|
||||
--offset) ARG_OFFSET="${2:?--offset needs seconds}"; shift 2 ;;
|
||||
--duration) ARG_DURATION="${2:?--duration needs seconds}"; shift 2 ;;
|
||||
--sha256) ARG_SHA="${2:?--sha256 needs a hex digest}"; shift 2 ;;
|
||||
--tool) ARG_TOOL="${2:?--tool needs a path}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
--all) BUILD_ALL=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--*) die "unknown option: $1 (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$VOICE" ]; then
|
||||
VOICE="$1"
|
||||
elif [ -z "$PACKS_DIR" ]; then
|
||||
PACKS_DIR="$1"
|
||||
else
|
||||
die "unexpected argument: $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f "$ENV_FILE" ] || die "tunables file not found: $ENV_FILE"
|
||||
for tool in ffmpeg ffprobe jq sha256sum; do
|
||||
command -v "$tool" >/dev/null 2>&1 || die "$tool is required and not on PATH"
|
||||
done
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
|
||||
# Tunables the env file owes us. Defaults are the engine facts read out of the
|
||||
# pinned tree; an env file that omits one still builds against the truth.
|
||||
VOICE_PACKS="${VOICE_PACKS:-}"
|
||||
SAMPLE_RATE="${VOICE_PACK_SAMPLE_RATE:-24000}"
|
||||
MIN_SECONDS="${VOICE_PACK_MIN_SECONDS:-18}"
|
||||
MAX_SECONDS="${VOICE_PACK_MAX_SECONDS:-25}"
|
||||
FRAME_RATE="${VOICE_PACK_FRAME_RATE:-12.5}"
|
||||
RVQ_BYTES_PER_FRAME="${VOICE_PACK_RVQ_BYTES_PER_FRAME:-22}"
|
||||
SPK_BYTES="${VOICE_PACK_SPK_BYTES:-8192}"
|
||||
RVQ_MIN_BYTES="${VOICE_PACK_RVQ_MIN_BYTES:-5456}"
|
||||
RVQ_MAX_BYTES="${VOICE_PACK_RVQ_MAX_BYTES:-6864}"
|
||||
TOOL="${ARG_TOOL:-${VOICE_PACK_TOOL:-}}"
|
||||
CODEC_MODEL="${VOICE_PACK_CODEC_MODEL:-}"
|
||||
TALKER_MODEL="${VOICE_PACK_TALKER_MODEL:-}"
|
||||
ENGINE_REPO="${VOICE_PACK_ENGINE_REPO:-https://github.com/ServeurpersoCom/qwentts.cpp}"
|
||||
ENGINE_COMMIT="${VOICE_PACK_ENGINE_COMMIT:-5916bf7c79dbb4e66fc059cdc507a79a23a28bb4}"
|
||||
DEVICE="${VOICE_PACK_DEVICE:-CPU}"
|
||||
THREADS="${VOICE_PACK_THREADS:-8}"
|
||||
NICE_LEVEL="${VOICE_PACK_NICE:-19}"
|
||||
TRASH_DIRNAME="${VOICE_PACK_TRASH_DIRNAME:-.AGENTTRASH}"
|
||||
DEFAULT_PACKS_DIR="${VOICE_PACK_DIR:-/home/dank/voices}"
|
||||
|
||||
[ -n "$PACKS_DIR" ] || PACKS_DIR="$DEFAULT_PACKS_DIR"
|
||||
[ -n "$PACKS_DIR" ] || die "no packs directory (pass it as an argument)"
|
||||
is_seconds "$SAMPLE_RATE" || die "VOICE_PACK_SAMPLE_RATE is not a number: $SAMPLE_RATE"
|
||||
is_seconds "$MIN_SECONDS" || die "VOICE_PACK_MIN_SECONDS is not a number: $MIN_SECONDS"
|
||||
is_seconds "$MAX_SECONDS" || die "VOICE_PACK_MAX_SECONDS is not a number: $MAX_SECONDS"
|
||||
|
||||
# hop / frame arithmetic, derived once: the codec runs at FRAME_RATE Hz over
|
||||
# SAMPLE_RATE audio, so one frame is HOP_SAMPLES samples and one second is
|
||||
# FRAME_RATE frames.
|
||||
HOP_SAMPLES="$(floorn "$(awk -v r="$SAMPLE_RATE" -v f="$FRAME_RATE" 'BEGIN { print r / f }')")"
|
||||
[ "$HOP_SAMPLES" -gt 0 ] || die "VOICE_PACK_FRAME_RATE ($FRAME_RATE) exceeds the sample rate"
|
||||
BYTES_PER_SECOND="$(awk -v b="$RVQ_BYTES_PER_FRAME" -v f="$FRAME_RATE" 'BEGIN { print b * f }')"
|
||||
|
||||
# rvq_bytes_for DURATION — packed size the extractor will emit for a crop this
|
||||
# long: whole frames only, because it truncates to the hop boundary.
|
||||
rvq_bytes_for() {
|
||||
local samples frames
|
||||
samples="$(awk -v d="$1" -v r="$SAMPLE_RATE" 'BEGIN { printf "%d\n", int(d * r + 0.5) }')"
|
||||
frames=$((samples / HOP_SAMPLES))
|
||||
printf '%d\n' "$((frames * RVQ_BYTES_PER_FRAME))"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- build --
|
||||
build_voice() {
|
||||
local voice="$1"
|
||||
# The pack dir name is the voice name on the wire: app.py's VOICE_NAME_RE
|
||||
# matches it exactly while the engine lowercases names internally, so an
|
||||
# uppercase or punctuated name would be reported one way by the façade and
|
||||
# another by the engine. Lowercase only, and nothing that could escape
|
||||
# $PACKS_DIR.
|
||||
[[ "$voice" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] ||
|
||||
die "voice name '$voice' is not a lowercase [a-z0-9._-] name starting with alphanumerics (app.py resolves it verbatim; the engine lowercases it)"
|
||||
|
||||
local prefix
|
||||
prefix="VOICE_PACK_$(printf '%s' "$voice" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')"
|
||||
local source_var="${prefix}_SOURCE" offset_var="${prefix}_OFFSET"
|
||||
local duration_var="${prefix}_DURATION" sha_var="${prefix}_SHA256"
|
||||
local source="${ARG_SOURCE:-${!source_var:-}}"
|
||||
local offset="${ARG_OFFSET:-${!offset_var:-}}"
|
||||
local duration="${ARG_DURATION:-${!duration_var:-}}"
|
||||
local want_sha="${ARG_SHA:-${!sha_var:-}}"
|
||||
|
||||
if [ -z "$voice" ] || [ -z "$source" ]; then
|
||||
die "no source for '$voice': pass --source or set ${source_var} in $ENV_FILE"
|
||||
fi
|
||||
[ -n "$offset" ] || die "no crop offset for '$voice': pass --offset or set ${offset_var}"
|
||||
[ -n "$duration" ] || die "no crop duration for '$voice': pass --duration or set ${duration_var}"
|
||||
is_seconds "$offset" || die "crop offset '$offset' is not a number of seconds"
|
||||
is_seconds "$duration" || die "crop duration '$duration' is not a number of seconds"
|
||||
[ -f "$source" ] && [ -r "$source" ] || die "source not readable: $source"
|
||||
|
||||
# === THE GATE. Source seconds 0..10 are forbidden voice material, full stop.
|
||||
if ! fcmp "$offset" '>=' "$SOURCE_FORBIDDEN_SECONDS"; then
|
||||
die "source-material invariant violated: the crop must start past the first ${SOURCE_FORBIDDEN_SECONDS} s of the source file, and '$voice' asks for ${offset} s into $source. Pick an offset at or past ${SOURCE_FORBIDDEN_SECONDS} s — nothing was written."
|
||||
fi
|
||||
|
||||
local pack_dir="$PACKS_DIR/$voice"
|
||||
if [ "$DRY_RUN" -ne 1 ] && [ -e "$pack_dir" ] && [ "$FORCE" -ne 1 ]; then
|
||||
die "$pack_dir already exists; nothing was touched. Re-run with --force to retire it to $PACKS_DIR/$TRASH_DIRNAME/ and rebuild."
|
||||
fi
|
||||
|
||||
local source_duration source_bytes source_sha
|
||||
source_duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$source")"
|
||||
is_seconds "$source_duration" || die "ffprobe reports no numeric duration for $source: '$source_duration'"
|
||||
source_bytes="$(stat -c%s "$source")"
|
||||
if ! fcmp "$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')" '<=' "$source_duration"; then
|
||||
die "crop ends at $(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }') s but $source is only $source_duration s; the crop would come out short"
|
||||
fi
|
||||
if ! fcmp "$duration" '>=' "$MIN_SECONDS" || ! fcmp "$duration" '<=' "$MAX_SECONDS"; then
|
||||
die "crop duration $duration s is outside the ${MIN_SECONDS}-${MAX_SECONDS} s reference window"
|
||||
fi
|
||||
local crop_end planned_rvq
|
||||
crop_end="$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')"
|
||||
planned_rvq="$(rvq_bytes_for "$duration")"
|
||||
if ! fcmp "$planned_rvq" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$planned_rvq" '<=' "$RVQ_MAX_BYTES"; then
|
||||
die "a $duration s crop packs to ${planned_rvq} B of .rvq, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B; lengthen the crop (that window starts at $(awk -v m="$RVQ_MIN_BYTES" -v b="$BYTES_PER_SECOND" 'BEGIN { print m / b }') s) — the source, not the codec, sets this floor"
|
||||
fi
|
||||
|
||||
echo "VOICE $voice"
|
||||
echo "SOURCE $source ($source_bytes bytes, ${source_duration} s)"
|
||||
echo "CROP ${offset} s .. ${crop_end} s (${duration} s) — past the ${SOURCE_FORBIDDEN_SECONDS} s forbidden region"
|
||||
|
||||
source_sha="$(sha256sum "$source" | awk '{ print $1 }')"
|
||||
if [ -n "$want_sha" ]; then
|
||||
is_hex64 "$want_sha" || die "expected sha256 '$want_sha' is not 64 hex digits"
|
||||
[ "$source_sha" = "$want_sha" ] ||
|
||||
die "source sha256 is $source_sha, expected $want_sha: $source is not the file these offsets were cut from"
|
||||
fi
|
||||
echo "SHA256 $source_sha"
|
||||
|
||||
# Where the crop is cut. A dry run keeps its work in a scratch dir it owns;
|
||||
# a real build stages INSIDE the pack dir so the final rename is on one
|
||||
# filesystem and the pack becomes visible only when it is whole.
|
||||
local stage_dir
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/voice-pack-$voice.XXXXXX")"
|
||||
stage_dir="$WORK_DIR"
|
||||
else
|
||||
mkdir -p "$PACKS_DIR"
|
||||
if [ -e "$pack_dir" ]; then
|
||||
local trash
|
||||
trash="$PACKS_DIR/$TRASH_DIRNAME/$voice-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
mkdir -p "$(dirname "$trash")"
|
||||
mv "$pack_dir" "$trash"
|
||||
echo "RETIRED $pack_dir -> $trash (nothing deleted)"
|
||||
fi
|
||||
mkdir "$pack_dir" || die "cannot create $pack_dir (it may exist now); nothing was overwritten"
|
||||
stage_dir="$pack_dir"
|
||||
fi
|
||||
local stage_wav="$stage_dir/staging-reference.wav"
|
||||
|
||||
nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-ss "$offset" -t "$duration" -i "$source" \
|
||||
-map 0:a:0 -vn -ac 1 -ar "$SAMPLE_RATE" -c:a pcm_s16le "$stage_wav"
|
||||
|
||||
local wav_rate wav_channels wav_codec wav_duration
|
||||
wav_rate="$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$stage_wav")"
|
||||
wav_channels="$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$stage_wav")"
|
||||
wav_codec="$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$stage_wav")"
|
||||
wav_duration="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$stage_wav")"
|
||||
is_seconds "$wav_rate" || die "ffprobe reports no numeric sample rate for the crop: '$wav_rate'"
|
||||
is_seconds "$wav_duration" || die "ffprobe reports no numeric duration for the crop: '$wav_duration'"
|
||||
[ "$wav_rate" = "$SAMPLE_RATE" ] || die "reference.wav came out at ${wav_rate} Hz, not ${SAMPLE_RATE}"
|
||||
[ "$wav_channels" = "1" ] || die "reference.wav came out with $wav_channels channels, not mono"
|
||||
[ "$wav_codec" = "pcm_s16le" ] || die "reference.wav came out as $wav_codec, not the pcm_s16le the law names"
|
||||
if ! fcmp "$wav_duration" '>=' "$MIN_SECONDS" || ! fcmp "$wav_duration" '<=' "$MAX_SECONDS"; then
|
||||
die "reference.wav is ${wav_duration} s, outside the ${MIN_SECONDS}-${MAX_SECONDS} s window"
|
||||
fi
|
||||
echo "REFERENCE ${wav_rate} Hz ${wav_codec} mono, ${wav_duration} s, $(stat -c%s "$stage_wav") bytes"
|
||||
|
||||
local spk_file="$stage_dir/staging-reference.spk" rvq_file="$stage_dir/staging-reference.rvq"
|
||||
local stage_log="$stage_dir/staging-qwen-codec.log"
|
||||
local spk_bytes=0 rvq_bytes=0 produced=false
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
if [ -n "$TOOL" ] && [ -x "$TOOL" ]; then
|
||||
echo "EXTRACTOR $TOOL (present; --dry-run will not run it)"
|
||||
else
|
||||
echo "EXTRACTOR not present (${TOOL:-unset}) — --dry-run does not need it"
|
||||
fi
|
||||
echo "PLANNED reference.spk ${SPK_BYTES} B, reference.rvq $(rvq_bytes_for "$wav_duration") B"
|
||||
else
|
||||
[ -n "$TOOL" ] && [ -x "$TOOL" ] ||
|
||||
die "speaker extractor missing or not executable: ${TOOL:-VOICE_PACK_TOOL unset} (use --dry-run to build everything up to this point)"
|
||||
[ -f "$CODEC_MODEL" ] || die "codec model missing: $CODEC_MODEL"
|
||||
[ -f "$TALKER_MODEL" ] || die "talker model missing: $TALKER_MODEL"
|
||||
# The extractor takes the WAV and writes its outputs next to it with the
|
||||
# extension swapped — there is no -o, so the staged name decides where
|
||||
# the artifacts land.
|
||||
local extractor=("$TOOL" --model "$CODEC_MODEL" --talker "$TALKER_MODEL" -i "$stage_wav")
|
||||
if [ -n "$DEVICE" ]; then
|
||||
extractor+=(--device "$DEVICE")
|
||||
fi
|
||||
echo "EXTRACT $(basename "$TOOL") at $ENGINE_COMMIT (device ${DEVICE:-auto}, $THREADS threads)"
|
||||
if ! nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
|
||||
"${extractor[@]}" 2>&1 | tee "$stage_log" >&2; then
|
||||
die "$TOOL failed on $stage_wav (its output is in $stage_log); the pack was not published"
|
||||
fi
|
||||
[ -f "$spk_file" ] || die "$TOOL wrote no $spk_file (see $stage_log)"
|
||||
[ -f "$rvq_file" ] || die "$TOOL wrote no $rvq_file (see $stage_log)"
|
||||
spk_bytes="$(stat -c%s "$spk_file")"
|
||||
rvq_bytes="$(stat -c%s "$rvq_file")"
|
||||
local expect_rvq
|
||||
expect_rvq="$(rvq_bytes_for "$wav_duration")"
|
||||
[ "$spk_bytes" = "$SPK_BYTES" ] ||
|
||||
die "reference.spk is $spk_bytes B, expected ${SPK_BYTES} B (enc_dim x f32). The engine changed its speaker encoder — see [[Voice Packs]] before shipping this pack"
|
||||
[ "$rvq_bytes" = "$expect_rvq" ] ||
|
||||
die "reference.rvq is $rvq_bytes B, expected ${expect_rvq} B for a ${wav_duration} s crop (22 B per 12.5 Hz frame)"
|
||||
if ! fcmp "$rvq_bytes" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$rvq_bytes" '<=' "$RVQ_MAX_BYTES"; then
|
||||
die "reference.rvq is $rvq_bytes B, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B — an engine bump? see [[Voice Packs]]"
|
||||
fi
|
||||
produced=true
|
||||
echo "ARTIFACTS reference.spk ${spk_bytes} B, reference.rvq ${rvq_bytes} B"
|
||||
fi
|
||||
|
||||
local built stage_manifest
|
||||
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
stage_manifest="$stage_dir/staging-manifest.json"
|
||||
jq -n \
|
||||
--arg voice "$voice" \
|
||||
--arg built "$built" \
|
||||
--arg builder "scripts/build-voice-pack.sh" \
|
||||
--arg env_file "$ENV_FILE" \
|
||||
--arg src "$source" \
|
||||
--arg src_sha "$source_sha" \
|
||||
--argjson src_bytes "$source_bytes" \
|
||||
--argjson src_duration "$source_duration" \
|
||||
--argjson offset "$offset" \
|
||||
--argjson end "$crop_end" \
|
||||
--argjson duration "$duration" \
|
||||
--argjson forbidden "$SOURCE_FORBIDDEN_SECONDS" \
|
||||
--argjson rate "$SAMPLE_RATE" \
|
||||
--argjson wav_duration "$wav_duration" \
|
||||
--arg wav_codec "$wav_codec" \
|
||||
--argjson wav_channels "$wav_channels" \
|
||||
--argjson wav_bytes "$(stat -c%s "$stage_wav")" \
|
||||
--arg tool "${TOOL:-unset}" \
|
||||
--arg repo "$ENGINE_REPO" \
|
||||
--arg commit "$ENGINE_COMMIT" \
|
||||
--arg codec "$CODEC_MODEL" \
|
||||
--arg talker "$TALKER_MODEL" \
|
||||
--arg device "$DEVICE" \
|
||||
--argjson produced "$produced" \
|
||||
--argjson spk_bytes "$spk_bytes" \
|
||||
--argjson rvq_bytes "$rvq_bytes" \
|
||||
--argjson planned_rvq "$(rvq_bytes_for "$wav_duration")" \
|
||||
--argjson dry_run "$DRY_RUN" \
|
||||
'{
|
||||
voice: $voice,
|
||||
kind: "voice-pack",
|
||||
built_utc: $built,
|
||||
builder: $builder,
|
||||
tunables: $env_file,
|
||||
dry_run: ($dry_run == 1),
|
||||
source: {
|
||||
path: $src,
|
||||
sha256: $src_sha,
|
||||
bytes: $src_bytes,
|
||||
duration_seconds: $src_duration
|
||||
},
|
||||
crop: {
|
||||
offset_seconds: $offset,
|
||||
end_seconds: $end,
|
||||
duration_seconds: $duration
|
||||
},
|
||||
reference: {
|
||||
file: "reference.wav",
|
||||
codec: $wav_codec,
|
||||
sample_rate: $rate,
|
||||
channels: $wav_channels,
|
||||
duration_seconds: $wav_duration,
|
||||
bytes: $wav_bytes
|
||||
},
|
||||
artifacts: {
|
||||
produced: $produced,
|
||||
tool: $tool,
|
||||
engine_repo: $repo,
|
||||
engine_commit: $commit,
|
||||
device: $device,
|
||||
codec_model: $codec,
|
||||
talker_model: $talker,
|
||||
spk: {file: "reference.spk", bytes: $spk_bytes},
|
||||
rvq: {file: "reference.rvq", bytes: $rvq_bytes, planned_bytes: $planned_rvq}
|
||||
},
|
||||
invariant: {
|
||||
name: "source-material invariant",
|
||||
rule: "a voice crop MUST start past the first 10 seconds of the source file",
|
||||
forbidden_source_seconds: $forbidden,
|
||||
crop_offset_seconds: $offset
|
||||
}
|
||||
}' >"$stage_manifest"
|
||||
|
||||
# Assert the gate a second time, FROM the manifest, next to the bytes it
|
||||
# describes. Anything short of a pass leaves the staged pack unpublished.
|
||||
local recorded_offset recorded_sha
|
||||
recorded_offset="$(jq -r '.crop.offset_seconds' "$stage_manifest")"
|
||||
recorded_sha="$(jq -r '.source.sha256' "$stage_manifest")"
|
||||
jq -e --argjson min "$SOURCE_FORBIDDEN_SECONDS" \
|
||||
'(.crop.offset_seconds | type == "number") and .invariant.forbidden_source_seconds == $min and (.crop.offset_seconds >= $min) and (.invariant.name == "source-material invariant")' \
|
||||
"$stage_manifest" >/dev/null ||
|
||||
die "manifest assertion failed: .crop.offset_seconds ($recorded_offset) does not clear the ${SOURCE_FORBIDDEN_SECONDS} s source-material invariant"
|
||||
[ "$recorded_sha" = "$source_sha" ] ||
|
||||
die "manifest assertion failed: .source.sha256 ($recorded_sha) is not the hash of $source ($source_sha)"
|
||||
jq -e '(.voice | type == "string") and (.reference.sample_rate | type == "number") and (.reference.duration_seconds | type == "number") and (.artifacts.engine_commit | length == 40)' \
|
||||
"$stage_manifest" >/dev/null ||
|
||||
die "manifest assertion failed: voice / reference / engine provenance fields are not all present"
|
||||
echo "MANIFEST crop offset $recorded_offset s clears the ${SOURCE_FORBIDDEN_SECONDS} s invariant"
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "MANIFEST_BEGIN"
|
||||
cat "$stage_manifest"
|
||||
echo "MANIFEST_END"
|
||||
echo "PLAN would write $pack_dir/{reference.wav,reference.spk,reference.rvq,manifest.json}"
|
||||
echo "DRYRUN $voice: inputs valid, crop cut and probed, extractor not run"
|
||||
else
|
||||
# reference.wav is the gate the shim reads, so it lands last: the voice
|
||||
# name never exists on disk in a state the artifacts are not behind it.
|
||||
mv "$stage_log" "$pack_dir/qwen-codec.log"
|
||||
mv "$spk_file" "$pack_dir/reference.spk"
|
||||
mv "$rvq_file" "$pack_dir/reference.rvq"
|
||||
mv "$stage_manifest" "$pack_dir/manifest.json"
|
||||
mv "$stage_wav" "$pack_dir/reference.wav"
|
||||
echo "BUILT $pack_dir (voice '$voice': ${wav_duration} s, spk ${spk_bytes} B, rvq ${rvq_bytes} B)"
|
||||
echo "VOICE_PACK_BUILT $voice $pack_dir"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
if [ "$BUILD_ALL" -eq 1 ]; then
|
||||
[ -z "$VOICE" ] || die "--all takes no voice name (got '$VOICE'); the list comes from VOICE_PACKS"
|
||||
for flag in "$ARG_SOURCE" "$ARG_OFFSET" "$ARG_DURATION" "$ARG_SHA"; do
|
||||
[ -z "$flag" ] || die "--all reads every crop from $ENV_FILE; --source/--offset/--duration/--sha256 would apply one value to all of them"
|
||||
done
|
||||
read -r -a voices <<<"$VOICE_PACKS"
|
||||
[ "${#voices[@]}" -gt 0 ] || die "VOICE_PACKS is empty in $ENV_FILE"
|
||||
for voice in "${voices[@]}"; do
|
||||
build_voice "$voice"
|
||||
done
|
||||
else
|
||||
[ -n "$VOICE" ] || { usage >&2; die "no voice name given"; }
|
||||
build_voice "$VOICE"
|
||||
fi
|
||||
@@ -62,6 +62,10 @@ SERVICE_ORDER = (
|
||||
"sonarr",
|
||||
"lidarr",
|
||||
"readarr",
|
||||
# The voice pair keeps the inference family's ordering rule: the engine is
|
||||
# a dependency of the 7772 facade, so it is planned (and therefore
|
||||
# restarted) first.
|
||||
"frank-tts-engine",
|
||||
"frank-dragoon",
|
||||
"frank-zealot",
|
||||
"frank-archon",
|
||||
@@ -97,6 +101,7 @@ DANKCLAW_SERVICES = {
|
||||
"homepage",
|
||||
"frank-dragoon",
|
||||
"frank-zealot",
|
||||
"frank-tts-engine",
|
||||
}
|
||||
|
||||
DANKSERVER_SERVICES = {
|
||||
@@ -326,6 +331,18 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# plans the pair, which deploy_open_notebook starts database-first.
|
||||
_add(services, "open-notebook")
|
||||
return services
|
||||
if len(parts) >= 2 and parts[1] == "voiceservice":
|
||||
# The voice stack's containers share one directory but are TWO
|
||||
# deployables — the arm A engine and the 7772 façade in front of it
|
||||
# — so this maps per file, the way the authentik trio does, and not
|
||||
# per directory. Anything that is not named after one of the two
|
||||
# services plans nothing, exactly as it did before the pair was
|
||||
# registered.
|
||||
if parts[-1] in {"frank-tts-engine.container", "frank-tts-engine.network",
|
||||
"Containerfile.engine", "tts-engine-start.sh",
|
||||
"tts-engine-warm.sh"}:
|
||||
_add(services, "frank-tts-engine")
|
||||
return services
|
||||
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
||||
# build, root unit): its files live in scripts/ and map there.
|
||||
if len(parts) >= 2 and parts[1] == "observability":
|
||||
@@ -429,6 +446,21 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# what the live frank-dragoon endpoint renders, so it plans
|
||||
# frank-dragoon.
|
||||
_add(services, "frank-dragoon")
|
||||
if name == "frank-tts-engine.env":
|
||||
# The engine's knobs are command-line flags, and this file is the
|
||||
# repo's copy of them (the image entrypoint translates one line to
|
||||
# one flag), so editing it is a restart of the engine. It also
|
||||
# carries the boot warmer's three knobs, because the warmer is
|
||||
# exec-ed into this container and inherits the same EnvironmentFile.
|
||||
# A restart is what re-registers and re-warms every voice: the
|
||||
# engine's voice registry is memory-only. (The warmer itself lives
|
||||
# with the image, mapped above with the files it is built from, like
|
||||
# the serve scripts of frank-zealot and frank-archon are mapped to
|
||||
# the units that run them.)
|
||||
# scripts/download-frank-tts-engine-qwentts.sh deliberately plans
|
||||
# nothing: it fetches host-side state, like every other download
|
||||
# script in this directory.
|
||||
_add(services, "frank-tts-engine")
|
||||
if name in {"frank-archon.env", "frank-archon.service", "serve-frank-archon.sh"}:
|
||||
# llama.cpp frank-archon (2026-09-26, replacing the Lucebox
|
||||
# DS4.1 quadlet) keeps its unit, env and serve files here the
|
||||
|
||||
@@ -92,6 +92,7 @@ service_is_allowed() {
|
||||
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
||||
dankclaw:open-notebook|\
|
||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||
dankclaw:frank-tts-engine|\
|
||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||
@@ -549,6 +550,14 @@ build_compose_image() {
|
||||
image="franksdank/$service:latest"
|
||||
containerfile="dankclaw/$service/Containerfile"
|
||||
;;
|
||||
frank-tts-engine)
|
||||
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
|
||||
# source at a pinned upstream commit, so the build needs network
|
||||
# and the cfhash label below is what keeps an unchanged re-deploy
|
||||
# offline: the clone only happens when this Containerfile changes.
|
||||
image=franksdank/frank-tts-engine:latest
|
||||
containerfile=dankclaw/voiceservice/Containerfile.engine
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
@@ -744,6 +753,54 @@ deploy_open_notebook() {
|
||||
wait_for_health open-notebook http://127.0.0.1:5056/health 180
|
||||
}
|
||||
|
||||
# frank-tts-engine is the arm A TTS engine behind frank-tts: one Quadlet unit
|
||||
# plus the private bridge its unit defines (install_quadlet copies the sibling
|
||||
# frank-tts-engine.network — without that file the v1 generator refuses to
|
||||
# produce the container unit at all, the same trap documented above). The
|
||||
# façade that consumes it is a separate service on a separate port and a
|
||||
# separate handler.
|
||||
deploy_frank_tts_engine() {
|
||||
# Weights BEFORE the build, and refuse rather than fetch. The payload is
|
||||
# 1.4 GiB of GGUF with byte and sha256 checks; silently pulling it inside a
|
||||
# deploy would turn a stalled network into a deploy that looks hung, and
|
||||
# would hide which of the two failure modes the operator is in. The fetcher
|
||||
# is idempotent, so re-running this handler after the fetch is cheap.
|
||||
if ! scripts/download-frank-tts-engine-qwentts.sh --verify-only >/dev/null; then
|
||||
echo "frank-tts-engine: the talker/tokenizer GGUFs are missing or wrong-sized under /home/dank/models/Qwen3-TTS-GGUF; run scripts/download-frank-tts-engine-qwentts.sh and re-deploy" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build first: Image= is a locally built tag with Pull=never, so the
|
||||
# generator cannot start a container from it before it exists.
|
||||
build_compose_image frank-tts-engine
|
||||
|
||||
install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine
|
||||
systemctl --user start frank-tts-engine-network.service
|
||||
# No `systemctl --user enable` (generated Quadlet units cannot be enabled
|
||||
# on this host); boot persistence is the [Install] section in the unit.
|
||||
#
|
||||
# `restart` blocks until the unit reaches started, which includes the
|
||||
# ExecStartPost warm pass — a boot of this service is therefore already
|
||||
# warm by the time the next line runs.
|
||||
systemctl --user restart frank-tts-engine.service
|
||||
# NOT wait_for_health: this container publishes nothing, so there is no URL
|
||||
# the deployer can dial from the host. The unit's HealthCmd runs inside the
|
||||
# container's own namespace, and podman's recorded health status is what the
|
||||
# host can actually observe.
|
||||
wait_container_healthy frank-tts-engine 150 || return 1
|
||||
|
||||
# The warmer runs a second time here on purpose. ExecStartPost carries a
|
||||
# `-` prefix so a failed warm can never crash-loop the endpoint, which also
|
||||
# means the unit cannot fail the deploy on it. This call is where a voice
|
||||
# that did not warm becomes a deploy result, and where the per-voice
|
||||
# cold=/warm= numbers land on the operator's console instead of only in the
|
||||
# journal. The pass itself is idempotent and the second cold cost is gone:
|
||||
# the voices are already warm, so this costs about a second per voice. It is
|
||||
# exec-ed into the container for the same reason the health gate is: no host
|
||||
# address exists.
|
||||
podman exec frank-tts-engine /app/warm-voices.sh
|
||||
}
|
||||
|
||||
deploy_comfyui() {
|
||||
# Build first: the unit's Image= resolves against local storage, so the
|
||||
# image has to exist before the generator starts a container from it.
|
||||
@@ -1690,6 +1747,7 @@ deploy_service() {
|
||||
frank-dragoon) deploy_llm_dragoon ;;
|
||||
frank-zealot) deploy_llm_zealot ;;
|
||||
frank-archon) deploy_llm_archon ;;
|
||||
frank-tts-engine) deploy_frank_tts_engine ;;
|
||||
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
||||
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
||||
jellyfin) deploy_jellyfin ;;
|
||||
|
||||
@@ -142,6 +142,7 @@ valid_service() {
|
||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
||||
dankclaw:open-notebook|\
|
||||
dankclaw:frank-tts-engine|\
|
||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download the frank-tts-engine payload: the two GGUF files the arm A engine
|
||||
# (qwentts.cpp `tts-server`, dankclaw/voiceservice/Containerfile.engine) loads
|
||||
# once at startup — Serveurperso/Qwen3-TTS-GGUF's **1.7 B base talker at
|
||||
# Q4_K_M** and the **12 Hz tokenizer at Q8_0**. Public repo — no auth header,
|
||||
# and never a token on the command line (world-readable in /proc/<pid>/cmdline).
|
||||
#
|
||||
# THE BASE VARIANT IS MANDATORY, NOT A PREFERENCE. The engine refuses every
|
||||
# voice registration unless the loaded model reports type "base"
|
||||
# (tools/tts-server.cpp, register_voice): on a customvoice or voicedesign talker
|
||||
# the cloned-voice product does not exist. The 0.6 B talker (628,905,056 B for
|
||||
# its Q4_K_M) also exists and is NOT the decided artifact — 1.7 B is what live
|
||||
# output was measured with on 2026-10-18.
|
||||
#
|
||||
# Fresh downloads, no resume (the same rule the frank-zealot fetcher learned the
|
||||
# hard way: `curl -C -` once produced an oversized file on a multi-GB payload);
|
||||
# -f/--fail-with-body so an HTTP error is never a success; then exact byte count
|
||||
# and sha256 against the Hub's own LFS oid. This repo IS LFS, so the sha values
|
||||
# below are the pointer oids the Hub publishes — verified at fetch, not
|
||||
# computed locally. Idempotent: a complete, correctly-hashed file is skipped,
|
||||
# and `--verify-only` reports without downloading (that is the mode the deployer
|
||||
# and the runbook use to answer "are the weights there?").
|
||||
#
|
||||
# Both files together are 1.4 GiB and the engine mmaps them: peak RSS measured
|
||||
# 5.7 GiB with both resident. Nothing here is deleted on a swap — the previous
|
||||
# talker stays on disk, because the rollback for a bad engine build is to point
|
||||
# QWENTTS_MODEL at a different file, not to re-download 1.4 GiB under pressure.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/download-frank-tts-engine-qwentts.sh [DEST_DIR] [--verify-only]
|
||||
# Default DEST_DIR is the host directory the frank-tts-engine unit mounts
|
||||
# read-only at /models.
|
||||
set -euo pipefail
|
||||
|
||||
DEST=""
|
||||
VERIFY_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--verify-only) VERIFY_ONLY=1 ;;
|
||||
*) DEST="$arg" ;;
|
||||
esac
|
||||
done
|
||||
DEST="${DEST:-/home/dank/models/Qwen3-TTS-GGUF}"
|
||||
|
||||
# repo | hub-name | dest-name | bytes | sha256 (Hub LFS oid, resolved 2026-10-18)
|
||||
FILES=(
|
||||
"Serveurperso/Qwen3-TTS-GGUF|qwen-talker-1.7b-base-Q4_K_M.gguf|qwen-talker-1.7b-base-Q4_K_M.gguf|1219245248|ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d"
|
||||
"Serveurperso/Qwen3-TTS-GGUF|qwen-tokenizer-12hz-Q8_0.gguf|qwen-tokenizer-12hz-Q8_0.gguf|291150624|1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94"
|
||||
)
|
||||
|
||||
mkdir -p "$DEST"
|
||||
status=0
|
||||
for entry in "${FILES[@]}"; do
|
||||
IFS='|' read -r repo hubname destname size sha <<<"$entry"
|
||||
url="https://huggingface.co/${repo}/resolve/main/${hubname}"
|
||||
f="$DEST/$destname"
|
||||
have=0
|
||||
if [[ -f "$f" ]] && [[ "$(stat -c%s "$f")" == "$size" ]]; then
|
||||
if sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
|
||||
have=1
|
||||
fi
|
||||
fi
|
||||
if (( have )); then
|
||||
echo "OK $destname"
|
||||
continue
|
||||
fi
|
||||
if (( VERIFY_ONLY )); then
|
||||
echo "MISSING $destname"
|
||||
status=1
|
||||
continue
|
||||
fi
|
||||
echo "FETCHING $destname (from $repo)"
|
||||
tmp="$f.part"
|
||||
curl -fsSL --fail-with-body -o "$tmp" "$url"
|
||||
mv "$tmp" "$f"
|
||||
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
|
||||
echo "SIZE FAIL $destname ($(stat -c%s "$f") != $size)"
|
||||
status=1
|
||||
continue
|
||||
fi
|
||||
if ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
|
||||
echo "SHA FAIL $destname"
|
||||
status=1
|
||||
continue
|
||||
fi
|
||||
echo "VERIFIED $destname"
|
||||
done
|
||||
if (( status == 0 )); then
|
||||
echo "DOWNLOAD_DONE $DEST"
|
||||
else
|
||||
echo "DOWNLOAD_FAILED $DEST" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -0,0 +1,136 @@
|
||||
# frank-tts-engine.env — tunables for **frank-tts-engine**, the arm A TTS
|
||||
# engine on Dankclaw (qwentts.cpp `tts-server`, container port **8880**, never a
|
||||
# fleet endpoint). Read by the Quadlet unit
|
||||
# (dankclaw/voiceservice/quadlet/frank-tts-engine.container) as its only
|
||||
# EnvironmentFile=, which is why every path below is the value the CONTAINER
|
||||
# sees. The host directories those mounts come from are named in
|
||||
# dankclaw/voiceservice/quadlet/frank-tts-engine.container and
|
||||
# scripts/download-frank-tts-engine-qwentts.sh.
|
||||
#
|
||||
# WHAT SITS HERE AND WHAT DOES NOT. `tts-server` reads its configuration from
|
||||
# command-line flags only — it looks at no environment variable — so this file
|
||||
# is consumed by the image's entrypoint
|
||||
# (dankclaw/voiceservice/tts-engine-start.sh), which turns each key below into
|
||||
# the matching flag. Every value has a default baked into that script, and the
|
||||
# default equals the value below: this file exists so the decided shape is
|
||||
# written down in the repo and one line can be changed without a rebuild, not
|
||||
# because the engine would otherwise fail to boot.
|
||||
#
|
||||
# THE THREAD LAW (read this before reaching for CPUQuota=, CPUAffinity=,
|
||||
# taskset or a systemd slice). There is **no `-t`/`--threads` flag** in this
|
||||
# build: src/backend.h computes the CPU thread count as
|
||||
# `std::thread::hardware_concurrency() / 2` (its own comment: GEMM shares SIMD
|
||||
# units across hyperthreads). The engine therefore sizes its pools from what the
|
||||
# kernel *reports*, not from what it is allowed to use. Pin the container to a
|
||||
# cpuset and the thread count silently changes with it — different GEMM
|
||||
# tiling, different timings, no log line saying so, and the measured numbers
|
||||
# below stop describing the process you are running. Nothing in this fleet
|
||||
# pins this container; that is a decision, not an oversight.
|
||||
#
|
||||
# THE COLD-VOICE LAW (why the unit has an ExecStartPost at all). The first
|
||||
# speech request for a registered voice decodes the ENTIRE reference clip to
|
||||
# snapshot its codec state: 6.2-8.0 s total, first audio byte at 5,530 ms. Every
|
||||
# later request for that voice restores the snapshot in 0.5 ms. Registrations
|
||||
# live in memory only, so a restart loses them and re-registration alone does
|
||||
# NOT warm the voice — the warm pass has to synthesize something. So
|
||||
# dankclaw/voiceservice/tts-engine-warm.sh (in the image at
|
||||
# /app/warm-voices.sh) runs on every start and speaks one short sentence per
|
||||
# voice. Skip it and the first person to ask for audio that day waits 5.5 s in
|
||||
# silence. It runs INSIDE this container — `podman exec`, from the unit's
|
||||
# ExecStartPost — so it reads /voices here and the keys at the bottom of this
|
||||
# file are its knobs too.
|
||||
#
|
||||
# STREAMING IS NOT BUFFERED. The same seed gives audio that differs in 92.2 %
|
||||
# of samples (-41.6 dB RMS) between `response_format=pcm` (the streaming path,
|
||||
# codec chunks pushed as they decode) and `response_format=wav` (the buffered
|
||||
# path). Nothing in this stack may compare the two, cache one against the
|
||||
# other, or assume a seed pins output across `response_format`. frank-tts always
|
||||
# asks for `wav` and re-encodes to mp3 itself; the warmer below also asks for
|
||||
# `wav`, for that reason and because the streamed path would need a reader.
|
||||
#
|
||||
# THE ROUTE LIST, verbatim from tts-server at the pinned commit, so nobody
|
||||
# debugs against a route that does not exist: GET /health, GET /v1/models,
|
||||
# GET|POST /v1/audio/voices, DELETE /v1/audio/voices/{name},
|
||||
# POST /v1/audio/speech (response_format wav or pcm). Everything else is a bare
|
||||
# 404. Note two traps in that surface: `GET /v1/audio/voices` returns an empty
|
||||
# JSON object rather than the voice list at this commit (the handler builds the
|
||||
# array and never attaches it to the document), and a `POST /v1/audio/voices`
|
||||
# carrying `spk_b64` without `rvq_b64` is rejected outright — it is `wav_b64` or
|
||||
# BOTH of the pair.
|
||||
|
||||
# --- the two weights (see scripts/download-frank-tts-engine-qwentts.sh) -------
|
||||
# Container paths; the host directory /home/dank/models/Qwen3-TTS-GGUF is
|
||||
# mounted read-only at /models. 1,219,245,248 B + 291,150,624 B, fetched from
|
||||
# https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF and byte-checked at fetch.
|
||||
#
|
||||
# The 1.7 B talker is the decided artifact (Frank, 2026-10-18). A 0.6 B talker
|
||||
# exists at 628,905,056 B and is NOT what live output was measured with; the
|
||||
# Q4_K_M quantization is the size that leaves the measured RTF margin intact on
|
||||
# this box, and the tokenizer rides at Q8_0 because the codec is where clone
|
||||
# fidelity is lost first.
|
||||
#
|
||||
# It has to be the **base** variant, not customvoice/voicedesign: the engine
|
||||
# refuses any voice registration unless qt_model_type() reports "base"
|
||||
# (tools/tts-server.cpp, the register_voice lambda). Cloned voices — the whole
|
||||
# product — are impossible on the other two variants.
|
||||
QWENTTS_MODEL=/models/qwen-talker-1.7b-base-Q4_K_M.gguf
|
||||
QWENTTS_CODEC=/models/qwen-tokenizer-12hz-Q8_0.gguf
|
||||
|
||||
# What GET /v1/models reports, and what scripts/frank-tts.env's
|
||||
# TTS_UPSTREAM_MODEL names. A model id, never a file name.
|
||||
QWENTTS_ALIAS=frank-tts-engine
|
||||
|
||||
# 0.0.0.0 is required and is not an exposure decision: tts-server binds
|
||||
# 127.0.0.1 by default, and inside its own netns that address is reachable by no
|
||||
# one, not even the façade on the same bridge — which is why a host-loopback
|
||||
# publish would not have helped either (netavark DNATs published ports only, and
|
||||
# the unit publishes nothing at all). Binding 0.0.0.0 exposes the server to the
|
||||
# one network it is attached to: the private voice bridge
|
||||
# (systemd-frank-tts-engine, 10.89.5.0/24), whose only other member is frank-tts.
|
||||
QWENTTS_HOST=0.0.0.0
|
||||
QWENTTS_PORT=8880
|
||||
|
||||
# Language label applied when a request omits one. `auto` is upstream's default
|
||||
# and what the trial ran; naming a language is a per-request field on
|
||||
# /v1/audio/speech, so this is only the fallback.
|
||||
QWENTTS_LANG=auto
|
||||
|
||||
# Serialised FIFO: one synthesis at a time, everyone else queued. Warm RTF on
|
||||
# this box is 0.55-0.67 against real time 1.0, which is the entire margin a live
|
||||
# stream has; two concurrent syntheses split the same cores and the margin is
|
||||
# gone, with the added effect that the second caller's time-to-first-audio now
|
||||
# includes the first one's whole utterance. The façade's own budget
|
||||
# (SHIM_TIMEOUT=600, and open-notebook's ESPERANTO_TTS_TIMEOUT=660 sitting one
|
||||
# round-trip outside it) is what absorbs the queue.
|
||||
QWENTTS_MAX_BATCH=1
|
||||
|
||||
# --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) --------------
|
||||
# These three are read by the warm hook, not by tts-server. They live here
|
||||
# because the hook is exec-ed into this container and therefore inherits this
|
||||
# same EnvironmentFile — one file, one restart, no second source of truth.
|
||||
|
||||
# Container path of the voice packs; the host directory is /home/dank/voices,
|
||||
# mounted read-only by the unit and mounted (read-only, same source) by
|
||||
# frank-tts as well. A directory counts as a voice exactly when it contains
|
||||
# reference.wav, which is the same rule frank-tts applies.
|
||||
VOICES_DIR=/voices
|
||||
|
||||
# How long the hook waits for /health before giving up. Model load dominates
|
||||
# this: both GGUFs are read and the context is built before the HTTP thread
|
||||
# binds, and a boot competes with every other unit on the box. 300 s is generous
|
||||
# against a cold cache; the unit's TimeoutStartSec (900 s) is what bounds the
|
||||
# whole pass, warming included.
|
||||
HEALTH_WAIT=300
|
||||
|
||||
# The sentence each voice speaks to snapshot its codec state, and the text the
|
||||
# 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.
|
||||
WARM_TEXT="Warm up, one two three."
|
||||
|
||||
# Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is
|
||||
# ~15x margin: reaching it means the engine is wedged, and systemd is still
|
||||
# waiting on this pass before it calls the unit started.
|
||||
REQUEST_TIMEOUT=120
|
||||
+28
-8
@@ -138,6 +138,21 @@ for path in "${CHANGED[@]}"; do
|
||||
add_test tests/docs_completeness.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
;;
|
||||
# The voice stack: the arm A engine unit, the private bridge it owns,
|
||||
# its image and its entrypoint. Like the open-notebook block above,
|
||||
# this case MUST precede the dankclaw/*.container glob — that `*`
|
||||
# crosses `/`, so without this case
|
||||
# dankclaw/voiceservice/quadlet/frank-tts-engine.container is routed to
|
||||
# the open-webui/pihole/searxng seams and its own seam never runs.
|
||||
# The façade's seam rides along: the two share a directory, a network
|
||||
# and the request path a consumer takes.
|
||||
dankclaw/voiceservice/*)
|
||||
add_test tests/frank_tts_engine.bats
|
||||
add_test tests/frank_tts_unit.bats
|
||||
add_test tests/deployment_flow.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
add_test tests/docs_completeness.bats
|
||||
;;
|
||||
dankclaw/*.container|dankclaw/*.network)
|
||||
add_test tests/open_webui_quadlet.bats
|
||||
add_test tests/pihole.bats
|
||||
@@ -197,6 +212,19 @@ for path in "${CHANGED[@]}"; do
|
||||
# as additions, with "no focused-test mapping".
|
||||
.AGENTTRASH/*|*/.AGENTTRASH/*)
|
||||
;;
|
||||
# The two voice env files both reach a container through a Quadlet unit
|
||||
# (EnvironmentFile=), so they select the voice seams plus the
|
||||
# script-syntax seam. This case has to precede the generic
|
||||
# scripts/*.service and scripts/* fallbacks below, or a change to
|
||||
# scripts/frank-tts-engine.env would run nothing but bash -n. The
|
||||
# warmer is NOT here: it ships inside the engine image and is routed
|
||||
# with the rest of dankclaw/voiceservice/* above.
|
||||
scripts/frank-tts-engine.env|scripts/frank-tts.env)
|
||||
add_test tests/frank_tts_engine.bats
|
||||
add_test tests/frank_tts_unit.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
add_test tests/scripts_syntax.bats
|
||||
;;
|
||||
# frank-archon is llama.cpp (the unsloth b11030-mix prebuilt) on the
|
||||
# two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
|
||||
# Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
|
||||
@@ -229,14 +257,6 @@ for path in "${CHANGED[@]}"; do
|
||||
add_test tests/scripts_syntax.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
;;
|
||||
# The voice-pack builder is the only thing that enforces the 10-second
|
||||
# source-material invariant and the artifact sizes the packs are pinned
|
||||
# to, so it selects its own seam rather than landing on the generic
|
||||
# scripts/* syntax fallback. This case has to precede that fallback.
|
||||
scripts/build-voice-pack.sh|scripts/voice-packs.env)
|
||||
add_test tests/voice_pack_unit.bats
|
||||
add_test tests/scripts_syntax.bats
|
||||
;;
|
||||
scripts/*)
|
||||
add_test tests/scripts_syntax.bats
|
||||
;;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# voice-packs.env — tunables for the **frank-tts voice packs**: the three
|
||||
# numbers per voice (source file, crop offset, crop length) plus the pinned
|
||||
# engine facts that turn a crop into a pack. Read by
|
||||
# `scripts/build-voice-pack.sh`, which is the only thing that consumes this
|
||||
# file — the running service reads none of it, because the packs themselves are
|
||||
# the interface (see scripts/frank-tts.env for what the shim resolves).
|
||||
#
|
||||
# WHAT IS TRACKED HERE AND WHAT IS NOT. Paths and numbers, never credentials,
|
||||
# which is the same rule the other `scripts/*.env` files follow. The pack
|
||||
# DIRECTORIES are host state under $VOICE_PACK_DIR and stay out of the repo; the
|
||||
# crops that produce them are exactly the kind of decision worth a diff, so the
|
||||
# offsets live here. If a crop changes, change it here and rebuild with
|
||||
# --force — the manifest inside the pack records what was actually cut.
|
||||
#
|
||||
# THE OFFSETS ARE NOT ARBITRARY. Two rules pick them:
|
||||
# 1. The source-material invariant: seconds 0-10 of any sample file are
|
||||
# forbidden voice material. The builder refuses a crop that starts inside
|
||||
# that region and asserts the same thing again from the manifest it just
|
||||
# wrote, so the values below are checked, not trusted.
|
||||
# 2. Speech density: the trial cut these from a silencedetect scan of the
|
||||
# source (noise -35 dB, 0.6 s) and took the longest run of continuous
|
||||
# speech at or past the scan window — a reference that starts mid-word or
|
||||
# contains a page-turn of silence clones the artefacts, not the voice.
|
||||
# serverAppConfigs-docs/Dankclaw/Voice Packs.md carries the recipe.
|
||||
#
|
||||
# WHY THE SHA256 LINES EXIST. A crop offset is a statement about one exact
|
||||
# file: 98.739 s into a re-encode of the same audiobook is a different sentence
|
||||
# and therefore a different voice. The builder hashes the source, compares it
|
||||
# with the pinned digest when one is set, and writes the digest into the pack's
|
||||
# manifest — so a pack can always be traced back to the bytes it was cut from.
|
||||
# The three values below are the trial's digests (2026-10-18), verified against
|
||||
# the files on disk.
|
||||
#
|
||||
# THE ENGINE BLOCK IS A PIN, NOT A DEFAULT. The speaker artifacts come from
|
||||
# `qwen-codec` in the qwentts.cpp tree at $VOICE_PACK_ENGINE_COMMIT: it takes a
|
||||
# 24 kHz WAV, writes the RVQ code stream next to it with the extension swapped,
|
||||
# and with --talker also runs that talker's speaker encoder to write the
|
||||
# x-vector .spk. The talker has to be a **Base** build — the customvoice and
|
||||
# voicedesign GGUFs carry no speaker encoder and the extractor says so. The
|
||||
# sizes the builder then verifies (8,192 B of .spk, 5,456-6,864 B of .rvq) are
|
||||
# derived from the codec config in the GGUF, so an engine bump that changes them
|
||||
# fails the build on purpose; see [[Voice Packs]] for the bump procedure.
|
||||
#
|
||||
# HOST PATHS, NOT CONTAINER PATHS. This runs on the host that holds the engine
|
||||
# build (Dankclaw), outside any container, with no GPU and no fleet endpoint.
|
||||
# The model paths below are where scripts/download-frank-tts-engine-qwentts.sh
|
||||
# puts the GGUFs; until that fetch has run, the same two files sit in the trial
|
||||
# tree at /home/dank/voice-audition/qwentts/models/ and the tool is only in its
|
||||
# build directory. The SOURCE paths are the third thing to place: the three
|
||||
# sample files live on the workstation today and have to be copied to
|
||||
# $VOICE_PACK_SOURCE_DIR on Dankclaw before a build can read them.
|
||||
|
||||
# The packs root on the host: the directory frank-tts mounts read-only at
|
||||
# /voices, one sub-directory per voice.
|
||||
VOICE_PACK_DIR=/home/dank/voices
|
||||
|
||||
# The pack list. `--all` walks it in order; every name has to carry the four
|
||||
# per-voice values below.
|
||||
VOICE_PACKS="narrator1 narrator2 narrator3"
|
||||
|
||||
# Where the source recordings live on the engine host.
|
||||
VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources
|
||||
|
||||
# narrator1 — Randall Munroe "How To", read by Wil Wheaton. Longest speech run
|
||||
# at or past the scan window: 98.739-118.636 s, 19.897 s, which packs to the
|
||||
# 5,456 B floor of the .rvq window.
|
||||
VOICE_PACK_NARRATOR1_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample.mp3
|
||||
VOICE_PACK_NARRATOR1_OFFSET=98.739
|
||||
VOICE_PACK_NARRATOR1_DURATION=19.897
|
||||
VOICE_PACK_NARRATOR1_SHA256=2299510467eb9143455d8c1e6a21601c3b451dd59c1dc99b99d4aacb54d1d850
|
||||
|
||||
# narrator2 — "Abyssal Archive" (Lokey). 25 s is the ceiling of the reference
|
||||
# window and packs to exactly 6,864 B of .rvq.
|
||||
VOICE_PACK_NARRATOR2_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample2.m4b
|
||||
VOICE_PACK_NARRATOR2_OFFSET=191.13
|
||||
VOICE_PACK_NARRATOR2_DURATION=25
|
||||
VOICE_PACK_NARRATOR2_SHA256=8e1e33317baf67b2cca67e4ce1b095ae477a18285c7373ec18ad013f2cc320e0
|
||||
|
||||
# narrator3 — "Children of Time" (Adrian Tchaikovsky, author-read).
|
||||
VOICE_PACK_NARRATOR3_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample3.m4b
|
||||
VOICE_PACK_NARRATOR3_OFFSET=489.232
|
||||
VOICE_PACK_NARRATOR3_DURATION=25
|
||||
VOICE_PACK_NARRATOR3_SHA256=6875d2c1db421ab96ccbb68855fa9efd4e054297e94dd1b4345eb6a9869da945
|
||||
|
||||
# The pinned extractor and the two GGUFs it needs. $VOICE_PACK_TOOL is a build
|
||||
# output, not a download: rebuild it at $VOICE_PACK_ENGINE_COMMIT after a bump
|
||||
# rather than pointing this line at a newer tree.
|
||||
VOICE_PACK_TOOL=/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec
|
||||
VOICE_PACK_CODEC_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-tokenizer-12hz-Q8_0.gguf
|
||||
VOICE_PACK_TALKER_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-talker-1.7b-base-Q4_K_M.gguf
|
||||
VOICE_PACK_ENGINE_REPO=https://github.com/ServeurpersoCom/qwentts.cpp
|
||||
VOICE_PACK_ENGINE_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
|
||||
|
||||
# CPU only, and never in the way of the text endpoints on this box: ggml thread
|
||||
# count and `nice -n`. The extractor picks the best backend it is given, so the
|
||||
# device is stated rather than guessed.
|
||||
VOICE_PACK_DEVICE=CPU
|
||||
VOICE_PACK_THREADS=8
|
||||
VOICE_PACK_NICE=19
|
||||
|
||||
# The reference window and the shape the packs are cut to. 24 kHz mono PCM is
|
||||
# the codec's own input rate, so no resampling happens inside the engine.
|
||||
VOICE_PACK_SAMPLE_RATE=24000
|
||||
VOICE_PACK_MIN_SECONDS=18
|
||||
VOICE_PACK_MAX_SECONDS=25
|
||||
|
||||
# Artifact arithmetic, all four values read out of the pinned tree: the codec
|
||||
# runs at 12.5 Hz (1,920 samples per frame at 24 kHz), packs 16 codebooks at 11
|
||||
# bits = 22 bytes per frame, and the Base talker's speaker encoder emits
|
||||
# enc_dim 2048 f32 = 8,192 bytes. The .rvq window is 22 x 248 = 5,456 B up to
|
||||
# 22 x 312 = 6,864 B, i.e. crops of 19.84-25 s: an 18 s crop is a legal
|
||||
# reference.wav but too little code for the engine, and the builder says so
|
||||
# before it runs anything.
|
||||
VOICE_PACK_FRAME_RATE=12.5
|
||||
VOICE_PACK_RVQ_BYTES_PER_FRAME=22
|
||||
VOICE_PACK_SPK_BYTES=8192
|
||||
VOICE_PACK_RVQ_MIN_BYTES=5456
|
||||
VOICE_PACK_RVQ_MAX_BYTES=6864
|
||||
|
||||
# Where a --force rebuild puts the pack it replaced. Nothing is ever deleted;
|
||||
# the retired directory stays readable beside its replacement until a human
|
||||
# clears it.
|
||||
VOICE_PACK_TRASH_DIRNAME=.AGENTTRASH
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
type: reference
|
||||
description: "frank-tts-engine is the arm A TTS engine on Dankclaw: qwentts.cpp's resident tts-server with the 1.7 B base talker, on a private podman bridge behind frank-tts — repo-owned, not yet deployed."
|
||||
tags:
|
||||
- dankclaw
|
||||
- tts
|
||||
- voice
|
||||
- quadlet
|
||||
- cpu
|
||||
timestamp: 2026-10-18
|
||||
---
|
||||
|
||||
# Frank TTS Engine
|
||||
|
||||
`frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** in RAM once and synthesizing from there. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page.
|
||||
|
||||
**Nothing here is deployed.** This page documents repo-owned configuration — the Quadlet unit and network, the image build, the weight fetcher, the boot warmer, the deployer path — and the measured facts those files were written against. No container, image, weight file or network exists on Dankclaw yet, and the engine publishes no port at all, so there is nothing for the homepage checker or a browser to reach. Deploying it is a deliberate next step, in the order at the bottom of this page.
|
||||
|
||||
**Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders.
|
||||
|
||||
## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub, and the CPU trial on this host)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline |
|
||||
| Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8` — the exact line the 2026-10-18 trial ran on this box. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both of which pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** |
|
||||
| Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree |
|
||||
| Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) |
|
||||
| Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` |
|
||||
| Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact |
|
||||
| Memory | **5.7 GiB peak RSS** with both files resident (2026-10-18 trial). No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance |
|
||||
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on this box is 0.55–0.67 against real time 1.0, and that margin is the entire headroom a live stream has; two concurrent syntheses split the same cores and the second caller's first byte additionally waits for the first utterance to finish |
|
||||
| Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` |
|
||||
| Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only |
|
||||
| Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside |
|
||||
| Routes | `GET /health`, `GET /v1/models`, `GET|POST /v1/audio/voices`, `DELETE /v1/audio/voices/{name}`, `POST /v1/audio/speech` (`response_format` = `wav` or `pcm`; **default `pcm`**). Everything else is a bare 404. `POST /v1/audio/speech` requires `input`; unknown `voice`, missing `input` and a bad `speed` are 400s, an empty generation is a 502 |
|
||||
|
||||
## Why this engine, and not the alternatives
|
||||
|
||||
The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. RTF only has to stay under 1.0 with margin; what separates the arms is first-byte latency and **spread**. Measured on this host the same day (CPU; the full A/B is the section *"Decision: arm A for live output (2026-10-18)"* in [[Local TTS and Voice Cloning]], which is the source of these numbers):
|
||||
|
||||
| Measure | Resident `tts-server` (arm A, chosen) | Mainline per-request CLI path (arm B) |
|
||||
|---|---|---|
|
||||
| first audio byte | **86–451 ms** end to end; **94–112 ms** server-side | model load, per request |
|
||||
| warm real-time factor while streaming | **0.55–0.67** | 0.93–0.99 |
|
||||
| run-to-run spread | **±0.01** | ±0.25 |
|
||||
| HTTP surface | serves `/v1/audio/speech` itself | none — façade CLI only |
|
||||
|
||||
The spread is the whole argument: at ±0.01 the engine finishes synthesizing 1.5–1.8× faster than the audio plays, every run; at ±0.25 some runs fall behind real time and a live listener hears the stream stall mid-sentence. The rollback path stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs.
|
||||
|
||||
## Three laws this service lives under
|
||||
|
||||
**1. The cold-voice law → the unit warms at boot.** The **first** speech request for a registered voice decodes the whole reference clip to snapshot its codec state: **6.2–8.0 s, first audio byte at 5 530 ms**. Every later request for that voice restores the snapshot in **0.5 ms**. Registrations live in memory only — a restart forgets every voice — and re-registering does **not** warm a voice, only synthesizing does. So `frank-tts-engine.container` carries
|
||||
|
||||
```
|
||||
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
|
||||
TimeoutStartSec=900
|
||||
```
|
||||
|
||||
and the warm pass runs on every start including boot. Without it, whoever asks for audio first that day pays 5.5 s of silence. The `-` prefix is load-bearing: with `Restart=always`, a warmer that exits non-zero would restart the container and crash-loop the endpoint forever. The hook is `podman exec` rather than a host script **because this container publishes nothing** — there is no address the host could dial — and a consequence worth knowing: an exec inherits the unit's `EnvironmentFile`, so the warmer's knobs (`VOICES_DIR`, `HEALTH_WAIT`, `WARM_TEXT`, `REQUEST_TIMEOUT`) live in `scripts/frank-tts-engine.env` beside the engine's own. The deployer runs the same hook a second time so a failed warm becomes a deploy result rather than only a journal line, and so the per-voice numbers land on the console:
|
||||
|
||||
```
|
||||
frank-tts-engine-warm: voice narrator1 cold=6821ms warm=412ms
|
||||
frank-tts-engine-warm: 3 voice(s) registered and warm
|
||||
```
|
||||
|
||||
**2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's GEMM tiling and its timings, with no log line. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour.
|
||||
|
||||
**3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either.
|
||||
|
||||
## The voice registry contract
|
||||
|
||||
A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted:
|
||||
|
||||
- `{"name": …, "wav_b64": …}` — the reference clip travels as base64 WAV (the engine resamples to 24 kHz internally). **This is what the boot warmer sends.**
|
||||
- `{"name": …, "spk_b64": …, "rvq_b64": …}` — the extracted embedding plus the reference codec codes. **`spk_b64` alone is rejected** (`src/tts-server.h:480`): it is `wav_b64` or *both* of the pair.
|
||||
|
||||
Whether a registered voice is *embedding-only* or *in-context-learning* is then decided by `ref_text`: `tools/tts-server.cpp:258` selects ICL only when **both** `ref_text` and the reference codes are present. Registering `{name, wav_b64}` with no `ref_text` therefore yields the **x-vector voice** — the shipped default (0.57–0.63 warm RTF against ICL's 0.67) and the only one this fleet can produce honestly, because the audition crops have no trustworthy transcript to put in `ref_text`. The ICL door stays open for batch work like the podcast render: a pack that wants it needs its text in the pack plus one key in the payload built by `dankclaw/voiceservice/tts-engine-warm.sh`.
|
||||
|
||||
The packs themselves are host state at `/home/dank/voices/<name>/reference.wav` (24 kHz mono PCM), alongside the `reference.spk` / `reference.rvq` extraction and a manifest. Both halves of the voice stack read that directory — `frank-tts` for its name list, the warmer for its registrations — and `tts-server` never opens it. Two naming facts, both from the source: pack names must be **lowercase**, because `tts_voice_name` lowercases every name engine-side while the façade matches the directory name exactly (the warmer refuses anything outside `[a-z0-9._-]`); and `GET /v1/audio/voices` at this commit returns an empty JSON object rather than the voice list (the handler builds the array and never attaches it to the document), so **use the façade's `/v1/voices` for "which voices exist"** and treat the engine's listing route as broken, not empty.
|
||||
|
||||
## Status visibility
|
||||
|
||||
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). Its up-state is visible in four places, and the runbook uses all of them:
|
||||
|
||||
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
|
||||
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
|
||||
- `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` — loopback is valid **inside** the container's namespace, which is where the address lives.
|
||||
- The journal of the warm pass: `journalctl --user -u frank-tts-engine`, whose per-voice `cold=` / `warm=` lines are the only signal that voices are actually registered — a live HTTP listener with an empty registry still looks healthy.
|
||||
|
||||
The **façade** on 7772 is the pair's public surface and carries the probe. Note the honest limit there: a façade that answers `/health` does **not** prove the engine behind it is alive — it reports its backend and its voice list, not an upstream ping. That is exactly why the warm pass is part of the deploy and not only of the boot.
|
||||
|
||||
## Files this service owns
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts |
|
||||
| `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights |
|
||||
| `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav`, speak one sentence, report cold/warm |
|
||||
| `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, health probe, warm hook, `Restart=always` |
|
||||
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry |
|
||||
| `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the cold-voice law in its header |
|
||||
| `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` |
|
||||
| `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm |
|
||||
| `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps |
|
||||
|
||||
## Deploy order (host steps, in order)
|
||||
|
||||
Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblocks the podcast pipeline.
|
||||
|
||||
1. **Fetch the weights** — `scripts/download-frank-tts-engine-qwentts.sh` (1.4 GiB, byte + sha256 verified, idempotent; `--verify-only` is what the deployer calls).
|
||||
2. **Deploy the engine** — `./scripts/deploy.sh dankclaw frank-tts-engine`. Builds the image (needs github.com once), installs the unit and the network file, restarts, gates on the container's health status, then warms.
|
||||
3. **Confirm the endpoint is alive** — `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` → `{"status":"ok"}`, and `systemctl --user status frank-tts-engine` showing the unit `active` with the warm pass behind it.
|
||||
4. **Confirm the voices are known** — the warmer's journal lines must name every pack; once the façade is up, `curl http://127.0.0.1:7772/v1/voices` is the authoritative list.
|
||||
5. **Prove the cold-voice law is paid at boot, not by a listener** — two identical synthesis requests for one voice, back to back, from inside the engine's namespace; the **second** must answer well under a second (`warm=` in the journal is already that number; the manual pair is the confirmation on the real artifact).
|
||||
6. Then the façade on **7772** (its own page): nothing may be published there until the engine answers, because open-notebook's render fails the moment the façade is up and the engine is not.
|
||||
7. Nothing on 7772 exists yet — that is why open-notebook's podcast render cannot run. See [[Open Notebook]].
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Meaning | Move |
|
||||
|---|---|---|
|
||||
| Unit restarts every 10 s, journal says `cannot read /models/…` | weights missing or the mount is empty | run the fetcher; re-deploy |
|
||||
| Unit `active`, but every voice name returns 400 from the façade | the engine restarted and lost its in-memory registry, or the warm pass failed | `journalctl --user -u frank-tts-engine` for the warmer's lines; `podman exec frank-tts-engine /app/warm-voices.sh` |
|
||||
| First request of the day is slow, the rest are fast | the warm pass did not run (the hook carries `-`, so it cannot fail the unit) | look for `did not answer` in the journal — the warmer gave up while the model was still loading |
|
||||
| Everything is ~2× slower after a systemd tweak | a cpuset/cap pin changed `hardware_concurrency()` and the engine resized its thread pools | remove the pin (law 2) |
|
||||
| `502` from the façade with `frank-tts upstream request failed` | the façade cannot resolve or reach the engine | `podman network inspect systemd-frank-tts-engine` and `podman inspect frank-tts --format '{{json .NetworkSettings.Networks}}'` — the façade must be attached to the bridge; a `127.0.0.1:8880` or `host.containers.internal:8880` upstream in `scripts/frank-tts.env` is the old shape and cannot work |
|
||||
| A voice is missing from the list | its directory has no `reference.wav`, or the name is not lowercase | fix the pack; both are refused loudly by design |
|
||||
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | rebuild: `build_compose_image` skips on an unchanged `Containerfile.engine`, and the `COPY` of the warmer changed it |
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
type: reference
|
||||
description: "How a frank-tts voice pack is built on Dankclaw: the three files every pack must carry, the 10-second source-material gate scripts/build-voice-pack.sh enforces, how to add a fourth voice, and what to do when a pinned engine bump changes the extractor's output sizes."
|
||||
tags:
|
||||
- dankclaw
|
||||
- frank-tts
|
||||
- voices
|
||||
- tts
|
||||
timestamp: 2026-10-18
|
||||
---
|
||||
|
||||
# Voice Packs
|
||||
|
||||
A **voice pack** is the unit of identity for **frank-tts**: one directory under
|
||||
`/home/dank/voices`, named by the voice, carrying the reference audio that makes
|
||||
the name real plus the pinned engine's own speaker artifacts and a provenance
|
||||
record. `voice=narrator1` on `POST /v1/audio/speech` resolves to
|
||||
`/home/dank/voices/narrator1/reference.wav` and to nothing else — a name without
|
||||
that file is a 400 that lists the names that do exist, because a silent fallback
|
||||
voice is how a wrong-voice endpoint stays wrong for weeks.
|
||||
|
||||
**Host:** Dankclaw (192.168.0.143) — **Pack root:** `/home/dank/voices`, host
|
||||
state that stays out of the repo, mounted read-only at `/voices` by the façade
|
||||
unit — **Builder:** `scripts/build-voice-pack.sh` with tunables in
|
||||
`scripts/voice-packs.env` — **Seam:** `tests/voice_pack_unit.bats` — **Engine:**
|
||||
arm A `qwentts.cpp`, decided in [[Local TTS and Voice Cloning]].
|
||||
|
||||
Packs are **world-readable on purpose** (`drwxr-xr-x`, owner `dank`): the façade
|
||||
container runs as uid 10000 and reads the mount from outside its own uid map.
|
||||
|
||||
## The required files
|
||||
|
||||
| File | What it is | Who reads it |
|
||||
|------|------------|--------------|
|
||||
| `reference.wav` | the crop: 24 kHz mono `pcm_s16le`, **18–25 s** of clean single-speaker audio | the façade (it is the existence gate for the name) and the engine's boot warmer, which base64s it and registers the voice with no transcript |
|
||||
| `reference.spk` | the engine's x-vector: **8,192 B** = `enc_dim` 2048 × f32 | the engine, for the ICL/`--ref-spk` path |
|
||||
| `reference.rvq` | the engine's RVQ code stream: **5,456–6,864 B**, 16 codebooks × T frames packed at 11 bits = 22 B per 12.5 Hz frame | the engine, for the ICL/`--ref-rvq` path |
|
||||
| `manifest.json` | provenance: source path and sha256, crop start/end offsets, sample rate, duration, the tool and engine commit that made the artifacts, and the `voice` name | humans, and the builder itself on its second gate assertion |
|
||||
|
||||
`qwen-codec.log` rides along with the extractor's own record of the run; the
|
||||
façade ignores anything it does not name and never deletes anything.
|
||||
|
||||
Division of enforcement, which is deliberate and not a gap: the **façade** gates
|
||||
on `reference.wav` because that is the only engine-agnostic term (see the
|
||||
voice-pack law in `scripts/frank-tts.env`); the **builder** is what refuses to
|
||||
emit a pack without its engine artifacts; the **engine** registers a voice from
|
||||
the WAV alone because its own `POST /v1/audio/voices` refuses a lone embedding —
|
||||
it wants `wav_b64` or **both** `spk_b64` and `rvq_b64`. So the artifacts are in
|
||||
the pack to make ICL a per-voice opt-in, not to boot it.
|
||||
|
||||
## How a pack is built
|
||||
|
||||
```bash
|
||||
# everything except the extractor: gate, crop, probe, plan, the manifest it would write
|
||||
scripts/build-voice-pack.sh --dry-run narrator1 /home/dank/voices
|
||||
|
||||
# the real thing, on the host that holds the engine build
|
||||
scripts/build-voice-pack.sh narrator1 /home/dank/voices
|
||||
```
|
||||
|
||||
Crops come from `scripts/voice-packs.env` — per voice: source path, offset,
|
||||
duration, and the source's pinned sha256 — and any of them can be overridden on
|
||||
the command line (`--source`, `--offset`, `--duration`, `--sha256`, `--tool`).
|
||||
`--all` walks `VOICE_PACKS`. The builder assumes no GPU, no container, no fleet
|
||||
endpoint and no network: `ffmpeg` cuts the crop, the pinned extractor makes the
|
||||
artifacts, and both run `nice -n 19` so a build never competes with the text
|
||||
endpoints on that box.
|
||||
|
||||
The extractor is `qwen-codec` in the `qwentts.cpp` tree at
|
||||
`VOICE_PACK_ENGINE_COMMIT` (`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4`). Its
|
||||
usage text, read out of that tree and not guessed:
|
||||
|
||||
```
|
||||
Usage: qwen-codec --model <gguf> [-i <input>] [--talker <gguf>] [--format <fmt>]
|
||||
|
||||
Required:
|
||||
--model <gguf> Codec GGUF (qwen-tokenizer-12hz-*.gguf)
|
||||
|
||||
Optional:
|
||||
--device <name> Compute device: CUDA0, Vulkan0, MTL0, CPU...
|
||||
(default: best available)
|
||||
-i <path> Input. WAV -> encode, .rvq -> decode
|
||||
--talker <gguf> Talker GGUF (Base only). Encode also extracts the speaker
|
||||
embedding and writes it next to the .rvq as a .spk file
|
||||
--format <fmt> WAV output format: wav16, wav24, wav32 (default: wav16)
|
||||
|
||||
Output is auto-named next to input : clip.wav -> clip.rvq, clip.rvq -> clip.wav.
|
||||
```
|
||||
|
||||
Three consequences shape the builder. **There is no `-o`**, so the pack's WAV is
|
||||
staged under its final stem and the artifacts land beside it with nothing to
|
||||
rename. **`--talker` must be a _Base_ talker** — the customvoice and voicedesign
|
||||
GGUFs carry no speaker encoder and the tool says `has no speaker encoder (Base
|
||||
only)`. **Encode truncates to the hop boundary**, so the `.rvq` size is arithmetic,
|
||||
not chance: `22 × floor(samples / 1920)` bytes, which is where the recorded
|
||||
5,456–6,864 B window comes from and why the **effective crop window is
|
||||
19.84–25 s** even though the WAV law says 18–25 s. A crop shorter than that is
|
||||
refused before the extractor runs, with the number in the message.
|
||||
|
||||
The build is staged and published, never assembled in place: `reference.wav` is
|
||||
the file the façade reads, so it moves into position **last**, after the
|
||||
artifacts and the manifest. A failed extractor therefore leaves a pack directory
|
||||
that is not a voice. Nothing is ever deleted — an existing pack is refused unless
|
||||
`--force`, and `--force` moves the whole old directory to
|
||||
`/home/dank/voices/.AGENTTRASH/<voice>-<UTC>/` and builds fresh beside it.
|
||||
|
||||
Measured on the shipped `narrator1` crop (2026-10-18, `--dry-run` against the
|
||||
real source): 98.739–118.636 s → `955,378 B` of 24 kHz mono `pcm_s16le`, 19.897
|
||||
s, planned `.rvq` 5,456 B — the same byte counts the trial produced by hand, and
|
||||
the extractor itself costs 1.8–2.1 s and 1.3–1.5 GiB peak per voice.
|
||||
|
||||
## The 10-second gate
|
||||
|
||||
Project-wide source-material invariant: **the first 10 seconds of any sample
|
||||
file used to generate voices is forbidden material.** An offset that starts
|
||||
inside it is refused with exit 1 and a message that names the invariant, before
|
||||
a directory is created:
|
||||
|
||||
```
|
||||
ERROR: source-material invariant violated: the crop must start past the first
|
||||
10 s of the source file, and 'narrator1' asks for 4.5 s into … — nothing was
|
||||
written.
|
||||
```
|
||||
|
||||
The same gate is then asserted **from the manifest** that was just written —
|
||||
`.crop.offset_seconds >= .invariant.forbidden_source_seconds`, plus the recorded
|
||||
sha256 against the source it claims — so a pack cannot reach disk carrying a
|
||||
forbidden crop even if the offsets in the env file are edited into nonsense.
|
||||
`10` is not a knob: `scripts/voice-packs.env` carries the offsets, the sample
|
||||
rate, the windows and the engine facts, but the forbidden region is a constant in
|
||||
the builder. `tests/voice_pack_unit.bats` pins both halves (a refusal that
|
||||
creates nothing, and one legal crop that builds the whole pack) and applies the
|
||||
invariant to every crop in the tracked pack list.
|
||||
|
||||
## Adding a fourth voice
|
||||
|
||||
1. Pick the source file and hash it: `sha256sum /path/to/sample.wav`. A crop
|
||||
offset is a claim about one exact file, so the digest goes in the env with it.
|
||||
2. Find a speech-dense stretch **past 10 s**. The trial's recipe, applied once:
|
||||
`ffmpeg -ss 60 -t 600 -i SRC -af silencedetect=noise=-35dB:d=0.6 -f null -`,
|
||||
build the runs of non-silence, keep runs ≥ 18 s, take the longest (earliest
|
||||
on ties), and cap at 25 s. Aim for 20–25 s: shorter than 19.84 s and the
|
||||
`.rvq` falls under the floor.
|
||||
3. Add the four lines to `scripts/voice-packs.env` and the name to
|
||||
`VOICE_PACKS`:
|
||||
|
||||
```
|
||||
VOICE_PACKS="narrator1 narrator2 narrator3 narrator4"
|
||||
|
||||
VOICE_PACK_NARRATOR4_SOURCE=/home/dank/voice-sources/audiosample4.m4b
|
||||
VOICE_PACK_NARRATOR4_OFFSET=142.31
|
||||
VOICE_PACK_NARRATOR4_DURATION=24
|
||||
VOICE_PACK_NARRATOR4_SHA256=<64 hex>
|
||||
```
|
||||
|
||||
The name must be lowercase `[a-z0-9._-]`: the façade matches the directory
|
||||
name verbatim while `tts-server` lowercases names internally, so an uppercase
|
||||
name would be reported one way by the façade and another by the engine. The
|
||||
builder refuses one anyway.
|
||||
4. `scripts/build-voice-pack.sh --dry-run narrator4 /home/dank/voices`, read the
|
||||
plan, then drop `--dry-run`.
|
||||
5. `./tests/run.sh` (the pack-list invariant test covers the new row for free).
|
||||
6. Register it with the running engine. The engine's voice registry is
|
||||
memory-only and warm-on-boot, but a restart re-decodes **every** voice (6–8 s
|
||||
each), so adding one pack uses the hook instead:
|
||||
|
||||
```bash
|
||||
podman exec frank-tts-engine /app/warm-voices.sh
|
||||
curl -s localhost:7772/v1/audio/voices # the façade's list, not the engine's
|
||||
```
|
||||
|
||||
The hook walks `/voices`, registers any pack with a `reference.wav` it has not
|
||||
seen, and logs `voice <name> cold=…ms warm=…ms` to the unit's journal. The
|
||||
rootless unit restart — `systemctl --user restart frank-tts-engine.service` —
|
||||
is for when the unit or its env changed, not for a new pack.
|
||||
|
||||
## When the extractor's output size changes on an engine bump
|
||||
|
||||
The builder treats the two artifact sizes as properties of the pinned engine, so
|
||||
an engine bump that changes them **fails the build**: `reference.spk is N B,
|
||||
expected 8192 B` or `reference.rvq is N B, expected …`. That is the failure being
|
||||
loud, not a bug. Work through it in order:
|
||||
|
||||
1. Re-read the tool's own numbers rather than trusting this page: `qwen-codec`
|
||||
prints `SpeakerEncoder] Loaded: enc_dim=…` and `Pipeline] Ready: hop … , …
|
||||
codebooks @ … Hz` on every run, and the pinned tree's
|
||||
`tools/qwen-codec.cpp` documents the packing. `enc_dim × 4` is the `.spk`;
|
||||
`codebooks × code_bits / 8` bytes per frame × frames is the `.rvq`.
|
||||
2. Decide whether the new number is intended. A changed `enc_dim` means the old
|
||||
`.spk` files are unreadable by the new engine, so **every** pack must be
|
||||
rebuilt, not just the failing one.
|
||||
3. Update the four arithmetic lines in `scripts/voice-packs.env`
|
||||
(`VOICE_PACK_SPK_BYTES`, `VOICE_PACK_RVQ_MIN_BYTES`, `VOICE_PACK_RVQ_MAX_BYTES`,
|
||||
and `VOICE_PACK_RVQ_BYTES_PER_FRAME` if the codebook packing changed) **and**
|
||||
`VOICE_PACK_ENGINE_COMMIT`, `VOICE_PACK_TOOL`, `VOICE_PACK_CODEC_MODEL`,
|
||||
`VOICE_PACK_TALKER_MODEL` to the new build. An env diff that moves the sizes
|
||||
without moving the commit is the mistake to avoid.
|
||||
4. Rebuild all three packs with `--force` (the retired directories keep the old
|
||||
bytes readable beside their replacements until a human clears them), then
|
||||
restart the engine rather than using the warm hook: the hook only registers
|
||||
packs it has **not** seen, and every voice here is already registered against
|
||||
the old artifacts. `systemctl --user restart frank-tts-engine.service`
|
||||
re-warms them all from the new bytes.
|
||||
5. `./tests/voice_pack_unit.bats` — the stub in that seam reproduces the packing
|
||||
law, so it needs the same arithmetic update as the env file. The seam's
|
||||
engine-bump test is exactly this scenario with a wrong `.spk` size, and its
|
||||
assertion — no `reference.wav`, no manifest — is the property that matters.
|
||||
|
||||
## Host state (census 2026-10-18, read-only)
|
||||
|
||||
- `/home/dank/voices/` holds `narrator1`, `narrator2`, `narrator3`, each with
|
||||
`reference.wav`, a `reference16k.wav`, and a `manifest.json` **in the trial's
|
||||
own schema** (`kind: voice-reference-pack`, a silence-scan record). They carry
|
||||
**no** `reference.spk` / `reference.rvq`: the trial's artifacts live in its own
|
||||
work directory (`/home/dank/voice-audition/qwentts/voices/`, and only for
|
||||
narrator1 and narrator2). The builder's first real run is therefore a
|
||||
migration, and it should be a `--force` run per voice so the old directories
|
||||
are retired rather than left half-updated.
|
||||
- The three source files (`/home/blank/audiosample.mp3`,
|
||||
`audiosample2.m4b`, `audiosample3.m4b`, digests pinned in
|
||||
`scripts/voice-packs.env`) live on the workstation, not on Dankclaw; the
|
||||
builder reads them from `$VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources`, so
|
||||
they have to be copied there once before a build.
|
||||
- `VOICE_PACK_TOOL` points at the trial's build tree
|
||||
(`/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec`, checked out
|
||||
at exactly `5916bf7c`), and the two GGUFs at
|
||||
`/home/dank/models/Qwen3-TTS-GGUF/`, which is where the engine slice's
|
||||
download script puts them — that directory does not exist yet, so until it
|
||||
does the models have to come from the trial's `models/` directory.
|
||||
- Nothing is deployed: nothing listens on 7772 or 8880 today, so building packs
|
||||
is possible on that host while serving them is not.
|
||||
|
||||
## Known gaps, on purpose
|
||||
|
||||
- **No transcript in the manifest.** ICL wants a `ref_text` that matches the
|
||||
crop, and the trial's machine transcripts came back wrong ("Demon Souls" for
|
||||
*Dark Souls*). Transcribing on the host is not a fix, so the pack carries none
|
||||
and the engine registers the WAV-only voice, which is what its boot warmer
|
||||
does anyway. When ICL becomes a per-voice opt-in, the curated transcript
|
||||
belongs in this manifest — a human-supplied field, not something the builder
|
||||
can derive.
|
||||
- **No 16 kHz variant.** The packs the trial left behind carry
|
||||
`reference16k.wav` for the `llama-tts-cli` rollback engine; arm A is native
|
||||
24 kHz, so the builder emits one reference. `--force` retires the whole
|
||||
directory including that variant, so if the rollback engine is ever revived:
|
||||
`ffmpeg -i reference.wav -ar 16000 -c:a pcm_s16le reference16k.wav`.
|
||||
- **No loudness pass.** The trial's pipeline also measured and normalised
|
||||
(two-pass `loudnorm`, `astats`, `ebur128`, spectrograms) to a `-16 LUFS` spec.
|
||||
None of that is in the pack law, which asks for 24 kHz mono inside the
|
||||
duration window, so the builder does only that; a future engine that is
|
||||
loudness-sensitive should get the pass as an explicit, documented step.
|
||||
@@ -80,7 +80,7 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
|
||||
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
|
||||
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
||||
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
|
||||
- [[Voice Packs]] — how a frank-tts voice is built: reference.wav + the pinned engine's speaker artifacts, cut past the 10-second source-material gate
|
||||
- [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; repo-owned config, not deployed
|
||||
|
||||
## Infrastructure
|
||||
|
||||
|
||||
@@ -2442,55 +2442,51 @@ every restart since the cutover.
|
||||
carries no encryption key and injects the whole harbor env — left untouched,
|
||||
read-only, as the record of what not to copy.
|
||||
|
||||
## 2026-10-18 — voice-pack builder: the 10-second source-material gate becomes code
|
||||
## 2026-10-18 — frank-tts-engine: the arm A engine becomes deployable (nothing deployed)
|
||||
|
||||
- Third slice of the voice stack (after the façade and the engine decision):
|
||||
`scripts/build-voice-pack.sh` turns a source recording plus a crop offset into
|
||||
a deployable pack — `reference.wav` (24 kHz mono `pcm_s16le`, 18–25 s), the
|
||||
engine's own `reference.spk` (8,192 B) + `reference.rvq` (5,456–6,864 B) made
|
||||
from it, and `manifest.json` provenance. Tracked tunables in
|
||||
`scripts/voice-packs.env`: pack list, per-voice source/offset/duration/pinned
|
||||
source sha256, extractor path and engine commit, artifact arithmetic. New runbook
|
||||
[[Voice Packs]].
|
||||
- **The gate is the product.** The project-wide invariant — no voice material
|
||||
from the first 10 s of a sample file — had only ever been a sentence in
|
||||
`scripts/frank-tts.env`. An offset inside it now exits 1 before a directory is
|
||||
created, and the same assertion runs again against the manifest that was just
|
||||
written, next to the recorded sha256, so a pack cannot carry a forbidden crop
|
||||
even if the env file is edited into nonsense. `10` stays a constant in the
|
||||
builder; the env file carries offsets, never the law.
|
||||
- **The extractor's contract was read, not guessed:** `qwen-codec` in qwentts.cpp
|
||||
`5916bf7c` takes `--model <codec gguf> --talker <base talker gguf> -i <wav>`
|
||||
with an optional `--device`, has **no `-o`** and names both artifacts next to
|
||||
the input with the extension swapped. So the WAV is staged under its final stem
|
||||
and `reference.wav` — the file the façade gates on — is moved into place
|
||||
**last**, after the artifacts and the manifest: a failed extractor leaves a
|
||||
directory that is not a voice. `--force` retires an existing pack whole to
|
||||
`.AGENTTRASH/` instead of touching bytes; nothing in this script deletes.
|
||||
- Sizes are arithmetic, so they are checked: 16 codebooks × 11 bits = 22 B per
|
||||
12.5 Hz frame (`22 × floor(samples/1920)`) and `enc_dim 2048 × f32` = 8,192 B.
|
||||
That makes the **effective crop window 19.84–25 s**, narrower than the WAV
|
||||
law's 18–25 s, and a too-short crop is refused before the extractor runs. An
|
||||
engine bump that changes either number fails the build by design; the bump
|
||||
procedure is in [[Voice Packs]].
|
||||
- Verified against reality, not against the model: the pinned `narrator1` crop
|
||||
(98.739–118.636 s of `audiosample.mp3`, sha256 `2299510…`) comes out of a
|
||||
`--dry-run` at 955,378 B / 19.897 s with 5,456 B of `.rvq` planned — byte-for-byte
|
||||
the trial's hand-cut numbers — and all three pinned source digests match the
|
||||
files on disk.
|
||||
- Seam: `tests/voice_pack_unit.bats` (13 tests) — refusal inside the forbidden
|
||||
region creates nothing, a legal crop builds the whole pack, `ffprobe` on the
|
||||
generated reference, manifest round-trip through `jq` with a sha that matches
|
||||
the source, re-run-without-`--force` leaves every byte alone, `--force` retires
|
||||
rather than deletes, `--dry-run` needs no extractor, a wrong-sized artifact
|
||||
from a bumped engine cannot publish a voice, and every crop in the tracked pack
|
||||
list clears the invariant. `qwen-codec` is stood in by a stub with the real
|
||||
tool's I/O shape, so no engine, no GPU, no network, no fleet host. Suite green:
|
||||
**416/416** (`./tests/run.sh`, wall 77 s) — 403 before this branch.
|
||||
- Deliberately NOT done: no transcript in the manifest (ICL wants a curated
|
||||
`ref_text`; the trial's machine transcripts were wrong, and the engine
|
||||
registers WAV-only voices anyway), no 16 kHz variant (arm A is native 24 kHz),
|
||||
no loudness pass (not in the pack law), and no pack built on the host — the
|
||||
three sources still live on the workstation and the engine GGUFs have not been
|
||||
fetched to `/home/dank/models/Qwen3-TTS-GGUF` yet, so the first real run is a
|
||||
host step.
|
||||
- Goal: turn the engine the day's A/B picked into a **repo-owned, boot-persistent,
|
||||
self-healing container** — `frank-tts-engine`, `qwentts.cpp` commit
|
||||
`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4`, resident `tts-server`, 1.7 B base
|
||||
talker Q4_K_M + Q8_0 tokenizer, container port 8880, and **not** a fleet
|
||||
endpoint: the endpoint stays `frank-tts` on 7772. Runbook: [[Frank TTS Engine]].
|
||||
- **No `PublishPort` at all, which is the shape the merged A/B states as law.**
|
||||
netavark DNATs published ports only, so a `127.0.0.1:8880` publish is not a path
|
||||
to a peer container — the façade could never have used it (the trap that 502'd
|
||||
hermes) — while being the one LAN-reachable surface of an unauthenticated
|
||||
synthesiser. Both voice containers therefore sit on one committed quadlet
|
||||
network, `frank-tts-engine.network` at **10.89.5.0/24** (the next free /24 after
|
||||
open-notebook's 10.89.4.0/24), and the façade addresses the engine by container
|
||||
name. Diagnostics moved to `podman exec`, including the deployer's health gate
|
||||
(`wait_container_healthy`, not `wait_for_health`: there is no URL to dial).
|
||||
- **The boot warmer is a unit hook, not an operator habit.** Cold-voice law: the
|
||||
first request for a registered voice decodes the whole reference to snapshot
|
||||
codec state (6.2–8.0 s, first byte 5 530 ms), later ones restore in 0.5 ms; the
|
||||
registry is memory-only and re-registering does not warm. So
|
||||
`ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` with
|
||||
`TimeoutStartSec=900`, and the `-` prefix is what stops a failed warm from
|
||||
crash-looping the endpoint under `Restart=always`. It logs `cold=` and `warm=`
|
||||
per voice, so the under-a-second second request is proven on every boot.
|
||||
- **Thread law recorded before anyone tunes it away:** no `--threads` exists;
|
||||
`src/backend.h` uses `hardware_concurrency()/2`, so a cpuset pin silently
|
||||
changes the thread count. Nothing pins this container.
|
||||
- Image builds from the pinned commit with the **proven** line, verbatim —
|
||||
`GGML_BLAS=OFF` is forced (this host has `libopenblas.so.0`, no dev headers),
|
||||
every GPU backend off, `-j 8`, and no `-DGGML_NATIVE` because ggml's default is
|
||||
`-march=native` for the building host: the image is built where it runs. Base
|
||||
`ubuntu:24.04` pinned by index digest; two stages, because ggml builds shared
|
||||
and the RPATH names the build tree.
|
||||
- Weights fetched by `scripts/download-frank-tts-engine-qwentts.sh` with byte and
|
||||
LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the deployer
|
||||
gates on `--verify-only` rather than pulling 1.4 GiB inside a deploy.
|
||||
- Registered in all five places the open-notebook entry just named as law, plus
|
||||
the routing case above the `dankclaw/*.container` glob. New seam
|
||||
`tests/frank_tts_engine.bats` (18 tests): the Quadlet **generator's own output**
|
||||
for the network/publish/boot/warm shape, the entrypoint's argv driven from the
|
||||
tracked env file, the warmer against a stub `tts-server` over a real socket
|
||||
(registration keys, `ref_text` absent, late engine, hung synthesis, name
|
||||
refusal), the fetcher's verifier, and both routing tables.
|
||||
- Deliberately NOT done: the deploy (no host touched, no container started, no
|
||||
image built), any homepage card or Prometheus job for the engine — it publishes
|
||||
nothing and exposes no metrics, so there is nothing to probe, which is said out
|
||||
loud in the page rather than papered over. No ICL/`ref_text` work: the packs
|
||||
keep `reference.spk`/`reference.rvq` so it stays a per-voice opt-in.
|
||||
Executable
+656
@@ -0,0 +1,656 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: frank-tts-engine — the arm A TTS engine (qwentts.cpp `tts-server`) that
|
||||
# sits behind frank-tts. What is pinned here is the shape the fleet depends on,
|
||||
# observed through the tools that act on it: the Quadlet generator's own output,
|
||||
# the image entrypoint driven with a stub binary, the boot warmer driven against
|
||||
# a stub engine over a real socket, and the fetcher's own verifier.
|
||||
#
|
||||
# Nothing in this file synthesizes audio, builds an image, starts a container or
|
||||
# touches a fleet host. The engine's real behaviour (TTFA, RTF, the cold-voice
|
||||
# cost) was measured on Dankclaw on 2026-10-18 and is documented in
|
||||
# serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md; a test that re-derived it
|
||||
# would need the weights, the CPU and the time the endpoint is meant to serve.
|
||||
#
|
||||
# What this seam refuses to assert, on purpose: any comparison between streamed
|
||||
# (`pcm`) and buffered (`wav`) audio. At the same seed those two paths differ in
|
||||
# 92.2 % of samples (-41.6 dB RMS), so a fixture that pinned one against the
|
||||
# other would be a bug waiting to be "fixed".
|
||||
|
||||
load helpers
|
||||
|
||||
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
|
||||
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
|
||||
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
|
||||
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
||||
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
||||
WARM="$REPO_ROOT/dankclaw/voiceservice/tts-engine-warm.sh"
|
||||
FETCH="$REPO_ROOT/scripts/download-frank-tts-engine-qwentts.sh"
|
||||
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
|
||||
# The decided artifacts, exactly as the 2026-10-18 Hub census recorded them.
|
||||
TALKER="qwen-talker-1.7b-base-Q4_K_M.gguf"
|
||||
TALKER_BYTES=1219245248
|
||||
TALKER_SHA="ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d"
|
||||
CODEC="qwen-tokenizer-12hz-Q8_0.gguf"
|
||||
CODEC_BYTES=291150624
|
||||
CODEC_SHA="1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94"
|
||||
|
||||
setup() {
|
||||
TEST_TMP="$(mktemp -d)"
|
||||
PIDS="$TEST_TMP/pids"
|
||||
: > "$PIDS"
|
||||
: > "$TEST_TMP/journal.jsonl"
|
||||
|
||||
# One real pack, and one directory that LOOKS like a pack but has no
|
||||
# reference.wav so must never be offered as a voice. A third case — a pack
|
||||
# whose name the two halves of the voice stack would resolve differently —
|
||||
# is created only by the test that asserts on it, because every other run
|
||||
# of the warmer has to be able to report success.
|
||||
VOICES="$TEST_TMP/voices"
|
||||
mkdir -p "$VOICES/narrator1" "$VOICES/justamanifest"
|
||||
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(128)) * 2)' \
|
||||
> "$VOICES/narrator1/reference.wav"
|
||||
printf 'a manifest is not a voice\n' > "$VOICES/justamanifest/manifest.json"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
while IFS= read -r pid; do
|
||||
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
|
||||
done < "$PIDS"
|
||||
wait 2>/dev/null || true
|
||||
rm -rf "$TEST_TMP"
|
||||
}
|
||||
|
||||
# write_engine_stub — a stand-in for tts-server's HTTP surface, route for
|
||||
# route: GET /health, GET /v1/models, POST /v1/audio/voices, POST
|
||||
# /v1/audio/speech, and a bare 404 for anything else (that last part is the
|
||||
# point of the stub: a warmer dialing a route the engine does not have must
|
||||
# fail here, not in production). Every request is appended to $STUB_JOURNAL in
|
||||
# the order it arrived, with the registration reduced to its key set, its name
|
||||
# and the sha256 of the decoded wav — so "exactly which bytes were registered"
|
||||
# is observable without shipping a 1 MB fixture around.
|
||||
write_engine_stub() {
|
||||
cat > "$TEST_TMP/stub_engine.py" <<'PY'
|
||||
import base64, hashlib, json, os, time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
JOURNAL = os.environ["STUB_JOURNAL"]
|
||||
HANG = float(os.environ.get("STUB_HANG_SPEECH") or 0)
|
||||
|
||||
|
||||
class Stub(BaseHTTPRequestHandler):
|
||||
def _record(self, entry):
|
||||
with open(JOURNAL, "a") as fh:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
|
||||
def _reply(self, status, body, ctype="application/json"):
|
||||
self.send_response(status)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self._record({"method": "GET", "path": self.path})
|
||||
if self.path == "/health":
|
||||
self._reply(200, b'{"status":"ok"}')
|
||||
elif self.path == "/v1/models":
|
||||
self._reply(200, b'{"object":"list","data":[{"id":"frank-tts-engine"}]}')
|
||||
else:
|
||||
self._reply(404, b'{"detail":"not found"}')
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length") or 0)
|
||||
raw = self.rfile.read(length) or b"{}"
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except ValueError:
|
||||
self._record({"method": "POST", "path": self.path, "invalid_json": True})
|
||||
self._reply(400, b'{"error":{"message":"request body is not valid JSON"}}')
|
||||
return
|
||||
if self.path == "/v1/audio/voices":
|
||||
wav = body.get("wav_b64") or ""
|
||||
try:
|
||||
digest = hashlib.sha256(base64.b64decode(wav)).hexdigest()
|
||||
except Exception:
|
||||
digest = "undecodable"
|
||||
self._record({"method": "POST", "path": self.path,
|
||||
"keys": sorted(body), "name": body.get("name"),
|
||||
"wav_sha256": digest, "wav_b64_len": len(wav),
|
||||
"has_ref_text": "ref_text" in body})
|
||||
self._reply(200, json.dumps({"name": body.get("name"),
|
||||
"status": "registered"}).encode())
|
||||
elif self.path == "/v1/audio/speech":
|
||||
self._record({"method": "POST", "path": self.path,
|
||||
"voice": body.get("voice"),
|
||||
"response_format": body.get("response_format"),
|
||||
"input": body.get("input")})
|
||||
# STUB_HANG_SPEECH stands in for an engine that takes the request
|
||||
# and never answers it: the branch the warmer's ceiling guards.
|
||||
if HANG:
|
||||
time.sleep(HANG)
|
||||
audio = b"RIFF" + b"\x00" * 64
|
||||
self._reply(200, audio, "audio/wav")
|
||||
else:
|
||||
self._record({"method": "POST", "path": self.path})
|
||||
self._reply(404, b'{"detail":"not found"}')
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
server = HTTPServer(("127.0.0.1", int(os.environ.get("STUB_BOUND_PORT") or 0)), Stub)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.serve_forever()
|
||||
PY
|
||||
}
|
||||
|
||||
start_engine_stub() {
|
||||
# start_engine_stub [port] — with a port the stub binds that exact one,
|
||||
# which is what lets a test have a listener appear on an address the warmer
|
||||
# is already dialing.
|
||||
write_engine_stub
|
||||
: > "$TEST_TMP/stub.log"
|
||||
STUB_JOURNAL="$TEST_TMP/journal.jsonl" STUB_BOUND_PORT="${1:-}" \
|
||||
STUB_HANG_SPEECH="${STUB_HANG_SPEECH:-}" \
|
||||
python3 "$TEST_TMP/stub_engine.py" > "$TEST_TMP/stub.log" 2>&1 &
|
||||
echo "$!" >> "$PIDS"
|
||||
local waited=0
|
||||
while [ "$waited" -lt 200 ]; do
|
||||
STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")"
|
||||
[ -n "$STUB_PORT" ] && return 0
|
||||
sleep 0.05
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
echo "the stub engine never bound a port:"
|
||||
cat "$TEST_TMP/stub.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
# quadlet_generator — locate the Quadlet generator the target host also has.
|
||||
# podman moved it over the releases the fleet runs, so try every known home and
|
||||
# skip the test if this box has none: the assertions below are only meaningful
|
||||
# against a real generator, not a guess at one.
|
||||
quadlet_generator() {
|
||||
local candidate
|
||||
for candidate in \
|
||||
/usr/lib/systemd/system-generators/podman-system-generator \
|
||||
/usr/libexec/podman/quadlet-generator \
|
||||
/usr/lib/podman/quadlet-generator \
|
||||
/usr/lib/podman/quadlet; do
|
||||
[ -x "$candidate" ] && { printf '%s' "$candidate"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# render_units — run the generator in a throwaway HOME over a copy of the two
|
||||
# source files and print everything it emits. --dryrun prints instead of
|
||||
# writing, so this neither contacts the podman daemon nor creates a network.
|
||||
render_units() {
|
||||
local gen home
|
||||
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
||||
home="$TEST_TMP/fakehome"
|
||||
mkdir -p "$home/.config/containers/systemd"
|
||||
cp "$NETF" "$UNIT" "$home/.config/containers/systemd/"
|
||||
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
|
||||
cat "$TEST_TMP/generated.txt"
|
||||
}
|
||||
|
||||
# free_port — an address with nothing bound to it, for the tests that need the
|
||||
# warmer to be dialing before a listener exists.
|
||||
free_port() {
|
||||
python3 -c 'import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
s.close()'
|
||||
}
|
||||
|
||||
@test "the voice bridge is the next free pinned subnet and no two committed networks share one" {
|
||||
# Two networks claiming one subnet is a boot-time collision that shows up as
|
||||
# the later container failing to start, on the host, weeks from now. The
|
||||
# registry lives in the network files' comments, so the subnets themselves
|
||||
# are the only honest thing to compare.
|
||||
mine="$(sed -nE 's/^Subnet=([0-9./]+)$/\1/p' "$NETF")"
|
||||
[ "$mine" = "10.89.5.0/24" ] || { echo "expected 10.89.5.0/24, the next free /24, got: $mine"; false; }
|
||||
run grep -hoE '^Subnet=[0-9./]+' "$REPO_ROOT"/dankclaw/*/quadlet/*.network "$REPO_ROOT"/dankserver/*/quadlet/*.network
|
||||
dupes="$(printf '%s\n' "$output" | sort | uniq -d)"
|
||||
[ -z "$dupes" ] || { echo "subnets claimed by more than one network file: $dupes"; false; }
|
||||
printf '%s\n' "$output" | grep -qxF "Subnet=$mine"
|
||||
}
|
||||
|
||||
@test "the Quadlet generator renders the engine on its private bridge, publishing nothing" {
|
||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts-engine ')"
|
||||
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
|
||||
|
||||
# The data path: container DNS on the network the engine owns. One network
|
||||
# and nothing else attached — the façade joins this one rather than the
|
||||
# engine moving onto the fleet-wide bridge.
|
||||
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
|
||||
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | wc -l)" = "1" ]
|
||||
|
||||
# NO publish of any kind, not even 127.0.0.1:8880. netavark DNATs published
|
||||
# ports only, so a host-loopback publish is not a path to a peer container —
|
||||
# the façade could never have used it (the trap that 502'd hermes), and it
|
||||
# would be the one LAN-reachable surface of an unauthenticated synthesiser.
|
||||
! printf '%s' "$line" | grep -qE -- '--publish'
|
||||
|
||||
# Repo-owned config: the tracked env file, the read-only weights mount, the
|
||||
# read-only voice packs the in-container warmer reads, and a health probe
|
||||
# that survives the generator's CMD-SHELL wrapping because the image ships
|
||||
# curl.
|
||||
printf '%s' "$line" | grep -qF -- '--env-file /home/dank/serverAppConfigs/scripts/frank-tts-engine.env'
|
||||
printf '%s' "$line" | grep -qF -- '-v /home/dank/models/Qwen3-TTS-GGUF:/models:ro'
|
||||
printf '%s' "$line" | grep -qF -- '-v /home/dank/voices:/voices:ro'
|
||||
printf '%s' "$line" | grep -qF -- '--health-cmd'
|
||||
printf '%s' "$line" | grep -qF 'curl'
|
||||
|
||||
|
||||
# Boot persistence and self-healing, as the generator actually emits them —
|
||||
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
||||
# Restart=always plus a failing warmer is a crash loop on the endpoint.
|
||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
||||
printf '%s' "$unit" | grep -qE '^Restart=always$'
|
||||
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
||||
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
|
||||
printf '%s' "$unit" | grep -qE '^WantedBy=default\.target$'
|
||||
# The warm hook is an exec, because there is no host-visible address to run
|
||||
# a host script against (see the publish assertions above).
|
||||
printf '%s' "$unit" | grep -qE '^ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices\.sh$'
|
||||
# Warming a library is serialised: it must not fit inside the default 90 s.
|
||||
start_deadline="$(printf '%s' "$unit" | sed -nE 's/^TimeoutStartSec=([0-9]+)$/\1/p')"
|
||||
[ -n "$start_deadline" ] && [ "$start_deadline" -gt 90 ]
|
||||
}
|
||||
|
||||
@test "the generator renders the bridge creation command with the pinned subnet" {
|
||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||
net="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine-network.service---$/,$p')"
|
||||
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
|
||||
# The container unit must be tied to the network unit, not merely mention it:
|
||||
# a referenced .network that the generator cannot find kills the container
|
||||
# unit outright, and an untied one starts the container before the bridge.
|
||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
||||
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
||||
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
|
||||
}
|
||||
|
||||
@test "the entrypoint turns the tracked env file into the engine's own flags" {
|
||||
# Not a grep of the env file: source it the way podman's --env-file feeds
|
||||
# it to the container, then require the stub binary to be handed the flags
|
||||
# those values mean. A key the entrypoint forgot to translate shows up here
|
||||
# as a missing argument, which is what "the knob does nothing" looks like.
|
||||
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$@" > "$ARGS_FILE"
|
||||
FAKE
|
||||
chmod +x "$TEST_TMP/fake-tts-server"
|
||||
mkdir -p "$TEST_TMP/models"
|
||||
: > "$TEST_TMP/models/$TALKER"
|
||||
: > "$TEST_TMP/models/$CODEC"
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENVF"
|
||||
set +a
|
||||
# The env file names CONTAINER paths; the stub stands in for the mount.
|
||||
export QWENTTS_MODEL="$TEST_TMP/models/$TALKER"
|
||||
export QWENTTS_CODEC="$TEST_TMP/models/$CODEC"
|
||||
export QWENTTS_BIN="$TEST_TMP/fake-tts-server"
|
||||
export ARGS_FILE="$TEST_TMP/argv.txt"
|
||||
|
||||
run bash "$START"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
argv() { awk -v flag="$1" 'p { print; exit } $0 == flag { p = 1 }' "$ARGS_FILE"; }
|
||||
[ "$(argv --model)" = "$TEST_TMP/models/$TALKER" ]
|
||||
[ "$(argv --codec)" = "$TEST_TMP/models/$CODEC" ]
|
||||
[ "$(argv --alias)" = "frank-tts-engine" ]
|
||||
# 0.0.0.0 is the one value that makes the container reachable at all from
|
||||
# the bridge; tts-server's own default (127.0.0.1) is unreachable in a netns.
|
||||
[ "$(argv --host)" = "0.0.0.0" ]
|
||||
[ "$(argv --port)" = "8880" ]
|
||||
[ "$(argv --lang)" = "auto" ]
|
||||
[ "$(argv --max-batch)" = "1" ]
|
||||
# The two flags that must never appear: there is no thread count to set (it
|
||||
# comes from hardware_concurrency()/2), and naming a device on an image with
|
||||
# every GPU backend compiled out is only a new way to be wrong.
|
||||
! grep -qx -- '--device' "$ARGS_FILE"
|
||||
! grep -qE -- '^--?t$|^--threads$' "$ARGS_FILE"
|
||||
}
|
||||
|
||||
@test "missing weights are a refusal that names the fetcher, before the port is bound" {
|
||||
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
echo "the engine binary must not run when its weights are absent" > "$RAN_FILE"
|
||||
FAKE
|
||||
chmod +x "$TEST_TMP/fake-tts-server"
|
||||
export RAN_FILE="$TEST_TMP/ran.txt"
|
||||
export QWENTTS_MODEL="$TEST_TMP/nope/$TALKER"
|
||||
export QWENTTS_CODEC="$TEST_TMP/nope/$CODEC"
|
||||
export QWENTTS_BIN="$TEST_TMP/fake-tts-server"
|
||||
run bash "$START"
|
||||
[ "$status" -eq 1 ] || { echo "$output"; false; }
|
||||
[ ! -e "$RAN_FILE" ] || { echo "the engine was started without its weights"; false; }
|
||||
[[ "$output" == *"$TEST_TMP/nope/$TALKER"* ]] || { echo "$output"; false; }
|
||||
[[ "$output" == *"download-frank-tts-engine-qwentts.sh"* ]]
|
||||
}
|
||||
|
||||
@test "the boot warmer registers each pack from its reference.wav, embedding-only" {
|
||||
start_engine_stub
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
|
||||
|
||||
# The registration is the whole product: name + wav_b64, and NOTHING else.
|
||||
# ref_text present would flip the voice into in-context-learning mode (the
|
||||
# slower voice) — so its absence is the shipped-default assertion.
|
||||
python3 - "$TEST_TMP/journal.jsonl" "$VOICES/narrator1/reference.wav" <<'PY'
|
||||
import hashlib, json, sys
|
||||
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||
regs = [e for e in entries if e["path"] == "/v1/audio/voices"]
|
||||
assert len(regs) == 1, entries
|
||||
reg = regs[0]
|
||||
assert reg["name"] == "narrator1", reg
|
||||
assert reg["keys"] == ["name", "wav_b64"], reg
|
||||
assert reg["has_ref_text"] is False, reg
|
||||
want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
|
||||
assert reg["wav_sha256"] == want, (reg, want)
|
||||
PY
|
||||
|
||||
# Two speech calls for the one voice: the cold pass that creates the codec
|
||||
# snapshot, then the warm pass whose number is the fleet's real experience.
|
||||
# Both ask for `wav`, which is what frank-tts asks for too.
|
||||
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
|
||||
import json, sys
|
||||
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||
paths = [e["path"] for e in entries]
|
||||
assert paths[0] == "/health", paths
|
||||
assert paths[1] == "/v1/audio/voices", paths
|
||||
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
|
||||
assert len(speech) == 2, entries
|
||||
assert {s["voice"] for s in speech} == {"narrator1"}, speech
|
||||
assert {s["response_format"] for s in speech} == {"wav"}, 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
|
||||
# directory name exactly, so a mixed-case pack is a voice that works on one
|
||||
# side of the pair and not the other. Silence here is how a wrong-voice pack
|
||||
# survives for weeks.
|
||||
mkdir -p "$VOICES/Narrator2"
|
||||
printf 'RIFFwrongcase' > "$VOICES/Narrator2/reference.wav"
|
||||
start_engine_stub
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 1 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"Narrator2"* ]]
|
||||
[[ "$output" == *"lowercase"* ]]
|
||||
run cat "$TEST_TMP/journal.jsonl"
|
||||
[[ "$output" != *"justamanifest"* ]]
|
||||
[[ "$output" != *"Narrator2"* ]]
|
||||
# The refusal is a journal line plus a non-zero exit — not a crash: the
|
||||
# narrator1 pack still got registered and warmed above the failure.
|
||||
grep -q 'narrator1' "$TEST_TMP/journal.jsonl"
|
||||
}
|
||||
|
||||
@test "an engine that never answers /health is reported, and nothing is registered" {
|
||||
# HEALTH_WAIT is a deadline, not a retry-forever: an engine that dies on
|
||||
# boot must produce one clear journal line and a non-zero exit (which the
|
||||
# unit's `-` prefix turns into "nothing happened", not a crash loop).
|
||||
port="$(free_port)"
|
||||
run env ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=1 \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 1 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"/health"* ]] || { echo "$output"; false; }
|
||||
[[ "$output" == *"did not answer"* ]]
|
||||
[ ! -s "$TEST_TMP/journal.jsonl" ]
|
||||
}
|
||||
|
||||
@test "the warmer waits for a late engine instead of racing model load" {
|
||||
# Boot order: ExecStartPost fires the moment the container starts, while the
|
||||
# two GGUFs (1.2 GiB + 291 MB) are still being read and the HTTP thread has
|
||||
# not bound yet. The warmer is launched against an address with nothing on
|
||||
# it, the listener appears a beat later, and the requirement is that the
|
||||
# warmer notices and does the work — an early exit here is the day's first
|
||||
# caller paying 5.5 s of silence for a voice nobody warmed.
|
||||
port="$(free_port)"
|
||||
( ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=60 \
|
||||
bash "$WARM" > "$TEST_TMP/race.log" 2>&1; echo "$?" > "$TEST_TMP/race.rc" ) &
|
||||
echo "$!" >> "$PIDS"
|
||||
sleep 0.7
|
||||
start_engine_stub "$port"
|
||||
local waited=0
|
||||
while [ ! -f "$TEST_TMP/race.rc" ] && [ "$waited" -lt 200 ]; do
|
||||
sleep 0.05
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
[ -f "$TEST_TMP/race.rc" ] || { echo "warmer never finished:"; cat "$TEST_TMP/race.log"; false; }
|
||||
[ "$(cat "$TEST_TMP/race.rc")" = "0" ] || { cat "$TEST_TMP/race.log"; false; }
|
||||
grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl"
|
||||
grep -q '/v1/audio/speech' "$TEST_TMP/journal.jsonl"
|
||||
}
|
||||
|
||||
@test "a synthesis that never returns is abandoned at REQUEST_TIMEOUT, not waited out" {
|
||||
# systemd counts the WHOLE warm pass against TimeoutStartSec, so the price
|
||||
# of a hung engine is paid by the unit: the endpoint stays "activating" and
|
||||
# never reaches started. The warmer therefore puts a ceiling on every
|
||||
# request. Here the engine registers the voice and then never answers the
|
||||
# synthesis — the failure mode a wedged GEMM loop actually produces.
|
||||
STUB_HANG_SPEECH=30
|
||||
export STUB_HANG_SPEECH
|
||||
start_engine_stub
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||
REQUEST_TIMEOUT=1 bash "$WARM"
|
||||
[ "$status" -eq 1 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"warm synthesis failed"* ]] || { echo "$output"; false; }
|
||||
# It got as far as registering, so the stall — not a missing route — is
|
||||
# what ended the pass.
|
||||
grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl"
|
||||
}
|
||||
|
||||
@test "an empty voice root is an explanation, not a failure" {
|
||||
# No packs yet is the state right after this unit is deployed and before the
|
||||
# pack builder runs. Failing there would mark the engine deploy incomplete
|
||||
# and, worse, read as a broken warmer to whoever is debugging a real one.
|
||||
start_engine_stub
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/nodir" \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"no voice directory"* ]]
|
||||
# No registration, no synthesis. Health polls are expected — the stub
|
||||
# journals them, and this is about what the warmer must NOT send.
|
||||
run grep -c '/v1/audio' "$TEST_TMP/journal.jsonl"
|
||||
[ "$output" = "0" ]
|
||||
}
|
||||
|
||||
@test "the warmer's knobs come from the same env file the engine reads" {
|
||||
# The warm hook is exec-ed into the container, so systemd hands it the unit's
|
||||
# EnvironmentFile — one file for both. Sourcing it here is how that is
|
||||
# reproduced without a container: if a key were renamed on one side only, the
|
||||
# hook would silently fall back to its built-in default and the knob would do
|
||||
# nothing, which no grep of either file alone can catch.
|
||||
start_engine_stub
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENVF"
|
||||
set +a
|
||||
[ -n "${WARM_TEXT:-}" ] || { echo "scripts/frank-tts-engine.env defines no WARM_TEXT"; false; }
|
||||
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||
bash "$WARM"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
python3 - "$TEST_TMP/journal.jsonl" "$WARM_TEXT" <<'PY'
|
||||
import json, sys
|
||||
texts = {json.loads(l).get("input") for l in open(sys.argv[1])
|
||||
if json.loads(l)["path"] == "/v1/audio/speech"}
|
||||
assert texts == {sys.argv[2]}, (texts, sys.argv[2])
|
||||
PY
|
||||
# The engine-side keys are in the same file and must not collide with the
|
||||
# warmer's: QWENTTS_PORT is what the server binds, ENGINE_URL is where the
|
||||
# hook dials, and they are different values by design (container bind address
|
||||
# versus in-namespace loopback).
|
||||
grep -qE '^QWENTTS_PORT=8880$' "$ENVF"
|
||||
grep -qE '^VOICES_DIR=/voices$' "$ENVF"
|
||||
}
|
||||
|
||||
@test "the fetcher pins the decided weights and puts no credential on the wire" {
|
||||
# The byte counts and the Hub pointer oids ARE the contract for the payload,
|
||||
# the same way an image digest is: they came off the Hub on 2026-10-18 and a
|
||||
# silent change means someone moved a file upstream.
|
||||
run grep -h '^ "Serveurperso/Qwen3-TTS-GGUF|' "$FETCH"
|
||||
[ "$(printf '%s\n' "$output" | grep -c .)" = "2" ]
|
||||
printf '%s\n' "$output" | grep -qF "|$TALKER|$TALKER|$TALKER_BYTES|$TALKER_SHA"
|
||||
printf '%s\n' "$output" | grep -qF "|$CODEC|$CODEC|$CODEC_BYTES|$CODEC_SHA"
|
||||
# sha256 oids, not the 40-char git blob ids the same API also returns.
|
||||
run grep -oE '\|[0-9a-f]{64}"$' "$FETCH"
|
||||
[ "$(printf '%s\n' "$output" | grep -c .)" = "2" ]
|
||||
# Public repo: no auth header, and no token in the invocation.
|
||||
! grep -qE 'HF_TOKEN|Authorization|--header' "$FETCH"
|
||||
grep -qE 'curl -fsSL --fail-with-body' "$FETCH"
|
||||
}
|
||||
|
||||
@test "the fetcher reports absent and wrong-sized weights as MISSING, non-zero" {
|
||||
run bash "$FETCH" "$TEST_TMP/payload" --verify-only
|
||||
[ "$status" -eq 1 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"MISSING $TALKER"* ]]
|
||||
[[ "$output" == *"MISSING $CODEC"* ]]
|
||||
[[ "$output" == *"DOWNLOAD_FAILED"* ]]
|
||||
# A partial download of the right NAME is still missing: the byte count is
|
||||
# what gates, and a truncated GGUF would mmap fine and then produce noise.
|
||||
mkdir -p "$TEST_TMP/payload"
|
||||
head -c 4096 /dev/zero > "$TEST_TMP/payload/$TALKER"
|
||||
: > "$TEST_TMP/payload/$CODEC"
|
||||
run bash "$FETCH" "$TEST_TMP/payload" --verify-only
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"MISSING $TALKER"* ]]
|
||||
[[ "$output" == *"MISSING $CODEC"* ]]
|
||||
}
|
||||
|
||||
@test "the image build is the pinned commit with the proven cmake line" {
|
||||
# GGML_BLAS=OFF is forced by the host (libopenblas.so.0 present, no dev
|
||||
# headers) and upstream's own Dockerfile passes it ON, so this file is not a
|
||||
# copy of upstream on purpose. -j 8 and the OFF-for-every-GPU-backend list
|
||||
# are what the 2026-10-18 trial ran.
|
||||
grep -qE '^ARG QWENTTS_COMMIT=[0-9a-f]{40}$' "$CF"
|
||||
grep -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$CF"
|
||||
grep -qE 'git -C /src checkout --detach' "$CF"
|
||||
grep -qE 'submodule update --init' "$CF" # ggml is a submodule; without it the build cannot configure
|
||||
for flag in -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF \
|
||||
-DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF \
|
||||
-DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF; do
|
||||
grep -qF -- "$flag" "$CF" || { echo "build line lost $flag"; false; }
|
||||
done
|
||||
grep -qE 'cmake --build build -j 8' "$CF"
|
||||
# OpenMP on means the runtime stage needs the OpenMP runtime, and the probe
|
||||
# runs as /bin/sh -c, so the runtime stage needs curl.
|
||||
grep -qE 'libgomp1' "$CF"
|
||||
grep -qE '^\s+ca-certificates curl libgomp1' "$CF"
|
||||
# Both scripts ship in the image: the server's entrypoint, and the warmer the
|
||||
# unit execs. A warmer that is not in the image makes the unit's
|
||||
# ExecStartPost fail on every boot, silently (`-` prefix by design).
|
||||
grep -qF 'COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh' "$CF"
|
||||
grep -qF 'COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh' "$CF"
|
||||
grep -qE 'chmod 755 /app/start.sh /app/warm-voices.sh' "$CF"
|
||||
! grep -qE '^HEALTHCHECK' "$CF" # the probe lives in the Quadlet unit
|
||||
grep -qE '^USER tts$' "$CF"
|
||||
# Base pinned by digest, both stages from one image.
|
||||
grep -qE '^ARG BASE_IMAGE=docker\.io/library/ubuntu:24\.04@sha256:[0-9a-f]{64}$' "$CF"
|
||||
[ "$(grep -cE '^FROM \$\{BASE_IMAGE\}' "$CF")" = "2" ]
|
||||
}
|
||||
|
||||
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network \
|
||||
dankclaw/voiceservice/Containerfile.engine \
|
||||
dankclaw/voiceservice/tts-engine-start.sh \
|
||||
scripts/frank-tts-engine.env \
|
||||
dankclaw/voiceservice/tts-engine-warm.sh
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-tts-engine" ]
|
||||
|
||||
# Host-side payload and repo tooling restart nothing: the weights are host
|
||||
# state, exactly like every other download script.
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
scripts/download-frank-tts-engine-qwentts.sh
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
|
||||
# The engine is a Dankclaw container. On Dankserver these paths must plan
|
||||
# nothing rather than queue a service that does not exist there.
|
||||
run python3 "$PLANNER" --target dankserver --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
||||
scripts/frank-tts-engine.env
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "changed-path routing selects this seam" {
|
||||
# The regression this guards is real and was hit next door: the generic
|
||||
# dankclaw/*.container case matches across `/`, so a quadlet path placed
|
||||
# below it never reaches its own seam.
|
||||
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
|
||||
|
||||
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
|
||||
|
||||
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/frank-tts-engine.env
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
|
||||
printf '%s\n' "$output" | grep -qx "tests/scripts_syntax.bats"
|
||||
}
|
||||
|
||||
@test "both deployer entry points know the engine and its handler gates on health, then warm" {
|
||||
grep -qF 'dankclaw:frank-tts-engine|' "$REMOTE"
|
||||
grep -qF 'dankclaw:frank-tts-engine|' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -qF 'frank-tts-engine) deploy_frank_tts_engine ;;' "$REMOTE"
|
||||
grep -qF 'containerfile=dankclaw/voiceservice/Containerfile.engine' "$REMOTE"
|
||||
|
||||
handler="$(sed -n '/^deploy_frank_tts_engine()/,/^}/p' "$REMOTE")"
|
||||
[ -n "$handler" ] || { echo "no deploy_frank_tts_engine handler"; false; }
|
||||
# Weights before build, build before start, health before warm: in that
|
||||
# order, because each step's failure message is only true in that order.
|
||||
local weights build quadlet start health warm pos_prev=-1 pos
|
||||
weights="$(printf '%s' "$handler" | grep -n 'download-frank-tts-engine-qwentts.sh --verify-only' | head -1 | cut -d: -f1)"
|
||||
build="$(printf '%s' "$handler" | grep -n 'build_compose_image frank-tts-engine' | head -1 | cut -d: -f1)"
|
||||
quadlet="$(printf '%s' "$handler" | grep -n 'install_quadlet frank-tts-engine' | head -1 | cut -d: -f1)"
|
||||
start="$(printf '%s' "$handler" | grep -n 'systemctl --user restart frank-tts-engine.service' | head -1 | cut -d: -f1)"
|
||||
health="$(printf '%s' "$handler" | grep -n 'wait_container_healthy frank-tts-engine' | head -1 | cut -d: -f1)"
|
||||
warm="$(printf '%s' "$handler" | grep -n 'podman exec frank-tts-engine /app/warm-voices.sh' | head -1 | cut -d: -f1)"
|
||||
for pos in "$weights" "$build" "$quadlet" "$start" "$health" "$warm"; do
|
||||
[ -n "$pos" ] || { echo "handler is missing a step (weights/build/quadlet/start/health/warm)"; false; }
|
||||
[ "$pos" -gt "$pos_prev" ] || { echo "handler steps are out of order"; false; }
|
||||
pos_prev="$pos"
|
||||
done
|
||||
# The gate is podman's recorded health, not a URL: this container publishes
|
||||
# nothing, so the host has no address to dial.
|
||||
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts-engine [0-9]+'
|
||||
! printf '%s' "$handler" | grep -qE 'wait_for_health frank-tts-engine'
|
||||
# Generated Quadlet units cannot be enabled on this host: the handler must
|
||||
# not try, and the unit carries [Install] instead.
|
||||
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
|
||||
}
|
||||
|
||||
@test "the two scripts that ship in the image pass the repo's shell gates" {
|
||||
# scripts_syntax.bats walks scripts/ only, and these two no longer live
|
||||
# there: the entrypoint is the container's only way to read the env file,
|
||||
# and the warmer is exec-ed by the unit. A syntax error in either is a
|
||||
# container that never starts, or a deploy that reports an engine nobody
|
||||
# told it to talk to, so they get the same gates as scripts/*.sh.
|
||||
local s
|
||||
for s in "$START" "$WARM"; do
|
||||
bash -n "$s" || { echo "syntax error in $s"; false; }
|
||||
[ -x "$s" ] || { echo "$s is not executable — git must record the mode"; false; }
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
shellcheck -S error "$s" || { echo "shellcheck errors in $s"; false; }
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: the voice-pack builder (scripts/build-voice-pack.sh).
|
||||
#
|
||||
# The contract this pins is the one frank-tts's packs live or die by. A crop is
|
||||
# only usable voice material if it starts PAST the first 10 seconds of the
|
||||
# source file — the project-wide source-material invariant — and this builder is
|
||||
# the thing that enforces it, because nothing downstream looks at offsets again.
|
||||
# A pack is three files plus provenance: reference.wav at 24 kHz mono inside the
|
||||
# 18-25 s window, the engine's own speaker artifacts beside it, and a
|
||||
# manifest.json whose sha256 still matches the bytes the crop came from. Builds
|
||||
# refuse to clobber what is already on disk, and --force retires rather than
|
||||
# deletes.
|
||||
#
|
||||
# No engine binary, no model, no GPU, no network and no fleet host: the crop is
|
||||
# cut from a locally generated tone and the pinned extractor is stood in by a
|
||||
# stub that mirrors the real tool's I/O shape (no -o flag: outputs named next to
|
||||
# the input with the extension swapped, at the sizes the codec's config
|
||||
# implies), which is how frank_tts_unit.bats drives its backends too.
|
||||
|
||||
load helpers
|
||||
|
||||
setup() {
|
||||
BUILDER="$REPO_ROOT/scripts/build-voice-pack.sh"
|
||||
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
|
||||
TEST_TMP="$(mktemp -d)"
|
||||
PACKS="$TEST_TMP/packs"
|
||||
mkdir -p "$PACKS"
|
||||
STUB_ARGV="$TEST_TMP/stub-argv.tsv"
|
||||
export STUB_ARGV
|
||||
|
||||
# Forty seconds of "recording": stereo 44.1 kHz, so a legal build has to
|
||||
# downmix and resample just as it does on a real audiobook. Every crop this
|
||||
# file takes starts at or past 12 s, well out of the forbidden region.
|
||||
ffmpeg -v error -y -f lavfi -i "sine=frequency=330:sample_rate=44100:duration=40" \
|
||||
-ac 2 -c:a pcm_s16le "$TEST_TMP/source.wav"
|
||||
SOURCE_SHA="$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')"
|
||||
|
||||
write_stub_tool
|
||||
# The shipped tunables file, then the three machine-local overrides that
|
||||
# point the engine at the stub: the real env stays under test.
|
||||
cat "$ENV_FILE" > "$TEST_TMP/env"
|
||||
{
|
||||
printf 'VOICE_PACK_DIR=%s\n' "$PACKS"
|
||||
printf 'VOICE_PACK_TOOL=%s\n' "$TEST_TMP/qwen-codec"
|
||||
printf 'VOICE_PACK_CODEC_MODEL=%s\n' "$TEST_TMP/codec.gguf"
|
||||
printf 'VOICE_PACK_TALKER_MODEL=%s\n' "$TEST_TMP/talker.gguf"
|
||||
} >> "$TEST_TMP/env"
|
||||
printf 'stub-codec-weights\n' > "$TEST_TMP/codec.gguf"
|
||||
printf 'stub-talker-weights\n' > "$TEST_TMP/talker.gguf"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_TMP"
|
||||
}
|
||||
|
||||
# write_stub_tool — stand-in for qwen-codec. Records what it was handed and
|
||||
# writes <input-stem>.spk / <input-stem>.rvq, exactly where the real tool would.
|
||||
# STUB_SPK_BYTES lets one test be an engine that changed its x-vector size.
|
||||
write_stub_tool() {
|
||||
cat > "$TEST_TMP/qwen-codec" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
input="" model="" talker=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--model) model="$2"; shift 2 ;;
|
||||
--talker) talker="$2"; shift 2 ;;
|
||||
--device) shift 2 ;;
|
||||
-i) input="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf 'model=%s\ttalker=%s\tinput=%s\n' "$model" "$talker" "$input" >> "$STUB_ARGV"
|
||||
[ -n "$input" ] || exit 2
|
||||
[ -s "$model" ] || exit 3
|
||||
[ -s "$talker" ] || exit 4
|
||||
dur="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$input")"
|
||||
frames="$(awk -v d="$dur" 'BEGIN { printf "%d\n", int(int(d * 24000 + 0.5) / 1920) }')"
|
||||
head -c "${STUB_SPK_BYTES:-8192}" /dev/zero > "${input%.wav}.spk"
|
||||
head -c "$((22 * frames))" /dev/zero > "${input%.wav}.rvq"
|
||||
printf '[Stub] Wrote %s.rvq: %d packed bytes\n' "${input%.wav}" "$((22 * frames))" >&2
|
||||
STUB
|
||||
chmod +x "$TEST_TMP/qwen-codec"
|
||||
}
|
||||
|
||||
# build — a real (non-dry) run against the stub tool with the tone as source.
|
||||
build() {
|
||||
local voice="$1"
|
||||
shift
|
||||
run "$BUILDER" --env "$TEST_TMP/env" \
|
||||
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" "$@" "$voice" "$PACKS"
|
||||
}
|
||||
|
||||
pack_files() {
|
||||
ls "$PACKS/$1" 2>/dev/null | sort | tr '\n' ' '
|
||||
}
|
||||
|
||||
@test "a crop offset inside the first ten seconds is refused and names the invariant" {
|
||||
build narrator1 --offset 4.5 --duration 20
|
||||
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"source-material invariant"* ]]
|
||||
[[ "$output" == *"first 10 s"* ]]
|
||||
# Refused before anything was created, not cleaned up afterwards.
|
||||
[ ! -e "$PACKS/narrator1" ]
|
||||
[ ! -s "$STUB_ARGV" ]
|
||||
}
|
||||
|
||||
@test "the identical crop at a legal offset builds the whole pack" {
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"CROP 12 s .. 32 s"* ]]
|
||||
[[ "$output" == *"VOICE_PACK_BUILT narrator1 $PACKS/narrator1"* ]]
|
||||
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||
[ -f "$PACKS/narrator1/reference.spk" ]
|
||||
[ -f "$PACKS/narrator1/reference.rvq" ]
|
||||
[ -f "$PACKS/narrator1/manifest.json" ]
|
||||
}
|
||||
|
||||
@test "reference.wav comes out 24 kHz mono pcm inside the 18-25 s window" {
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
local wav="$PACKS/narrator1/reference.wav"
|
||||
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$wav")" = "pcm_s16le" ]
|
||||
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$wav")" = "24000" ]
|
||||
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$wav")" = "1" ]
|
||||
local dur
|
||||
dur="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$wav")"
|
||||
awk -v d="$dur" 'BEGIN { exit !(d >= 18 && d <= 25) }'
|
||||
}
|
||||
|
||||
@test "the manifest round-trips through jq with offsets and a sha that matches the source" {
|
||||
build narrator2 --offset 15 --duration 22.5
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
local manifest="$PACKS/narrator2/manifest.json"
|
||||
jq -e '
|
||||
.voice == "narrator2"
|
||||
and (.crop.offset_seconds == 15)
|
||||
and (.crop.end_seconds == 37.5)
|
||||
and (.reference.sample_rate == 24000)
|
||||
and (.reference.channels == 1)
|
||||
and (.reference.duration_seconds >= 18 and .reference.duration_seconds <= 25)
|
||||
and (.artifacts.engine_commit | test("^[0-9a-f]{40}$"))
|
||||
and (.artifacts.produced == true)
|
||||
and (.invariant.forbidden_source_seconds == 10)
|
||||
' "$manifest"
|
||||
# The recorded hash is the source's hash, not a hash of something else.
|
||||
[ "$(jq -r .source.sha256 "$manifest")" = "$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')" ]
|
||||
# The pack's provenance names the tool that made the artifacts and the files
|
||||
# it wrote beside the WAV.
|
||||
[ "$(jq -r .artifacts.tool "$manifest")" = "$TEST_TMP/qwen-codec" ]
|
||||
grep -q "input=$PACKS/narrator2/staging-reference.wav" "$STUB_ARGV"
|
||||
}
|
||||
|
||||
@test "a wrong pinned sha stops the build before a crop is cut" {
|
||||
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||
--sha256 "$(printf '0%.0s' {1..64})" --offset 12 --duration 20 narrator1 "$PACKS"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"sha256"* ]]
|
||||
[ ! -e "$PACKS/narrator1" ]
|
||||
}
|
||||
|
||||
@test "a crop that runs off the end of the source is refused" {
|
||||
build narrator1 --offset 30 --duration 20
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"crop would come out short"* ]]
|
||||
[ ! -e "$PACKS/narrator1" ]
|
||||
}
|
||||
|
||||
@test "a crop under the engine's artifact window is refused before the extractor runs" {
|
||||
# 18.5 s is inside the reference window but packs to 22 * 231 = 5,082 B of
|
||||
# .rvq, under the 5,456 B the pinned codec config produces — the builder
|
||||
# says so rather than running an extractor that would emit it anyway.
|
||||
build narrator1 --offset 12 --duration 18.5
|
||||
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"5456"* ]]
|
||||
[ ! -e "$PACKS/narrator1" ]
|
||||
[ ! -s "$STUB_ARGV" ]
|
||||
}
|
||||
|
||||
@test "an extractor whose artifact size changed does not get a published voice" {
|
||||
# The engine-bump case: a 4,096 B x-vector is not the 8,192 B the pinned
|
||||
# encoder emits, so the pack must not reach the state the shim reads.
|
||||
export STUB_SPK_BYTES=4096
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"reference.spk"* ]]
|
||||
[ ! -e "$PACKS/narrator1/reference.wav" ]
|
||||
[ ! -e "$PACKS/narrator1/manifest.json" ]
|
||||
}
|
||||
|
||||
@test "re-running without --force refuses and leaves every byte alone" {
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
sha256sum "$PACKS"/narrator1/* > "$TEST_TMP/before.sha"
|
||||
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"--force"* ]]
|
||||
sha256sum -c --quiet "$TEST_TMP/before.sha"
|
||||
[ "$(find "$PACKS" -name .AGENTTRASH -prune -o -type f -newer "$TEST_TMP/before.sha" -print | wc -l)" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "--force retires the previous pack instead of deleting anything" {
|
||||
build narrator1 --offset 12 --duration 20
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
cp "$PACKS/narrator1/reference.wav" "$TEST_TMP/first-reference.wav"
|
||||
|
||||
build narrator1 --offset 12 --duration 20 --force
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"RETIRED"* ]]
|
||||
# The old bytes survive, whole, under the trash directory beside the pack.
|
||||
local retired
|
||||
retired="$(find "$PACKS/.AGENTTRASH" -name reference.wav | head -n1)"
|
||||
[ -n "$retired" ]
|
||||
cmp "$retired" "$TEST_TMP/first-reference.wav"
|
||||
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||
}
|
||||
|
||||
@test "--dry-run needs no extractor at all" {
|
||||
# Everything up to the engine: the gate, the crop, the probe, the plan and
|
||||
# the manifest it would write — with the tool path pointed at nothing.
|
||||
run "$BUILDER" --env "$TEST_TMP/env" --tool "$TEST_TMP/no-such-tool" \
|
||||
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" \
|
||||
--offset 12 --duration 20 --dry-run narrator1 "$PACKS"
|
||||
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||
[[ "$output" == *"not present"* ]]
|
||||
[ ! -e "$PACKS/narrator1" ]
|
||||
[ ! -s "$STUB_ARGV" ]
|
||||
printf '%s\n' "$output" | awk '/^MANIFEST_BEGIN$/{p=1;next} /^MANIFEST_END$/{p=0} p' \
|
||||
| jq -e '.voice == "narrator1" and .crop.offset_seconds == 12 and .artifacts.produced == false'
|
||||
}
|
||||
|
||||
@test "a name the façade could not resolve is refused" {
|
||||
# app.py matches the directory name verbatim while the engine lowercases
|
||||
# names, and a slash would escape $VOICES_DIR: both are refused here, at the
|
||||
# only moment anyone can still fix them.
|
||||
for bad in 'Narrator1' '../evil' 'voice name'; do
|
||||
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||
--offset 12 --duration 20 "$bad" "$PACKS"
|
||||
[ "$status" -ne 0 ] || { echo "accepted '$bad': $output"; false; }
|
||||
[[ "$output" == *"voice name"* ]]
|
||||
done
|
||||
[ ! -e "$PACKS/Narrator1" ]
|
||||
[ ! -e "$PACKS/evil" ]
|
||||
}
|
||||
|
||||
@test "every crop in the tracked pack list clears the invariant" {
|
||||
# The law applied to the values the repo actually ships: no pack may be
|
||||
# described by an offset inside the forbidden region, a length outside the
|
||||
# reference window, or a source with no digest to verify it against.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
local checked=0 pack upper
|
||||
read -r -a packs <<<"$VOICE_PACKS"
|
||||
for pack in "${packs[@]}"; do
|
||||
upper="$(printf '%s' "$pack" | tr '[:lower:]' '[:upper:]')"
|
||||
local offset_var="VOICE_PACK_${upper}_OFFSET" duration_var="VOICE_PACK_${upper}_DURATION"
|
||||
local sha_var="VOICE_PACK_${upper}_SHA256" source_var="VOICE_PACK_${upper}_SOURCE"
|
||||
[ -n "${!source_var:-}" ] || { echo "$pack has no source path"; false; }
|
||||
printf '%s' "${!sha_var}" | grep -Eq '^[0-9a-f]{64}$' || { echo "$pack has no pinned sha256"; false; }
|
||||
awk -v o="${!offset_var}" 'BEGIN { exit !(o >= 10) }' || { echo "$pack crops at ${!offset_var} s, inside the forbidden first 10 s"; false; }
|
||||
awk -v d="${!duration_var}" 'BEGIN { exit !(d >= 18 && d <= 25) }' || { echo "$pack crops ${!duration_var} s, outside 18-25 s"; false; }
|
||||
checked=$((checked + 1))
|
||||
done
|
||||
[ "$checked" -gt 0 ]
|
||||
}
|
||||
Reference in new issue
Block a user