Repo state only: nothing deployed, no host touched, no container started. - `frank-tts-engine` on Dankclaw: `qwentts.cpp` commit 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4, resident `tts-server`, 1.7 B base talker Q4_K_M + Q8_0 tokenizer, container port 8880. It is deliberately NOT a fleet endpoint — `frank-tts` on 7772 is the endpoint consumers address. - Publishes NOTHING. A host-loopback publish would have been useless to the façade (netavark DNATs published ports only, so a bridge container cannot reach a host loopback bind — the shape that 502'd hermes) and would have been the one LAN-reachable surface of an unauthenticated synthesiser. Both voice containers sit on one committed quadlet network, `frank-tts-engine.network` pinned to 10.89.5.0/24 (next free /24 after open-notebook's 10.89.4.0/24), and peers address the engine by container name. Diagnostics and the deployer's health gate run inside the netns (`podman exec`, `wait_container_healthy`). - Cold-voice law honoured in the unit: the first request for a registered voice decodes its whole reference to snapshot codec state (6.2-8.0 s, first audio byte 5 530 ms), later ones restore in 0.5 ms, and the registry is memory-only. `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` with `TimeoutStartSec=900`; the `-` prefix keeps a failed warm from crash-looping the endpoint under `Restart=always`. It logs cold=/warm= per voice, so the under-a-second second request is proven on every boot. - Thread law recorded: `tts-server` has no `--threads`; src/backend.h uses hardware_concurrency()/2, so a cpuset pin silently changes the thread count. - Image builds from the pinned commit with the proven line (GGML_BLAS=OFF forced by this host, every GPU backend off, -j 8, no -DGGML_NATIVE because ggml defaults to -march=native for the building host). Two stages; the runtime stage COPYs both the entrypoint and the warmer, because a container that publishes nothing is reached with `podman exec`, not a port. - `scripts/download-frank-tts-engine-qwentts.sh` fetches the two GGUFs with byte and LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the deployer gates on --verify-only instead of pulling 1.4 GiB mid-deploy. - Registered in SERVICE_ORDER, DANKCLAW_SERVICES, the deploy-remote.sh dispatch, its pre-deploy requirement, deploy.sh's regex, the planner's path map (the whole `dankclaw/voiceservice/` tree mapped to nothing before this), and test-changed.sh ABOVE the `dankclaw/*.container` case whose `*` crosses `/`. - New seam tests/frank_tts_engine.bats (19): the Quadlet generator's own output for network/publish/boot/warm shape, entrypoint 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, the pinned cmake line, and both routing tables. Suite: 422 passed. - Vault: [[Frank TTS Engine]] linked from the index; log.md entry for the day.
137 lines
7.9 KiB
Bash
137 lines
7.9 KiB
Bash
# 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
|