# 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 DEVICE LAW (since the 2026-10-19 Vulkan cutover). Synthesis computes on # the gfx1151 iGPU — the SAME card frank-dragoon serves. It is a shared device, # never a dedicated one, so two numbers matter and neither is what `rocm-smi` # prints first: the memory the container may use is the RADV heap inside the # GTT-backed aperture (the ~2 GiB DRM carve-out is not the working set), and the # timing that matters is dragoon's, measured WITH a synthesis in flight. The # rollback is one line: set QWENTTS_DEVICE empty, and the entrypoint passes no # --device at all — the pre-iGPU CPU shape, no rebuild, no restart of anything # else on the box. # # THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live # in memory only, so a restart loses them, and re-registration alone does NOT warm # a voice — the warm pass has to synthesize something. Without it the façade # answers 400 for every name until somebody asks for audio. So # dankclaw/voiceservice/tts-engine-warm.sh (in the image at /app/warm-voices.sh) # runs on every start and speaks a short sentence twice per voice. The penalty it # removes is about 0.6 s, NOT the trial's 6.2-8.0 s: measured on the built # container 2026-10-18 against an empty registry, the first synthesis cost # 1,883 ms and the requests after it 1,226-1,283 ms. The hook 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 compute device, by ggml device NAME. `Vulkan0` is the gfx1151 iGPU: the # image carries the Mesa (RADV) ICD and no NVIDIA ICD, so this container's # Vulkan enumeration has exactly one entry and it is the iGPU. The unit hands # the container /dev/dri and keeps the render group; on this hybrid host the # iGPU's render node is renderD129 and renderD128 is the NVIDIA card, which is # why the unit passes the directory and lets the driver resolve the node. # # ROLLBACK: an empty value means no --device flag at all — the CPU shape this # endpoint shipped with until 2026-10-19. One line, no rebuild. QWENTTS_DEVICE=Vulkan0 # --- 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 — two syntheses per voice, ~5 s each, # inside the unit's start deadline. # QUOTED, and the quoting means two different things to the two readers of this # file, which is the trap here: # * bash (`set -a; source`, how the repo's seams and any human read it) needs # the quotes — an unquoted value with spaces is word-split and the second # word is executed as a command. # * podman's `--env-file` (how the unit actually reads it) does NOT strip # quotes: measured 2026-10-18 against the image's pinned base, the value # arrives as `"Warm up, one two three."` WITH them. Interpolated into the # hook's JSON body that is `""Warm up…""`, the engine's parser rejects it, # every voice stays cold and the boot still looks healthy. # So the value stays quoted for bash and the hook strips one matched pair before # it builds the request, then escapes what is left. Both readers now agree, and a # sentence containing a quote or a backslash still arrives verbatim. WARM_TEXT="Warm up, one two three." # Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is # ~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