PR #15 corrected the engine's page, the research brief's law bullet and the warmer's header, but the retired ladder had six more homes: both quadlet files ("6.2-8.0 s… 0.5 ms", "~6-8 s each", "paying the 5.5 s cold decode"), `scripts/frank-tts-engine.env` (the whole law paragraph and the WARM_TEXT note), `scripts/frank-tts.env`, the [[Frank TTS]] troubleshooting row that told an operator to expect ~5.5 s, the research brief's consequences paragraph, and one test comment. All re-keyed to what was measured on the deployed container 2026-10-18: the first synthesis of a freshly registered voice costs 1,883 ms against 1,226-1,283 ms afterwards, so the warm pass buys about 0.6 s — and the reason it exists at all, which the pages never led with, is that the registry is memory-only: without the hook every voice name is a 400 until somebody synthesizes. The trial's dated worklog entry stays as written; the correction points at it. No behavior change: comments, unit lore and docs only, so no cfhash move and no image rebuild.
116 lines
6.3 KiB
INI
116 lines
6.3 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.
|
|
#
|
|
# 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
|