Phase 1 of the voice-over goal (Gitea issue #2). One stdlib-only HTTP facade in front of whichever TTS engine wins the engine decision: POST /v1/audio/speech with voice=<name> answers audio in THAT voice, where a name is real exactly when /voices/<name>/reference.wav exists — an unknown name is a 400 listing the voices that are, never a silent fallback (a wrong-voice endpoint stays wrong for weeks). voice_id works as the alias the omp harness sends; response_format=mp3 is really mp3 (ffmpeg re-encode); oversized input is refused with 414 instead of half-narrated; /health reports the backend and the resolved voice list. Two backends behind the one surface, chosen by TTS_BACKEND: omni-http forwards to an OpenAI-shaped upstream carrying the voice as base64 reference_audio, and llama-tts-cli runs a local binary per request with --tts-speaker-file at nice 19 so narration never starves the text endpoints. Both are driven in the seam through stubs — no GPU, no containers. Named frank-tts, a first-class fleet model endpoint: port 7772 in the 7770/7771 family, alias naming per the StarCraft law, rootless boot-persistent self-healing quadlet with a read-only /home/dank/voices mount. Voice-pack law recorded in the env header: reference.wav PLUS the engine's own speaker artifact (required, so a voice is reusable in other runtimes), plus manifest.json provenance with offsets always past the first 10 s of the source file. Not deployed: the engine decision, the pack builder, deployer/homepage/Prometheus registration and the omp models.yml runner row are phase 2.
72 lines
3.3 KiB
INI
72 lines
3.3 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 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
|