# 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 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 warm voice # registry instead of paying the 5.5 s cold decode. # # 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