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.
44 lines
2.1 KiB
Docker
44 lines
2.1 KiB
Docker
# frank-tts — the cloned-voice speech façade (dankclaw/voiceservice/app.py).
|
|
#
|
|
# Standard library only, so there is nothing to pip-install and nothing to
|
|
# compile: the image is a python runtime plus ffmpeg (the one binary the app
|
|
# shells out to, for response_format=mp3) plus one COPY of app.py. Rebuild to
|
|
# ship a new app.py.
|
|
#
|
|
# Build on dankclaw (deploy-remote.sh build_compose_image frank-tts does this,
|
|
# including --format docker, which the fleet's other local builds also pass):
|
|
# podman build --format docker -t franksdank/frank-tts:latest \
|
|
# -f dankclaw/voiceservice/Containerfile .
|
|
#
|
|
# BASE PINNED BY DIGEST, like every other image this fleet runs from a
|
|
# registry: a tag moves under a rebuild and a voice endpoint that changes
|
|
# python or ffmpeg mid-week is a debug session nobody asked for. The digest is
|
|
# the multi-arch INDEX of python:3.12-alpine as published 2026-10-18 (amd64 +
|
|
# arm64 on linux); Dankclaw resolves the amd64 entry.
|
|
#
|
|
# WHY 3.12: the fleet's ceiling for this app is python <= 3.12 (single file,
|
|
# no build step); the alpine flavour also keeps the image in the tens of MB
|
|
# and ships busybox `wget`, which is what the quadlet unit's HealthCmd runs —
|
|
# so no HEALTHCHECK belongs here (the probe lives in the unit, as in
|
|
# alertmanager/prometheus; only distroless bases need it baked in).
|
|
FROM docker.io/library/python:3.12-alpine@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4
|
|
|
|
# ffmpeg: mp3 output. The app always asks the upstream for wav and re-encodes
|
|
# on the way out, so ffmpeg is the only external program in the picture.
|
|
RUN apk add --no-cache ffmpeg
|
|
|
|
# Unbuffered stdout: the unit's journal must show the "listening on" line (and
|
|
# any backend error) the moment it happens, not when the buffer fills.
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
COPY dankclaw/voiceservice/app.py /app/app.py
|
|
|
|
# Non-root. The voice packs are bind-mounted read-only, so this user only ever
|
|
# reads them — they must be world-readable (the pack builder writes 644/755).
|
|
# The one thing it writes is its per-request temp dir under /tmp.
|
|
RUN adduser -D -u 10000 -h /app -s /sbin/nologin shim
|
|
USER shim
|
|
|
|
EXPOSE 7772
|
|
CMD ["python3", "/app/app.py"]
|