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.
85 lines
4.2 KiB
INI
85 lines
4.2 KiB
INI
# Quadlet unit: frank-tts (rootless, boot-persistent).
|
|
#
|
|
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts.container
|
|
# Unit name: frank-tts.service (container name: frank-tts)
|
|
#
|
|
# WHAT IT IS. The fleet's one OpenAI-compatible speech endpoint: consumers
|
|
# (omp, OpenWebUI, open-notebook) POST /v1/audio/speech with voice=<name> and
|
|
# get audio in that cloned voice. A voice is a directory under
|
|
# /home/dank/voices holding reference.wav; the shim refuses any name without
|
|
# one (400 + the list that does exist) rather than narrating in a fallback
|
|
# voice nobody chose. The engine behind it is a separate decision —
|
|
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to the
|
|
# OpenAI-shaped engine (`frank-tts-engine`, [[Frank TTS Engine]]) and running a
|
|
# local CLI binary per request.
|
|
#
|
|
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
|
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
|
|
# pinned by digest + ffmpeg, app.py copied in, running as uid 10000.
|
|
# Pull=never and the localhost/ prefix are required — this host's quadlet
|
|
# path cannot resolve a locally built tag from local storage otherwise.
|
|
#
|
|
# NETWORK. TWO attached networks, and the order is the one the open-notebook
|
|
# pair uses: `dankclaw_default` first (the default route, and where consumers
|
|
# live — they reach the published 7772, and open-notebook dials
|
|
# `http://frank-tts:7772/v1` by container name), then the voice pair's private
|
|
# bridge, `frank-tts-engine.network`, where the arm A engine lives. The shim's
|
|
# upstream is therefore the engine's container DNS name
|
|
# (`http://frank-tts-engine:8880`, scripts/frank-tts.env) and NOT
|
|
# host.containers.internal: netavark DNATs published ports only, so a bridge
|
|
# container cannot reach a host-loopback bind — the shape that 502'd hermes —
|
|
# and the engine publishes nothing at all. That file MUST be installed beside
|
|
# this unit (the engine's handler does it; the façade handler re-installs it for
|
|
# `--only frank-tts`), because with a referenced Network= file missing the v1
|
|
# generator refuses to produce this container unit at all.
|
|
#
|
|
# ORDERING. Wants+After on frank-tts-engine.service, not Requires: the façade
|
|
# has a second backend (`TTS_BACKEND=llama-tts-cli`) that needs no upstream, and
|
|
# an engine that will not start must not take this endpoint's unit down with it.
|
|
# When both boot, this comes up after the engine, whose ExecStartPost warm pass
|
|
# is part of its start — so the first request of the day meets a REGISTERED voice
|
|
# (the registry is memory-only) rather than a 400, and skips the ~0.6 s penalty
|
|
# the first synthesis of an unwarmed voice costs.
|
|
#
|
|
# VOICES. /home/dank/voices is host state, never repo state, and is mounted
|
|
# READ-ONLY: this endpoint has no business writing voice packs. Because the
|
|
# container runs as uid 10000, the packs must be world-readable (644 files,
|
|
# 755 directories), which is how the pack builder writes them.
|
|
#
|
|
# NO GPU. The shim is a façade: audio conversion via ffmpeg on the CPU and,
|
|
# for the CLI backend, whatever CPU threads LLAMA_TTS_THREADS allows, at nice
|
|
# 19. No /dev/kfd, no /dev/dri, no keep-groups — unlike comfyui, whose unit
|
|
# carries them deliberately.
|
|
[Unit]
|
|
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
|
|
After=network-online.target frank-tts-engine.service
|
|
Wants=network-online.target
|
|
Wants=frank-tts-engine.service
|
|
|
|
[Container]
|
|
ContainerName=frank-tts
|
|
Image=localhost/franksdank/frank-tts:latest
|
|
Pull=never
|
|
Network=dankclaw_default
|
|
Network=frank-tts-engine.network
|
|
PublishPort=7772:7772
|
|
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
|
Volume=/home/dank/voices:/voices:ro
|
|
# The probe is a probe, not a voice test: /health answers 200 with the backend
|
|
# and the resolved voice list, so a mounted-but-empty /voices still reads
|
|
# healthy while a dead app does not. busybox wget ships in the alpine base;
|
|
# keep the command argument-only — the podman 5.x generator splits HealthCmd
|
|
# on spaces (see the comfyui unit for the quoting trap).
|
|
HealthCmd=wget --spider -q http://127.0.0.1:7772/health
|
|
HealthInterval=30s
|
|
HealthTimeout=10s
|
|
HealthRetries=3
|
|
HealthStartPeriod=30s
|
|
|
|
[Service]
|
|
Restart=always
|
|
RestartSec=10
|
|
|
|
[Install]
|
|
WantedBy=default.target
|