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.
113 lines
6.1 KiB
INI
113 lines
6.1 KiB
INI
# 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
|