# 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= 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 an # OpenAI-shaped upstream on host :8880 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. Bridge (dankclaw_default), NOT host: nothing here needs a shared # loopback namespace, and the comfyui/loki/authentik units are bridge too. # Consequences both ways: consumers reach it on the published 7772, and the # shim reaches the host-side TTS engine through host.containers.internal # (never the LAN IP — a bridge netns cannot route to the host's own address # for every bind shape, and the fleet's law is the gateway name). NOTE for # whoever starts the :8880 engine: host.containers.internal reaches the host # network namespace, so the engine must bind an address that namespace # accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop # needed the host LAN bind for the same reason). # # 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 Wants=network-online.target [Container] ContainerName=frank-tts Image=localhost/franksdank/frank-tts:latest Pull=never Network=dankclaw_default 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