# 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. # # REGISTRY LAW, THE REASON EXECSTARTPOST EXISTS. The voice registry is memory # only: a restart forgets every voice, and re-registering does not warm one — # only synthesizing does — so a boot without this hook leaves the façade # answering 400 for every name. The per-voice cold penalty is real but small, and # not the trial's 6.2-8.0 s: measured on the built container 2026-10-18 against an # empty registry, the first synthesis of a freshly registered voice cost 1,883 ms # against 1,226-1,283 ms for the requests after it. The warm pass runs serially # (two short syntheses per voice, ~5 s each) and `TimeoutStartSec=900` keeps a growing 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 two short syntheses per registered voice. TimeoutStartSec=900 ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh [Install] WantedBy=default.target