Stage 2 of the voice stack. Repo state only: nothing deployed, no host touched, no container started. - `frank-tts` is now registered everywhere a dankclaw service has to be: `SERVICE_ORDER` (after `frank-tts-engine`, before the consumers), the planner's dankclaw set, `deploy-remote.sh`'s allowlist + dispatch + a `deploy_frank_tts` handler, and `deploy.sh`'s dispatch regex — plus the `build_compose_image` arm naming `dankclaw/voiceservice/Containerfile`, without which the build silently returns 0 and the unit's `Pull=never` tag never exists. - The unit joins TWO networks, in order: `dankclaw_default` first (default route, where consumers and open-notebook live), then the voice bridge. `TTS_UPSTREAM` moves from `host.containers.internal:8880` to `http://frank-tts-engine:8880` — the actual fix for a shape that could never work, since a bridge container cannot reach a host-loopback bind and the engine publishes nothing at all. - `Wants=`/`After=` on the engine, never `Requires=`: the `llama-tts-cli` rollback backend needs no upstream, so a broken engine may delay this endpoint but must not take its unit down. - The handler installs the engine's quadlet pair too, because a referenced `Network=` file the generator cannot find means no façade unit is produced at all, and `--only frank-tts` has no other way to get it. It creates `/home/dank/voices` as the deploy user (rootless podman would auto-create a missing bind source root-owned, which reads as "no voices"), and gates on podman's recorded health. - Voice naming is now written down where it can be found: a voice is a lowercase directory name matching `^[a-z0-9][a-z0-9._-]*$`, at most 64 chars. That shape is the intersection of two rules that disagree — the shim would accept mixed case, the engine lowercases names — so a mixed-case pack resolves on one side only, and the boot warmer refuses it loudly. `narrator1/2/3` are load-bearing for open-notebook. - Homepage card for the pair, `wip: down` and hidden, probing `127.0.0.1:7772`; the engine gets no card because it publishes nothing and could never be probed. - **No Prometheus job, deliberately.** No `/metrics` exists on the shim, this Prometheus 3 refuses HTML/JSON bodies (the existing `comfyui` and `services` probes are documented down for exactly that), and `LlmEndpointDown` is scoped `up{job=~"frank-.*"}` — so a `/health` job would page on Signal for a healthy endpoint. Recorded in the page instead of quietly skipped. - Vault: new [[Frank TTS]] page linked from the index; [[Frank TTS Engine]] now points at it for the pair's public signal; log entry for the day. - Seam grows to 25 tests: the generator's rendering of the pair (both attachments, first-network order, one publish, Wants-not-Requires, bounded per-unit blocks), the env file's upstream checked as a network JOIN rather than a string, planner separation and order, the handler's steps, the card's gating, routing that selects both seams for either deployable.
84 lines
4.1 KiB
INI
84 lines
4.1 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 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
|