Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6956189bb6 | ||
|
|
3380b7da10 | ||
|
|
7178e1c789 | ||
|
|
59e021da2c | ||
|
|
d336bdaa66 | ||
|
|
d135ae7f02 | ||
|
|
c1087f214e | ||
|
|
1312c29fe2 | ||
|
|
8fefe6e1a9 | ||
|
|
d421cb69cf | ||
|
|
2427fc448f | ||
|
|
3824d03ac5 | ||
|
|
042003a969 | ||
|
|
2242fcfec0 |
No files matched your search
@@ -110,7 +110,16 @@ RUN apt-get update -qq \
|
|||||||
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
|
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
|
||||||
# model directory are host state mounted read-only, and one uid across the pair
|
# model directory are host state mounted read-only, and one uid across the pair
|
||||||
# means one thing to remember about what is world-readable.
|
# means one thing to remember about what is world-readable.
|
||||||
RUN adduser --system --uid 10000 --home /app --shell /usr/sbin/nologin tts
|
#
|
||||||
|
# `useradd`, NOT `adduser`: this trimmed ubuntu:24.04 base ships no `adduser` at
|
||||||
|
# all (verified 2026-10-18 against this digest — `adduser` is absent from the
|
||||||
|
# base and is not pulled in by ca-certificates/curl/libgomp1 either), so an
|
||||||
|
# `adduser` line dies at image-build time with crun's "executable file `adduser`
|
||||||
|
# not found in $PATH". `useradd --system --uid 10000` succeeds and prints one
|
||||||
|
# cosmetic warning (`uid 10000 is greater than SYS_UID_MAX 999`); the uid is the
|
||||||
|
# point, and it is what matches the façade. The alpine façade Containerfile keeps
|
||||||
|
# its own busybox `adduser -D -u 10000` — different base, different tool, same uid.
|
||||||
|
RUN useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/build/tts-server /src/build/*.so* /app/
|
COPY --from=build /src/build/tts-server /src/build/*.so* /app/
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
# /home/dank/voices holding reference.wav; the shim refuses any name without
|
# /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
|
# one (400 + the list that does exist) rather than narrating in a fallback
|
||||||
# voice nobody chose. The engine behind it is a separate decision —
|
# voice nobody chose. The engine behind it is a separate decision —
|
||||||
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an
|
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to the
|
||||||
# OpenAI-shaped upstream on host :8880 and running a local CLI binary per
|
# OpenAI-shaped engine (`frank-tts-engine`, [[Frank TTS Engine]]) and running a
|
||||||
# request.
|
# local CLI binary per request.
|
||||||
#
|
#
|
||||||
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
|
||||||
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
|
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
|
||||||
@@ -19,16 +19,26 @@
|
|||||||
# Pull=never and the localhost/ prefix are required — this host's quadlet
|
# Pull=never and the localhost/ prefix are required — this host's quadlet
|
||||||
# path cannot resolve a locally built tag from local storage otherwise.
|
# path cannot resolve a locally built tag from local storage otherwise.
|
||||||
#
|
#
|
||||||
# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared
|
# NETWORK. TWO attached networks, and the order is the one the open-notebook
|
||||||
# loopback namespace, and the comfyui/loki/authentik units are bridge too.
|
# pair uses: `dankclaw_default` first (the default route, and where consumers
|
||||||
# Consequences both ways: consumers reach it on the published 7772, and the
|
# live — they reach the published 7772, and open-notebook dials
|
||||||
# shim reaches the host-side TTS engine through host.containers.internal
|
# `http://frank-tts:7772/v1` by container name), then the voice pair's private
|
||||||
# (never the LAN IP — a bridge netns cannot route to the host's own address
|
# bridge, `frank-tts-engine.network`, where the arm A engine lives. The shim's
|
||||||
# for every bind shape, and the fleet's law is the gateway name). NOTE for
|
# upstream is therefore the engine's container DNS name
|
||||||
# whoever starts the :8880 engine: host.containers.internal reaches the host
|
# (`http://frank-tts-engine:8880`, scripts/frank-tts.env) and NOT
|
||||||
# network namespace, so the engine must bind an address that namespace
|
# host.containers.internal: netavark DNATs published ports only, so a bridge
|
||||||
# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop
|
# container cannot reach a host-loopback bind — the shape that 502'd hermes —
|
||||||
# needed the host LAN bind for the same reason).
|
# 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
|
# 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
|
# READ-ONLY: this endpoint has no business writing voice packs. Because the
|
||||||
@@ -41,14 +51,16 @@
|
|||||||
# carries them deliberately.
|
# carries them deliberately.
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
|
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
|
||||||
After=network-online.target
|
After=network-online.target frank-tts-engine.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
Wants=frank-tts-engine.service
|
||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=frank-tts
|
ContainerName=frank-tts
|
||||||
Image=localhost/franksdank/frank-tts:latest
|
Image=localhost/franksdank/frank-tts:latest
|
||||||
Pull=never
|
Pull=never
|
||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
|
Network=frank-tts-engine.network
|
||||||
PublishPort=7772:7772
|
PublishPort=7772:7772
|
||||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
|
||||||
Volume=/home/dank/voices:/voices:ro
|
Volume=/home/dank/voices:/voices:ro
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ set -euo pipefail
|
|||||||
: "${QWENTTS_ALIAS:=frank-tts-engine}"
|
: "${QWENTTS_ALIAS:=frank-tts-engine}"
|
||||||
|
|
||||||
# 0.0.0.0 inside the container is not "public". tts-server defaults to
|
# 0.0.0.0 inside the container is not "public". tts-server defaults to
|
||||||
# 127.0.0.1, which inside a container netns is reachable by nothing — the
|
# 127.0.0.1 (its own usage prints `--host <ip> Listen address (default:
|
||||||
# façade dials this container by its bridge address. The container publishes
|
# 127.0.0.1)`), and inside a container netns a loopback bind is reachable by
|
||||||
# 8880 on the host's loopback only (diagnostics and the boot warmer), so the
|
# nothing outside that namespace — the façade dials this container by its bridge
|
||||||
# only namespace that can reach 0.0.0.0:8880 here is the private voice bridge.
|
# address on the private voice network, which is why the address has to be
|
||||||
|
# broad. The unit PUBLISHES NOTHING (see
|
||||||
|
# dankclaw/voiceservice/quadlet/frank-tts-engine.container: no PublishPort at
|
||||||
|
# all, not even 127.0.0.1:8880), so the only namespaces that can reach
|
||||||
|
# 0.0.0.0:8880 are that bridge and this container's own loopback — and its own
|
||||||
|
# loopback is exactly where the boot warmer and the health probe speak.
|
||||||
: "${QWENTTS_HOST:=0.0.0.0}"
|
: "${QWENTTS_HOST:=0.0.0.0}"
|
||||||
: "${QWENTTS_PORT:=8880}"
|
: "${QWENTTS_PORT:=8880}"
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,31 @@ set -euo pipefail
|
|||||||
# systemd counts the whole warm pass against the unit's start deadline.
|
# systemd counts the whole warm pass against the unit's start deadline.
|
||||||
: "${REQUEST_TIMEOUT:=120}"
|
: "${REQUEST_TIMEOUT:=120}"
|
||||||
|
|
||||||
|
# The warm text goes into a JSON string literal by interpolation. Two steps make
|
||||||
|
# whatever the two readers of scripts/frank-tts-engine.env deliver arrive as the
|
||||||
|
# sentence itself:
|
||||||
|
#
|
||||||
|
# 1. Unwrap ONE shell-style quoting layer. bash (`set -a; source`) strips the
|
||||||
|
# quotes around a value with spaces; podman's `--env-file` does NOT — measured
|
||||||
|
# 2026-10-18 against the image's pinned base, `WARM_TEXT="…"` reaches the
|
||||||
|
# container WITH its quotes. Unwrapped only when the whole value is exactly one
|
||||||
|
# quoted string (starts AND ends with `"` and holds no quote inside), so a
|
||||||
|
# sentence that legitimately contains quotes is never mangled.
|
||||||
|
# 2. Escape the two characters that would corrupt or end a JSON string: a
|
||||||
|
# backslash, and a double quote.
|
||||||
|
#
|
||||||
|
# Without step 1 the body is `{"input":""Warm up…"",…}`, the engine's JSON parser
|
||||||
|
# rejects it, every voice stays cold, and the boot still reads healthy. Voice NAMES
|
||||||
|
# need neither step: the character class checked below admits no quote, no
|
||||||
|
# backslash and no control byte.
|
||||||
|
warm_text="$WARM_TEXT"
|
||||||
|
if (( ${#warm_text} >= 2 )) && [[ "$warm_text" == \"*\" ]]; then
|
||||||
|
inner="${warm_text:1:${#warm_text}-2}"
|
||||||
|
[[ "$inner" != *'"'* ]] && warm_text="$inner"
|
||||||
|
fi
|
||||||
|
warm_json="${warm_text//\\/\\\\}"
|
||||||
|
warm_json="${warm_json//\"/\\\"}"
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
||||||
|
|
||||||
@@ -135,7 +160,7 @@ for dir in "$VOICES_DIR"/*/; do
|
|||||||
start=$(date +%s%3N)
|
start=$(date +%s%3N)
|
||||||
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
|
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data-binary "{\"input\":\"${WARM_TEXT}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
|
--data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
|
||||||
"${ENGINE_URL}/v1/audio/speech"; then
|
"${ENGINE_URL}/v1/audio/speech"; then
|
||||||
log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold"
|
log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold"
|
||||||
fail=1
|
fail=1
|
||||||
@@ -149,7 +174,7 @@ for dir in "$VOICES_DIR"/*/; do
|
|||||||
start=$(date +%s%3N)
|
start=$(date +%s%3N)
|
||||||
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
|
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data-binary "{\"input\":\"${WARM_TEXT}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
|
--data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
|
||||||
"${ENGINE_URL}/v1/audio/speech"; then
|
"${ENGINE_URL}/v1/audio/speech"; then
|
||||||
log "voice '${name}': warm synthesis failed after a cold one that worked"
|
log "voice '${name}': warm synthesis failed after a cold one that worked"
|
||||||
fail=1
|
fail=1
|
||||||
|
|||||||
Executable
+483
@@ -0,0 +1,483 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-voice-pack.sh — turn a SOURCE recording plus a CROP OFFSET into a
|
||||||
|
# deployable frank-tts voice pack: the reference WAV, the engine's own speaker
|
||||||
|
# artifacts made from it, and manifest.json provenance.
|
||||||
|
#
|
||||||
|
# THE PACK. $PACKS_DIR/<voice>/ carries the three members the pack law in
|
||||||
|
# scripts/frank-tts.env names:
|
||||||
|
#
|
||||||
|
# reference.wav the crop: 24 kHz mono PCM (pcm_s16le), 18-25 s of clean
|
||||||
|
# single-speaker audio. This is what makes a voice NAME real
|
||||||
|
# (dankclaw/voiceservice/app.py lists and resolves exactly
|
||||||
|
# this file), so it is written LAST, once everything else in
|
||||||
|
# the pack has been verified.
|
||||||
|
# reference.spk the engine's x-vector: enc_dim 2048 f32 = 8,192 B.
|
||||||
|
# reference.rvq the engine's RVQ code stream: 16 codebooks x 11 bits = 22 B
|
||||||
|
# per 12.5 Hz frame = 22 * floor(samples / 1920).
|
||||||
|
# manifest.json provenance: source path and sha256, crop start/end, sample
|
||||||
|
# rate, duration, the tool + engine commit that made the
|
||||||
|
# artifacts, and the voice name.
|
||||||
|
#
|
||||||
|
# Both artifacts come from the pinned engine build's speaker extractor — the
|
||||||
|
# `qwen-codec` tool of qwentts.cpp at VOICE_PACK_ENGINE_COMMIT — run on the pack's
|
||||||
|
# own WAV, and the extractor names them by swapping the input's extension, so
|
||||||
|
# they land beside reference.wav with nothing to rename. Required, never a
|
||||||
|
# replacement for the WAV: a voice another runtime cannot reuse is not saved.
|
||||||
|
#
|
||||||
|
# THE 10-SECOND GATE — THE REASON THIS SCRIPT EXISTS. Project-wide source-material
|
||||||
|
# invariant: the first 10 s of any sample file used to generate voices is
|
||||||
|
# forbidden material. An offset that starts inside that region is refused with a
|
||||||
|
# nonzero exit BEFORE anything is created — no pack dir, no WAV, no artifact — and
|
||||||
|
# the same gate is asserted a second time from the manifest that was just written
|
||||||
|
# (plus the recorded sha256 against the source it claims). A pack carrying a
|
||||||
|
# forbidden crop therefore cannot survive a build.
|
||||||
|
#
|
||||||
|
# THE ARTIFACT SIZES ARE CHECKED, NOT ASSUMED. They are a property of the codec
|
||||||
|
# config inside the GGUF, so an engine bump that changes enc_dim or the codebook
|
||||||
|
# count fails the build instead of shipping a pack the engine will reject at
|
||||||
|
# synthesis time. Note the interaction with the WAV window: an 18.0 s crop is a
|
||||||
|
# legal reference.wav but only 22 * 225 = 4,950 B of .rvq, under the recorded
|
||||||
|
# 5,456 B floor, so the effective crop window is 19.84-25 s. That is a real
|
||||||
|
# constraint and the builder says so up front rather than failing after a build;
|
||||||
|
# serverAppConfigs-docs/Dankclaw/Voice Packs.md covers what to do on an engine bump.
|
||||||
|
#
|
||||||
|
# WHERE IT RUNS: the host that holds the engine build (Dankclaw). The extractor is
|
||||||
|
# a CPU ggml binary — no GPU, no container, no fleet endpoint, no network. ffmpeg
|
||||||
|
# does the crop; every spawned process runs `nice -n 19` so a pack build never
|
||||||
|
# competes with the text endpoints on that box.
|
||||||
|
#
|
||||||
|
# IT NEVER DELETES. An existing pack is refused unless --force, and --force moves
|
||||||
|
# the whole old directory aside into $PACKS_DIR/.AGENTTRASH/<voice>-<UTC>/ rather
|
||||||
|
# than overwriting or unlinking a single byte.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/build-voice-pack.sh [OPTIONS] VOICE PACKS_DIR
|
||||||
|
# scripts/build-voice-pack.sh [OPTIONS] --all PACKS_DIR
|
||||||
|
#
|
||||||
|
# Options (anything not given comes from scripts/voice-packs.env):
|
||||||
|
# --env FILE tunables file (default scripts/voice-packs.env)
|
||||||
|
# --source FILE source recording for this voice
|
||||||
|
# --offset SEC crop start, in seconds into the source (must be >= 10)
|
||||||
|
# --duration SEC crop length; reference.wav comes out this long
|
||||||
|
# --sha256 HEX expected sha256 of the source file
|
||||||
|
# --tool PATH the pinned speaker extractor (qwen-codec)
|
||||||
|
# --all build every voice in VOICE_PACKS, skipping none
|
||||||
|
# --dry-run everything except the extractor: validate, cut the crop,
|
||||||
|
# probe it, print the plan and the manifest it would write
|
||||||
|
# --force retire an existing pack to .AGENTTRASH/ and rebuild
|
||||||
|
# -h, --help this text
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
|
||||||
|
SOURCE_FORBIDDEN_SECONDS=10 # the source-material invariant; not a knob
|
||||||
|
WORK_DIR="" # scratch dir a dry run owns, removed on exit
|
||||||
|
|
||||||
|
# Only ever removes the scratch directory this script created for itself; pack
|
||||||
|
# bytes on disk are retired to .AGENTTRASH/, never deleted (see --force).
|
||||||
|
cleanup_work_dir() {
|
||||||
|
if [ -n "$WORK_DIR" ]; then
|
||||||
|
rm -rf -- "$WORK_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup_work_dir EXIT
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'ERROR: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# fcmp A OP B — float comparison (awk); OP is one of < <= > >= == and A/B are
|
||||||
|
# already validated as plain decimals, so nothing user-shaped reaches awk as
|
||||||
|
# program text.
|
||||||
|
fcmp() {
|
||||||
|
awk -v a="$1" -v b="$3" "BEGIN { exit !(a $2 b) }"
|
||||||
|
}
|
||||||
|
|
||||||
|
# floorn X — greatest integer <= X, printed. Only ever called on positives.
|
||||||
|
floorn() {
|
||||||
|
awk -v x="$1" 'BEGIN { printf "%d\n", int(x) }'
|
||||||
|
}
|
||||||
|
|
||||||
|
is_seconds() {
|
||||||
|
[[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
is_hex64() {
|
||||||
|
[[ "$1" =~ ^[0-9a-f]{64}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- arguments --
|
||||||
|
VOICE=""
|
||||||
|
PACKS_DIR=""
|
||||||
|
ARG_SOURCE=""
|
||||||
|
ARG_OFFSET=""
|
||||||
|
ARG_DURATION=""
|
||||||
|
ARG_SHA=""
|
||||||
|
ARG_TOOL=""
|
||||||
|
DRY_RUN=0
|
||||||
|
FORCE=0
|
||||||
|
BUILD_ALL=0
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--env) ENV_FILE="${2:?--env needs a path}"; shift 2 ;;
|
||||||
|
--source) ARG_SOURCE="${2:?--source needs a file}"; shift 2 ;;
|
||||||
|
--offset) ARG_OFFSET="${2:?--offset needs seconds}"; shift 2 ;;
|
||||||
|
--duration) ARG_DURATION="${2:?--duration needs seconds}"; shift 2 ;;
|
||||||
|
--sha256) ARG_SHA="${2:?--sha256 needs a hex digest}"; shift 2 ;;
|
||||||
|
--tool) ARG_TOOL="${2:?--tool needs a path}"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--force) FORCE=1; shift ;;
|
||||||
|
--all) BUILD_ALL=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--*) die "unknown option: $1 (see --help)" ;;
|
||||||
|
*)
|
||||||
|
if [ -z "$VOICE" ]; then
|
||||||
|
VOICE="$1"
|
||||||
|
elif [ -z "$PACKS_DIR" ]; then
|
||||||
|
PACKS_DIR="$1"
|
||||||
|
else
|
||||||
|
die "unexpected argument: $1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -f "$ENV_FILE" ] || die "tunables file not found: $ENV_FILE"
|
||||||
|
for tool in ffmpeg ffprobe jq sha256sum; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || die "$tool is required and not on PATH"
|
||||||
|
done
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
|
||||||
|
# Tunables the env file owes us. Defaults are the engine facts read out of the
|
||||||
|
# pinned tree; an env file that omits one still builds against the truth.
|
||||||
|
VOICE_PACKS="${VOICE_PACKS:-}"
|
||||||
|
SAMPLE_RATE="${VOICE_PACK_SAMPLE_RATE:-24000}"
|
||||||
|
MIN_SECONDS="${VOICE_PACK_MIN_SECONDS:-18}"
|
||||||
|
MAX_SECONDS="${VOICE_PACK_MAX_SECONDS:-25}"
|
||||||
|
FRAME_RATE="${VOICE_PACK_FRAME_RATE:-12.5}"
|
||||||
|
RVQ_BYTES_PER_FRAME="${VOICE_PACK_RVQ_BYTES_PER_FRAME:-22}"
|
||||||
|
SPK_BYTES="${VOICE_PACK_SPK_BYTES:-8192}"
|
||||||
|
RVQ_MIN_BYTES="${VOICE_PACK_RVQ_MIN_BYTES:-5456}"
|
||||||
|
RVQ_MAX_BYTES="${VOICE_PACK_RVQ_MAX_BYTES:-6864}"
|
||||||
|
TOOL="${ARG_TOOL:-${VOICE_PACK_TOOL:-}}"
|
||||||
|
CODEC_MODEL="${VOICE_PACK_CODEC_MODEL:-}"
|
||||||
|
TALKER_MODEL="${VOICE_PACK_TALKER_MODEL:-}"
|
||||||
|
ENGINE_REPO="${VOICE_PACK_ENGINE_REPO:-https://github.com/ServeurpersoCom/qwentts.cpp}"
|
||||||
|
ENGINE_COMMIT="${VOICE_PACK_ENGINE_COMMIT:-5916bf7c79dbb4e66fc059cdc507a79a23a28bb4}"
|
||||||
|
DEVICE="${VOICE_PACK_DEVICE:-CPU}"
|
||||||
|
THREADS="${VOICE_PACK_THREADS:-8}"
|
||||||
|
NICE_LEVEL="${VOICE_PACK_NICE:-19}"
|
||||||
|
TRASH_DIRNAME="${VOICE_PACK_TRASH_DIRNAME:-.AGENTTRASH}"
|
||||||
|
DEFAULT_PACKS_DIR="${VOICE_PACK_DIR:-/home/dank/voices}"
|
||||||
|
|
||||||
|
[ -n "$PACKS_DIR" ] || PACKS_DIR="$DEFAULT_PACKS_DIR"
|
||||||
|
[ -n "$PACKS_DIR" ] || die "no packs directory (pass it as an argument)"
|
||||||
|
is_seconds "$SAMPLE_RATE" || die "VOICE_PACK_SAMPLE_RATE is not a number: $SAMPLE_RATE"
|
||||||
|
is_seconds "$MIN_SECONDS" || die "VOICE_PACK_MIN_SECONDS is not a number: $MIN_SECONDS"
|
||||||
|
is_seconds "$MAX_SECONDS" || die "VOICE_PACK_MAX_SECONDS is not a number: $MAX_SECONDS"
|
||||||
|
|
||||||
|
# hop / frame arithmetic, derived once: the codec runs at FRAME_RATE Hz over
|
||||||
|
# SAMPLE_RATE audio, so one frame is HOP_SAMPLES samples and one second is
|
||||||
|
# FRAME_RATE frames.
|
||||||
|
HOP_SAMPLES="$(floorn "$(awk -v r="$SAMPLE_RATE" -v f="$FRAME_RATE" 'BEGIN { print r / f }')")"
|
||||||
|
[ "$HOP_SAMPLES" -gt 0 ] || die "VOICE_PACK_FRAME_RATE ($FRAME_RATE) exceeds the sample rate"
|
||||||
|
BYTES_PER_SECOND="$(awk -v b="$RVQ_BYTES_PER_FRAME" -v f="$FRAME_RATE" 'BEGIN { print b * f }')"
|
||||||
|
|
||||||
|
# rvq_bytes_for DURATION — packed size the extractor will emit for a crop this
|
||||||
|
# long: whole frames only, because it truncates to the hop boundary.
|
||||||
|
rvq_bytes_for() {
|
||||||
|
local samples frames
|
||||||
|
samples="$(awk -v d="$1" -v r="$SAMPLE_RATE" 'BEGIN { printf "%d\n", int(d * r + 0.5) }')"
|
||||||
|
frames=$((samples / HOP_SAMPLES))
|
||||||
|
printf '%d\n' "$((frames * RVQ_BYTES_PER_FRAME))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- build --
|
||||||
|
build_voice() {
|
||||||
|
local voice="$1"
|
||||||
|
# The pack dir name is the voice name on the wire: app.py's VOICE_NAME_RE
|
||||||
|
# matches it exactly while the engine lowercases names internally, so an
|
||||||
|
# uppercase or punctuated name would be reported one way by the façade and
|
||||||
|
# another by the engine. Lowercase only, and nothing that could escape
|
||||||
|
# $PACKS_DIR.
|
||||||
|
[[ "$voice" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] ||
|
||||||
|
die "voice name '$voice' is not a lowercase [a-z0-9._-] name starting with alphanumerics (app.py resolves it verbatim; the engine lowercases it)"
|
||||||
|
|
||||||
|
local prefix
|
||||||
|
prefix="VOICE_PACK_$(printf '%s' "$voice" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')"
|
||||||
|
local source_var="${prefix}_SOURCE" offset_var="${prefix}_OFFSET"
|
||||||
|
local duration_var="${prefix}_DURATION" sha_var="${prefix}_SHA256"
|
||||||
|
local source="${ARG_SOURCE:-${!source_var:-}}"
|
||||||
|
local offset="${ARG_OFFSET:-${!offset_var:-}}"
|
||||||
|
local duration="${ARG_DURATION:-${!duration_var:-}}"
|
||||||
|
local want_sha="${ARG_SHA:-${!sha_var:-}}"
|
||||||
|
|
||||||
|
if [ -z "$voice" ] || [ -z "$source" ]; then
|
||||||
|
die "no source for '$voice': pass --source or set ${source_var} in $ENV_FILE"
|
||||||
|
fi
|
||||||
|
[ -n "$offset" ] || die "no crop offset for '$voice': pass --offset or set ${offset_var}"
|
||||||
|
[ -n "$duration" ] || die "no crop duration for '$voice': pass --duration or set ${duration_var}"
|
||||||
|
is_seconds "$offset" || die "crop offset '$offset' is not a number of seconds"
|
||||||
|
is_seconds "$duration" || die "crop duration '$duration' is not a number of seconds"
|
||||||
|
[ -f "$source" ] && [ -r "$source" ] || die "source not readable: $source"
|
||||||
|
|
||||||
|
# === THE GATE. Source seconds 0..10 are forbidden voice material, full stop.
|
||||||
|
if ! fcmp "$offset" '>=' "$SOURCE_FORBIDDEN_SECONDS"; then
|
||||||
|
die "source-material invariant violated: the crop must start past the first ${SOURCE_FORBIDDEN_SECONDS} s of the source file, and '$voice' asks for ${offset} s into $source. Pick an offset at or past ${SOURCE_FORBIDDEN_SECONDS} s — nothing was written."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pack_dir="$PACKS_DIR/$voice"
|
||||||
|
if [ "$DRY_RUN" -ne 1 ] && [ -e "$pack_dir" ] && [ "$FORCE" -ne 1 ]; then
|
||||||
|
die "$pack_dir already exists; nothing was touched. Re-run with --force to retire it to $PACKS_DIR/$TRASH_DIRNAME/ and rebuild."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local source_duration source_bytes source_sha
|
||||||
|
source_duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$source")"
|
||||||
|
is_seconds "$source_duration" || die "ffprobe reports no numeric duration for $source: '$source_duration'"
|
||||||
|
source_bytes="$(stat -c%s "$source")"
|
||||||
|
if ! fcmp "$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')" '<=' "$source_duration"; then
|
||||||
|
die "crop ends at $(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }') s but $source is only $source_duration s; the crop would come out short"
|
||||||
|
fi
|
||||||
|
if ! fcmp "$duration" '>=' "$MIN_SECONDS" || ! fcmp "$duration" '<=' "$MAX_SECONDS"; then
|
||||||
|
die "crop duration $duration s is outside the ${MIN_SECONDS}-${MAX_SECONDS} s reference window"
|
||||||
|
fi
|
||||||
|
local crop_end planned_rvq
|
||||||
|
crop_end="$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')"
|
||||||
|
planned_rvq="$(rvq_bytes_for "$duration")"
|
||||||
|
if ! fcmp "$planned_rvq" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$planned_rvq" '<=' "$RVQ_MAX_BYTES"; then
|
||||||
|
die "a $duration s crop packs to ${planned_rvq} B of .rvq, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B; lengthen the crop (that window starts at $(awk -v m="$RVQ_MIN_BYTES" -v b="$BYTES_PER_SECOND" 'BEGIN { print m / b }') s) — the source, not the codec, sets this floor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "VOICE $voice"
|
||||||
|
echo "SOURCE $source ($source_bytes bytes, ${source_duration} s)"
|
||||||
|
echo "CROP ${offset} s .. ${crop_end} s (${duration} s) — past the ${SOURCE_FORBIDDEN_SECONDS} s forbidden region"
|
||||||
|
|
||||||
|
source_sha="$(sha256sum "$source" | awk '{ print $1 }')"
|
||||||
|
if [ -n "$want_sha" ]; then
|
||||||
|
is_hex64 "$want_sha" || die "expected sha256 '$want_sha' is not 64 hex digits"
|
||||||
|
[ "$source_sha" = "$want_sha" ] ||
|
||||||
|
die "source sha256 is $source_sha, expected $want_sha: $source is not the file these offsets were cut from"
|
||||||
|
fi
|
||||||
|
echo "SHA256 $source_sha"
|
||||||
|
|
||||||
|
# Where the crop is cut. A dry run keeps its work in a scratch dir it owns;
|
||||||
|
# a real build stages INSIDE the pack dir so the final rename is on one
|
||||||
|
# filesystem and the pack becomes visible only when it is whole.
|
||||||
|
local stage_dir
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/voice-pack-$voice.XXXXXX")"
|
||||||
|
stage_dir="$WORK_DIR"
|
||||||
|
else
|
||||||
|
mkdir -p "$PACKS_DIR"
|
||||||
|
if [ -e "$pack_dir" ]; then
|
||||||
|
local trash
|
||||||
|
trash="$PACKS_DIR/$TRASH_DIRNAME/$voice-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
mkdir -p "$(dirname "$trash")"
|
||||||
|
mv "$pack_dir" "$trash"
|
||||||
|
echo "RETIRED $pack_dir -> $trash (nothing deleted)"
|
||||||
|
fi
|
||||||
|
mkdir "$pack_dir" || die "cannot create $pack_dir (it may exist now); nothing was overwritten"
|
||||||
|
stage_dir="$pack_dir"
|
||||||
|
fi
|
||||||
|
local stage_wav="$stage_dir/staging-reference.wav"
|
||||||
|
|
||||||
|
nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
|
||||||
|
ffmpeg -hide_banner -loglevel error -y \
|
||||||
|
-ss "$offset" -t "$duration" -i "$source" \
|
||||||
|
-map 0:a:0 -vn -ac 1 -ar "$SAMPLE_RATE" -c:a pcm_s16le "$stage_wav"
|
||||||
|
|
||||||
|
local wav_rate wav_channels wav_codec wav_duration
|
||||||
|
wav_rate="$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$stage_wav")"
|
||||||
|
wav_channels="$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$stage_wav")"
|
||||||
|
wav_codec="$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$stage_wav")"
|
||||||
|
wav_duration="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$stage_wav")"
|
||||||
|
is_seconds "$wav_rate" || die "ffprobe reports no numeric sample rate for the crop: '$wav_rate'"
|
||||||
|
is_seconds "$wav_duration" || die "ffprobe reports no numeric duration for the crop: '$wav_duration'"
|
||||||
|
[ "$wav_rate" = "$SAMPLE_RATE" ] || die "reference.wav came out at ${wav_rate} Hz, not ${SAMPLE_RATE}"
|
||||||
|
[ "$wav_channels" = "1" ] || die "reference.wav came out with $wav_channels channels, not mono"
|
||||||
|
[ "$wav_codec" = "pcm_s16le" ] || die "reference.wav came out as $wav_codec, not the pcm_s16le the law names"
|
||||||
|
if ! fcmp "$wav_duration" '>=' "$MIN_SECONDS" || ! fcmp "$wav_duration" '<=' "$MAX_SECONDS"; then
|
||||||
|
die "reference.wav is ${wav_duration} s, outside the ${MIN_SECONDS}-${MAX_SECONDS} s window"
|
||||||
|
fi
|
||||||
|
echo "REFERENCE ${wav_rate} Hz ${wav_codec} mono, ${wav_duration} s, $(stat -c%s "$stage_wav") bytes"
|
||||||
|
|
||||||
|
local spk_file="$stage_dir/staging-reference.spk" rvq_file="$stage_dir/staging-reference.rvq"
|
||||||
|
local stage_log="$stage_dir/staging-qwen-codec.log"
|
||||||
|
local spk_bytes=0 rvq_bytes=0 produced=false
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
if [ -n "$TOOL" ] && [ -x "$TOOL" ]; then
|
||||||
|
echo "EXTRACTOR $TOOL (present; --dry-run will not run it)"
|
||||||
|
else
|
||||||
|
echo "EXTRACTOR not present (${TOOL:-unset}) — --dry-run does not need it"
|
||||||
|
fi
|
||||||
|
echo "PLANNED reference.spk ${SPK_BYTES} B, reference.rvq $(rvq_bytes_for "$wav_duration") B"
|
||||||
|
else
|
||||||
|
[ -n "$TOOL" ] && [ -x "$TOOL" ] ||
|
||||||
|
die "speaker extractor missing or not executable: ${TOOL:-VOICE_PACK_TOOL unset} (use --dry-run to build everything up to this point)"
|
||||||
|
[ -f "$CODEC_MODEL" ] || die "codec model missing: $CODEC_MODEL"
|
||||||
|
[ -f "$TALKER_MODEL" ] || die "talker model missing: $TALKER_MODEL"
|
||||||
|
# The extractor takes the WAV and writes its outputs next to it with the
|
||||||
|
# extension swapped — there is no -o, so the staged name decides where
|
||||||
|
# the artifacts land.
|
||||||
|
local extractor=("$TOOL" --model "$CODEC_MODEL" --talker "$TALKER_MODEL" -i "$stage_wav")
|
||||||
|
if [ -n "$DEVICE" ]; then
|
||||||
|
extractor+=(--device "$DEVICE")
|
||||||
|
fi
|
||||||
|
echo "EXTRACT $(basename "$TOOL") at $ENGINE_COMMIT (device ${DEVICE:-auto}, $THREADS threads)"
|
||||||
|
if ! nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
|
||||||
|
"${extractor[@]}" 2>&1 | tee "$stage_log" >&2; then
|
||||||
|
die "$TOOL failed on $stage_wav (its output is in $stage_log); the pack was not published"
|
||||||
|
fi
|
||||||
|
[ -f "$spk_file" ] || die "$TOOL wrote no $spk_file (see $stage_log)"
|
||||||
|
[ -f "$rvq_file" ] || die "$TOOL wrote no $rvq_file (see $stage_log)"
|
||||||
|
spk_bytes="$(stat -c%s "$spk_file")"
|
||||||
|
rvq_bytes="$(stat -c%s "$rvq_file")"
|
||||||
|
local expect_rvq
|
||||||
|
expect_rvq="$(rvq_bytes_for "$wav_duration")"
|
||||||
|
[ "$spk_bytes" = "$SPK_BYTES" ] ||
|
||||||
|
die "reference.spk is $spk_bytes B, expected ${SPK_BYTES} B (enc_dim x f32). The engine changed its speaker encoder — see [[Voice Packs]] before shipping this pack"
|
||||||
|
[ "$rvq_bytes" = "$expect_rvq" ] ||
|
||||||
|
die "reference.rvq is $rvq_bytes B, expected ${expect_rvq} B for a ${wav_duration} s crop (22 B per 12.5 Hz frame)"
|
||||||
|
if ! fcmp "$rvq_bytes" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$rvq_bytes" '<=' "$RVQ_MAX_BYTES"; then
|
||||||
|
die "reference.rvq is $rvq_bytes B, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B — an engine bump? see [[Voice Packs]]"
|
||||||
|
fi
|
||||||
|
produced=true
|
||||||
|
echo "ARTIFACTS reference.spk ${spk_bytes} B, reference.rvq ${rvq_bytes} B"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local built stage_manifest
|
||||||
|
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
stage_manifest="$stage_dir/staging-manifest.json"
|
||||||
|
jq -n \
|
||||||
|
--arg voice "$voice" \
|
||||||
|
--arg built "$built" \
|
||||||
|
--arg builder "scripts/build-voice-pack.sh" \
|
||||||
|
--arg env_file "$ENV_FILE" \
|
||||||
|
--arg src "$source" \
|
||||||
|
--arg src_sha "$source_sha" \
|
||||||
|
--argjson src_bytes "$source_bytes" \
|
||||||
|
--argjson src_duration "$source_duration" \
|
||||||
|
--argjson offset "$offset" \
|
||||||
|
--argjson end "$crop_end" \
|
||||||
|
--argjson duration "$duration" \
|
||||||
|
--argjson forbidden "$SOURCE_FORBIDDEN_SECONDS" \
|
||||||
|
--argjson rate "$SAMPLE_RATE" \
|
||||||
|
--argjson wav_duration "$wav_duration" \
|
||||||
|
--arg wav_codec "$wav_codec" \
|
||||||
|
--argjson wav_channels "$wav_channels" \
|
||||||
|
--argjson wav_bytes "$(stat -c%s "$stage_wav")" \
|
||||||
|
--arg tool "${TOOL:-unset}" \
|
||||||
|
--arg repo "$ENGINE_REPO" \
|
||||||
|
--arg commit "$ENGINE_COMMIT" \
|
||||||
|
--arg codec "$CODEC_MODEL" \
|
||||||
|
--arg talker "$TALKER_MODEL" \
|
||||||
|
--arg device "$DEVICE" \
|
||||||
|
--argjson produced "$produced" \
|
||||||
|
--argjson spk_bytes "$spk_bytes" \
|
||||||
|
--argjson rvq_bytes "$rvq_bytes" \
|
||||||
|
--argjson planned_rvq "$(rvq_bytes_for "$wav_duration")" \
|
||||||
|
--argjson dry_run "$DRY_RUN" \
|
||||||
|
'{
|
||||||
|
voice: $voice,
|
||||||
|
kind: "voice-pack",
|
||||||
|
built_utc: $built,
|
||||||
|
builder: $builder,
|
||||||
|
tunables: $env_file,
|
||||||
|
dry_run: ($dry_run == 1),
|
||||||
|
source: {
|
||||||
|
path: $src,
|
||||||
|
sha256: $src_sha,
|
||||||
|
bytes: $src_bytes,
|
||||||
|
duration_seconds: $src_duration
|
||||||
|
},
|
||||||
|
crop: {
|
||||||
|
offset_seconds: $offset,
|
||||||
|
end_seconds: $end,
|
||||||
|
duration_seconds: $duration
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
file: "reference.wav",
|
||||||
|
codec: $wav_codec,
|
||||||
|
sample_rate: $rate,
|
||||||
|
channels: $wav_channels,
|
||||||
|
duration_seconds: $wav_duration,
|
||||||
|
bytes: $wav_bytes
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
produced: $produced,
|
||||||
|
tool: $tool,
|
||||||
|
engine_repo: $repo,
|
||||||
|
engine_commit: $commit,
|
||||||
|
device: $device,
|
||||||
|
codec_model: $codec,
|
||||||
|
talker_model: $talker,
|
||||||
|
spk: {file: "reference.spk", bytes: $spk_bytes},
|
||||||
|
rvq: {file: "reference.rvq", bytes: $rvq_bytes, planned_bytes: $planned_rvq}
|
||||||
|
},
|
||||||
|
invariant: {
|
||||||
|
name: "source-material invariant",
|
||||||
|
rule: "a voice crop MUST start past the first 10 seconds of the source file",
|
||||||
|
forbidden_source_seconds: $forbidden,
|
||||||
|
crop_offset_seconds: $offset
|
||||||
|
}
|
||||||
|
}' >"$stage_manifest"
|
||||||
|
|
||||||
|
# Assert the gate a second time, FROM the manifest, next to the bytes it
|
||||||
|
# describes. Anything short of a pass leaves the staged pack unpublished.
|
||||||
|
local recorded_offset recorded_sha
|
||||||
|
recorded_offset="$(jq -r '.crop.offset_seconds' "$stage_manifest")"
|
||||||
|
recorded_sha="$(jq -r '.source.sha256' "$stage_manifest")"
|
||||||
|
jq -e --argjson min "$SOURCE_FORBIDDEN_SECONDS" \
|
||||||
|
'(.crop.offset_seconds | type == "number") and .invariant.forbidden_source_seconds == $min and (.crop.offset_seconds >= $min) and (.invariant.name == "source-material invariant")' \
|
||||||
|
"$stage_manifest" >/dev/null ||
|
||||||
|
die "manifest assertion failed: .crop.offset_seconds ($recorded_offset) does not clear the ${SOURCE_FORBIDDEN_SECONDS} s source-material invariant"
|
||||||
|
[ "$recorded_sha" = "$source_sha" ] ||
|
||||||
|
die "manifest assertion failed: .source.sha256 ($recorded_sha) is not the hash of $source ($source_sha)"
|
||||||
|
jq -e '(.voice | type == "string") and (.reference.sample_rate | type == "number") and (.reference.duration_seconds | type == "number") and (.artifacts.engine_commit | length == 40)' \
|
||||||
|
"$stage_manifest" >/dev/null ||
|
||||||
|
die "manifest assertion failed: voice / reference / engine provenance fields are not all present"
|
||||||
|
echo "MANIFEST crop offset $recorded_offset s clears the ${SOURCE_FORBIDDEN_SECONDS} s invariant"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "MANIFEST_BEGIN"
|
||||||
|
cat "$stage_manifest"
|
||||||
|
echo "MANIFEST_END"
|
||||||
|
echo "PLAN would write $pack_dir/{reference.wav,reference.spk,reference.rvq,manifest.json}"
|
||||||
|
echo "DRYRUN $voice: inputs valid, crop cut and probed, extractor not run"
|
||||||
|
else
|
||||||
|
# reference.wav is the gate the shim reads, so it lands last: the voice
|
||||||
|
# name never exists on disk in a state the artifacts are not behind it.
|
||||||
|
mv "$stage_log" "$pack_dir/qwen-codec.log"
|
||||||
|
mv "$spk_file" "$pack_dir/reference.spk"
|
||||||
|
mv "$rvq_file" "$pack_dir/reference.rvq"
|
||||||
|
mv "$stage_manifest" "$pack_dir/manifest.json"
|
||||||
|
mv "$stage_wav" "$pack_dir/reference.wav"
|
||||||
|
echo "BUILT $pack_dir (voice '$voice': ${wav_duration} s, spk ${spk_bytes} B, rvq ${rvq_bytes} B)"
|
||||||
|
echo "VOICE_PACK_BUILT $voice $pack_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$BUILD_ALL" -eq 1 ]; then
|
||||||
|
[ -z "$VOICE" ] || die "--all takes no voice name (got '$VOICE'); the list comes from VOICE_PACKS"
|
||||||
|
for flag in "$ARG_SOURCE" "$ARG_OFFSET" "$ARG_DURATION" "$ARG_SHA"; do
|
||||||
|
[ -z "$flag" ] || die "--all reads every crop from $ENV_FILE; --source/--offset/--duration/--sha256 would apply one value to all of them"
|
||||||
|
done
|
||||||
|
read -r -a voices <<<"$VOICE_PACKS"
|
||||||
|
[ "${#voices[@]}" -gt 0 ] || die "VOICE_PACKS is empty in $ENV_FILE"
|
||||||
|
for voice in "${voices[@]}"; do
|
||||||
|
build_voice "$voice"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
[ -n "$VOICE" ] || { usage >&2; die "no voice name given"; }
|
||||||
|
build_voice "$VOICE"
|
||||||
|
fi
|
||||||
@@ -66,6 +66,7 @@ SERVICE_ORDER = (
|
|||||||
# a dependency of the 7772 facade, so it is planned (and therefore
|
# a dependency of the 7772 facade, so it is planned (and therefore
|
||||||
# restarted) first.
|
# restarted) first.
|
||||||
"frank-tts-engine",
|
"frank-tts-engine",
|
||||||
|
"frank-tts",
|
||||||
"frank-dragoon",
|
"frank-dragoon",
|
||||||
"frank-zealot",
|
"frank-zealot",
|
||||||
"frank-archon",
|
"frank-archon",
|
||||||
@@ -102,6 +103,7 @@ DANKCLAW_SERVICES = {
|
|||||||
"frank-dragoon",
|
"frank-dragoon",
|
||||||
"frank-zealot",
|
"frank-zealot",
|
||||||
"frank-tts-engine",
|
"frank-tts-engine",
|
||||||
|
"frank-tts",
|
||||||
}
|
}
|
||||||
|
|
||||||
DANKSERVER_SERVICES = {
|
DANKSERVER_SERVICES = {
|
||||||
@@ -342,6 +344,15 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
"Containerfile.engine", "tts-engine-start.sh",
|
"Containerfile.engine", "tts-engine-start.sh",
|
||||||
"tts-engine-warm.sh"}:
|
"tts-engine-warm.sh"}:
|
||||||
_add(services, "frank-tts-engine")
|
_add(services, "frank-tts-engine")
|
||||||
|
# The 7772 façade is the other deployable in this directory. The
|
||||||
|
# bridge file is named after the ENGINE unit (install_quadlet copies
|
||||||
|
# `$unit.network` beside `$unit.container`), yet both units attach
|
||||||
|
# to it, so a subnet change is a change to both. Anything else here
|
||||||
|
# belongs to neither deployable and plans nothing.
|
||||||
|
if parts[-1] in {"frank-tts.container", "app.py", "Containerfile"}:
|
||||||
|
_add(services, "frank-tts")
|
||||||
|
if parts[-1] == "frank-tts-engine.network":
|
||||||
|
_add(services, "frank-tts")
|
||||||
return services
|
return services
|
||||||
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
||||||
# build, root unit): its files live in scripts/ and map there.
|
# build, root unit): its files live in scripts/ and map there.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ service_is_allowed() {
|
|||||||
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
||||||
dankclaw:open-notebook|\
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankclaw:frank-tts-engine|\
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||||
@@ -541,6 +541,14 @@ deploy_observability() {
|
|||||||
|
|
||||||
build_compose_image() {
|
build_compose_image() {
|
||||||
local service="$1" image cfhash oldhash containerfile
|
local service="$1" image cfhash oldhash containerfile
|
||||||
|
# Every file the image COPIES IN, beside the Containerfile. The skip below
|
||||||
|
# compares a hash against the image's `cfhash` label, so anything the build
|
||||||
|
# bakes must be in that hash or the deploy silently keeps serving the old
|
||||||
|
# bytes: an edited app.py or warm hook leaves the Containerfile untouched,
|
||||||
|
# the label matches, `podman build` never runs, and the health gate passes on
|
||||||
|
# a stale container. Services whose Containerfile pulls everything at
|
||||||
|
# build time (comfyui, loki, alloy) keep an empty list.
|
||||||
|
local -a extra=()
|
||||||
case "$service" in
|
case "$service" in
|
||||||
comfyui)
|
comfyui)
|
||||||
image=comfyui-rocm:gfx1151
|
image=comfyui-rocm:gfx1151
|
||||||
@@ -550,6 +558,15 @@ build_compose_image() {
|
|||||||
image="franksdank/$service:latest"
|
image="franksdank/$service:latest"
|
||||||
containerfile="dankclaw/$service/Containerfile"
|
containerfile="dankclaw/$service/Containerfile"
|
||||||
;;
|
;;
|
||||||
|
frank-tts)
|
||||||
|
# The 7772 façade: stdlib python + ffmpeg, app.py copied in. Listed
|
||||||
|
# explicitly because build_compose_image's `*)` arm returns 0 — an
|
||||||
|
# unlisted service builds nothing and the unit's Pull=never then
|
||||||
|
# fails on a tag that was never made.
|
||||||
|
image=franksdank/frank-tts:latest
|
||||||
|
containerfile=dankclaw/voiceservice/Containerfile
|
||||||
|
extra=(dankclaw/voiceservice/app.py)
|
||||||
|
;;
|
||||||
frank-tts-engine)
|
frank-tts-engine)
|
||||||
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
|
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
|
||||||
# source at a pinned upstream commit, so the build needs network
|
# source at a pinned upstream commit, so the build needs network
|
||||||
@@ -557,12 +574,16 @@ build_compose_image() {
|
|||||||
# offline: the clone only happens when this Containerfile changes.
|
# offline: the clone only happens when this Containerfile changes.
|
||||||
image=franksdank/frank-tts-engine:latest
|
image=franksdank/frank-tts-engine:latest
|
||||||
containerfile=dankclaw/voiceservice/Containerfile.engine
|
containerfile=dankclaw/voiceservice/Containerfile.engine
|
||||||
|
# start.sh is the image's CMD and the warmer is the unit's
|
||||||
|
# ExecStartPost; both are COPY'd in, so both decide what this image is.
|
||||||
|
extra=(dankclaw/voiceservice/tts-engine-start.sh
|
||||||
|
dankclaw/voiceservice/tts-engine-warm.sh)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
cfhash="$(sha256sum "$containerfile" | cut -d' ' -f1)"
|
cfhash="$(sha256sum "$containerfile" "${extra[@]}" | cut -d' ' -f1 | tr -d '\n')"
|
||||||
oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
|
oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
|
||||||
if [[ "$oldhash" != "$cfhash" ]]; then
|
if [[ "$oldhash" != "$cfhash" ]]; then
|
||||||
# --format docker is required, not cosmetic: podman defaults to the OCI
|
# --format docker is required, not cosmetic: podman defaults to the OCI
|
||||||
@@ -801,6 +822,43 @@ deploy_frank_tts_engine() {
|
|||||||
podman exec frank-tts-engine /app/warm-voices.sh
|
podman exec frank-tts-engine /app/warm-voices.sh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deploy_frank_tts() {
|
||||||
|
# The 7772 façade in front of frank-tts-engine. Weights and the engine build
|
||||||
|
# are not this handler's business, but the BRIDGE FILE is: this unit
|
||||||
|
# references frank-tts-engine.network, and with a referenced Network= file
|
||||||
|
# absent the v1 generator refuses to produce the container unit at all. So
|
||||||
|
# `--only frank-tts` installs the engine's quadlet pair too — idempotent, and
|
||||||
|
# it is what makes the façade deployable on a host where the engine was
|
||||||
|
# planned separately or not at all.
|
||||||
|
#
|
||||||
|
# The voice packs are host state (the pack builder writes them), but the
|
||||||
|
# directory is created here as the deploy user on purpose: rootless podman
|
||||||
|
# would otherwise auto-create a missing bind source as root-owned, and the
|
||||||
|
# container runs as uid 10000.
|
||||||
|
mkdir -p /home/dank/voices
|
||||||
|
|
||||||
|
build_compose_image frank-tts
|
||||||
|
install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine
|
||||||
|
install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts
|
||||||
|
systemctl --user start frank-tts-engine-network.service
|
||||||
|
|
||||||
|
# No `systemctl --user enable` (generated Quadlet units cannot be enabled on
|
||||||
|
# this host); boot persistence is the [Install] section in each unit. The
|
||||||
|
# façade unit carries Wants=/After= on frank-tts-engine.service, so this
|
||||||
|
# restart pulls the engine — and its ExecStartPost warm pass — ahead of it:
|
||||||
|
# an engine that will not start delays this gate instead of taking the
|
||||||
|
# endpoint down with it.
|
||||||
|
systemctl --user restart frank-tts.service
|
||||||
|
# The gate is podman's recorded health, driven by the unit's own HealthCmd
|
||||||
|
# (`wget --spider -q http://127.0.0.1:7772/health`, inside the container's
|
||||||
|
# namespace). /health answers 200 with the backend name and the resolved
|
||||||
|
# voice list, so an empty /voices still reads healthy while a dead app does
|
||||||
|
# not — and note what this gate cannot see: the engine publishes no port on
|
||||||
|
# purpose, so an engine that is down shows up in the /health payload and in
|
||||||
|
# the first synthesis, never here.
|
||||||
|
wait_container_healthy frank-tts 120
|
||||||
|
}
|
||||||
|
|
||||||
deploy_comfyui() {
|
deploy_comfyui() {
|
||||||
# Build first: the unit's Image= resolves against local storage, so the
|
# Build first: the unit's Image= resolves against local storage, so the
|
||||||
# image has to exist before the generator starts a container from it.
|
# image has to exist before the generator starts a container from it.
|
||||||
@@ -1748,6 +1806,7 @@ deploy_service() {
|
|||||||
frank-zealot) deploy_llm_zealot ;;
|
frank-zealot) deploy_llm_zealot ;;
|
||||||
frank-archon) deploy_llm_archon ;;
|
frank-archon) deploy_llm_archon ;;
|
||||||
frank-tts-engine) deploy_frank_tts_engine ;;
|
frank-tts-engine) deploy_frank_tts_engine ;;
|
||||||
|
frank-tts) deploy_frank_tts ;;
|
||||||
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
|
||||||
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
|
||||||
jellyfin) deploy_jellyfin ;;
|
jellyfin) deploy_jellyfin ;;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ valid_service() {
|
|||||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
||||||
dankclaw:open-notebook|\
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-tts-engine|\
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
|||||||
@@ -126,8 +126,20 @@ HEALTH_WAIT=300
|
|||||||
# second (warm-timing) pass repeats. Fixed on purpose: the cold=/warm= numbers in
|
# second (warm-timing) pass repeats. Fixed on purpose: the cold=/warm= numbers in
|
||||||
# the journal are comparable across boots only if the input is the same. Kept
|
# the journal are comparable across boots only if the input is the same. Kept
|
||||||
# short because warming a library is serial — one cold decode per voice, 6-8 s
|
# short because warming a library is serial — one cold decode per voice, 6-8 s
|
||||||
# each, inside the unit's start deadline. Quoted because podman's env-file parser
|
# each, inside the unit's start deadline.
|
||||||
# and a shell `source` must both read the sentence as ONE value.
|
# QUOTED, and the quoting means two different things to the two readers of this
|
||||||
|
# file, which is the trap here:
|
||||||
|
# * bash (`set -a; source`, how the repo's seams and any human read it) needs
|
||||||
|
# the quotes — an unquoted value with spaces is word-split and the second
|
||||||
|
# word is executed as a command.
|
||||||
|
# * podman's `--env-file` (how the unit actually reads it) does NOT strip
|
||||||
|
# quotes: measured 2026-10-18 against the image's pinned base, the value
|
||||||
|
# arrives as `"Warm up, one two three."` WITH them. Interpolated into the
|
||||||
|
# hook's JSON body that is `""Warm up…""`, the engine's parser rejects it,
|
||||||
|
# every voice stays cold and the boot still looks healthy.
|
||||||
|
# So the value stays quoted for bash and the hook strips one matched pair before
|
||||||
|
# it builds the request, then escapes what is left. Both readers now agree, and a
|
||||||
|
# sentence containing a quote or a backslash still arrives verbatim.
|
||||||
WARM_TEXT="Warm up, one two three."
|
WARM_TEXT="Warm up, one two three."
|
||||||
|
|
||||||
# Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is
|
# Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is
|
||||||
|
|||||||
+17
-8
@@ -30,21 +30,30 @@
|
|||||||
#
|
#
|
||||||
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
|
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
|
||||||
# consumer:
|
# consumer:
|
||||||
# omni-http forward to an OpenAI-shaped upstream on the host (:8880),
|
# omni-http forward to an OpenAI-shaped upstream, carrying the voice along
|
||||||
# carrying the voice along as base64 reference_audio. The
|
# as base64 reference_audio. The engine does the inference; the
|
||||||
# engine does the inference; the shim resolves the voice and
|
# shim resolves the voice and re-encodes to mp3 when asked. The
|
||||||
# re-encodes to mp3 when asked.
|
# shipped engine (frank-tts-engine) speaks a voice it has
|
||||||
|
# REGISTERED on itself, from the same /home/dank/voices packs,
|
||||||
|
# and warms them at boot — see [[Frank TTS Engine]] for why an
|
||||||
|
# unwarmed voice costs the first caller 5.5 s.
|
||||||
# llama-tts-cli run LLAMA_TTS_BIN once per request with
|
# llama-tts-cli run LLAMA_TTS_BIN once per request with
|
||||||
# --tts-speaker-file <reference.wav>, nice 19 so a narration
|
# --tts-speaker-file <reference.wav>, nice 19 so a narration
|
||||||
# never starves the text endpoints on this box. Needs
|
# never starves the text endpoints on this box. Needs
|
||||||
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
|
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
|
||||||
# only if that binary takes a projector.
|
# only if that binary takes a projector.
|
||||||
# The upstream address is host.containers.internal — the gateway name a bridge
|
|
||||||
# container uses for the host. It is NOT the LAN IP and it is NOT
|
# THE UPSTREAM ADDRESS. The engine is a container on the private bridge this
|
||||||
# 127.0.0.1: a bridge netns has neither.
|
# unit also joins (dankclaw/voiceservice/quadlet/frank-tts-engine.network), so
|
||||||
|
# this is its container DNS name. It used to read
|
||||||
|
# `http://host.containers.internal:8880`, which is a shape that cannot work: a
|
||||||
|
# bridge container reaches the host's network namespace, netavark DNATs PUBLISHED
|
||||||
|
# ports only, and an engine on the host's loopback is published to nobody
|
||||||
|
# ([[Container Network Namespaces]] — the trap that 502'd hermes). Inert under
|
||||||
|
# TTS_BACKEND=llama-tts-cli, which needs no upstream at all.
|
||||||
VOICES_DIR=/voices
|
VOICES_DIR=/voices
|
||||||
TTS_BACKEND=omni-http
|
TTS_BACKEND=omni-http
|
||||||
TTS_UPSTREAM=http://host.containers.internal:8880
|
TTS_UPSTREAM=http://frank-tts-engine:8880
|
||||||
TTS_UPSTREAM_MODEL=voxcpm2
|
TTS_UPSTREAM_MODEL=voxcpm2
|
||||||
|
|
||||||
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ for path in "${CHANGED[@]}"; do
|
|||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
;;
|
;;
|
||||||
|
# The voice-pack builder is the only thing that enforces the 10-second
|
||||||
|
# source-material invariant and the artifact sizes the packs are pinned
|
||||||
|
# to, so it selects its own seam rather than landing on the generic
|
||||||
|
# scripts/* syntax fallback. This case has to precede that fallback.
|
||||||
|
scripts/build-voice-pack.sh|scripts/voice-packs.env)
|
||||||
|
add_test tests/voice_pack_unit.bats
|
||||||
|
add_test tests/scripts_syntax.bats
|
||||||
|
;;
|
||||||
scripts/*)
|
scripts/*)
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# voice-packs.env — tunables for the **frank-tts voice packs**: the three
|
||||||
|
# numbers per voice (source file, crop offset, crop length) plus the pinned
|
||||||
|
# engine facts that turn a crop into a pack. Read by
|
||||||
|
# `scripts/build-voice-pack.sh`, which is the only thing that consumes this
|
||||||
|
# file — the running service reads none of it, because the packs themselves are
|
||||||
|
# the interface (see scripts/frank-tts.env for what the shim resolves).
|
||||||
|
#
|
||||||
|
# WHAT IS TRACKED HERE AND WHAT IS NOT. Paths and numbers, never credentials,
|
||||||
|
# which is the same rule the other `scripts/*.env` files follow. The pack
|
||||||
|
# DIRECTORIES are host state under $VOICE_PACK_DIR and stay out of the repo; the
|
||||||
|
# crops that produce them are exactly the kind of decision worth a diff, so the
|
||||||
|
# offsets live here. If a crop changes, change it here and rebuild with
|
||||||
|
# --force — the manifest inside the pack records what was actually cut.
|
||||||
|
#
|
||||||
|
# THE OFFSETS ARE NOT ARBITRARY. Two rules pick them:
|
||||||
|
# 1. The source-material invariant: seconds 0-10 of any sample file are
|
||||||
|
# forbidden voice material. The builder refuses a crop that starts inside
|
||||||
|
# that region and asserts the same thing again from the manifest it just
|
||||||
|
# wrote, so the values below are checked, not trusted.
|
||||||
|
# 2. Speech density: the trial cut these from a silencedetect scan of the
|
||||||
|
# source (noise -35 dB, 0.6 s) and took the longest run of continuous
|
||||||
|
# speech at or past the scan window — a reference that starts mid-word or
|
||||||
|
# contains a page-turn of silence clones the artefacts, not the voice.
|
||||||
|
# serverAppConfigs-docs/Dankclaw/Voice Packs.md carries the recipe.
|
||||||
|
#
|
||||||
|
# WHY THE SHA256 LINES EXIST. A crop offset is a statement about one exact
|
||||||
|
# file: 98.739 s into a re-encode of the same audiobook is a different sentence
|
||||||
|
# and therefore a different voice. The builder hashes the source, compares it
|
||||||
|
# with the pinned digest when one is set, and writes the digest into the pack's
|
||||||
|
# manifest — so a pack can always be traced back to the bytes it was cut from.
|
||||||
|
# The three values below are the trial's digests (2026-10-18), verified against
|
||||||
|
# the files on disk.
|
||||||
|
#
|
||||||
|
# THE ENGINE BLOCK IS A PIN, NOT A DEFAULT. The speaker artifacts come from
|
||||||
|
# `qwen-codec` in the qwentts.cpp tree at $VOICE_PACK_ENGINE_COMMIT: it takes a
|
||||||
|
# 24 kHz WAV, writes the RVQ code stream next to it with the extension swapped,
|
||||||
|
# and with --talker also runs that talker's speaker encoder to write the
|
||||||
|
# x-vector .spk. The talker has to be a **Base** build — the customvoice and
|
||||||
|
# voicedesign GGUFs carry no speaker encoder and the extractor says so. The
|
||||||
|
# sizes the builder then verifies (8,192 B of .spk, 5,456-6,864 B of .rvq) are
|
||||||
|
# derived from the codec config in the GGUF, so an engine bump that changes them
|
||||||
|
# fails the build on purpose; see [[Voice Packs]] for the bump procedure.
|
||||||
|
#
|
||||||
|
# HOST PATHS, NOT CONTAINER PATHS. This runs on the host that holds the engine
|
||||||
|
# build (Dankclaw), outside any container, with no GPU and no fleet endpoint.
|
||||||
|
# The model paths below are where scripts/download-frank-tts-engine-qwentts.sh
|
||||||
|
# puts the GGUFs; until that fetch has run, the same two files sit in the trial
|
||||||
|
# tree at /home/dank/voice-audition/qwentts/models/ and the tool is only in its
|
||||||
|
# build directory. The SOURCE paths are the third thing to place: the three
|
||||||
|
# sample files live on the workstation today and have to be copied to
|
||||||
|
# $VOICE_PACK_SOURCE_DIR on Dankclaw before a build can read them.
|
||||||
|
|
||||||
|
# The packs root on the host: the directory frank-tts mounts read-only at
|
||||||
|
# /voices, one sub-directory per voice.
|
||||||
|
VOICE_PACK_DIR=/home/dank/voices
|
||||||
|
|
||||||
|
# The pack list. `--all` walks it in order; every name has to carry the four
|
||||||
|
# per-voice values below.
|
||||||
|
VOICE_PACKS="narrator1 narrator2 narrator3"
|
||||||
|
|
||||||
|
# Where the source recordings live on the engine host.
|
||||||
|
VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources
|
||||||
|
|
||||||
|
# narrator1 — Randall Munroe "How To", read by Wil Wheaton. Longest speech run
|
||||||
|
# at or past the scan window: 98.739-118.636 s, 19.897 s, which packs to the
|
||||||
|
# 5,456 B floor of the .rvq window.
|
||||||
|
VOICE_PACK_NARRATOR1_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample.mp3
|
||||||
|
VOICE_PACK_NARRATOR1_OFFSET=98.739
|
||||||
|
VOICE_PACK_NARRATOR1_DURATION=19.897
|
||||||
|
VOICE_PACK_NARRATOR1_SHA256=2299510467eb9143455d8c1e6a21601c3b451dd59c1dc99b99d4aacb54d1d850
|
||||||
|
|
||||||
|
# narrator2 — "Abyssal Archive" (Lokey). 25 s is the ceiling of the reference
|
||||||
|
# window and packs to exactly 6,864 B of .rvq.
|
||||||
|
VOICE_PACK_NARRATOR2_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample2.m4b
|
||||||
|
VOICE_PACK_NARRATOR2_OFFSET=191.13
|
||||||
|
VOICE_PACK_NARRATOR2_DURATION=25
|
||||||
|
VOICE_PACK_NARRATOR2_SHA256=8e1e33317baf67b2cca67e4ce1b095ae477a18285c7373ec18ad013f2cc320e0
|
||||||
|
|
||||||
|
# narrator3 — "Children of Time" (Adrian Tchaikovsky, author-read).
|
||||||
|
VOICE_PACK_NARRATOR3_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample3.m4b
|
||||||
|
VOICE_PACK_NARRATOR3_OFFSET=489.232
|
||||||
|
VOICE_PACK_NARRATOR3_DURATION=25
|
||||||
|
VOICE_PACK_NARRATOR3_SHA256=6875d2c1db421ab96ccbb68855fa9efd4e054297e94dd1b4345eb6a9869da945
|
||||||
|
|
||||||
|
# The pinned extractor and the two GGUFs it needs. $VOICE_PACK_TOOL is a build
|
||||||
|
# output, not a download: rebuild it at $VOICE_PACK_ENGINE_COMMIT after a bump
|
||||||
|
# rather than pointing this line at a newer tree.
|
||||||
|
VOICE_PACK_TOOL=/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec
|
||||||
|
VOICE_PACK_CODEC_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-tokenizer-12hz-Q8_0.gguf
|
||||||
|
VOICE_PACK_TALKER_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-talker-1.7b-base-Q4_K_M.gguf
|
||||||
|
VOICE_PACK_ENGINE_REPO=https://github.com/ServeurpersoCom/qwentts.cpp
|
||||||
|
VOICE_PACK_ENGINE_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
|
||||||
|
|
||||||
|
# CPU only, and never in the way of the text endpoints on this box: ggml thread
|
||||||
|
# count and `nice -n`. The extractor picks the best backend it is given, so the
|
||||||
|
# device is stated rather than guessed.
|
||||||
|
VOICE_PACK_DEVICE=CPU
|
||||||
|
VOICE_PACK_THREADS=8
|
||||||
|
VOICE_PACK_NICE=19
|
||||||
|
|
||||||
|
# The reference window and the shape the packs are cut to. 24 kHz mono PCM is
|
||||||
|
# the codec's own input rate, so no resampling happens inside the engine.
|
||||||
|
VOICE_PACK_SAMPLE_RATE=24000
|
||||||
|
VOICE_PACK_MIN_SECONDS=18
|
||||||
|
VOICE_PACK_MAX_SECONDS=25
|
||||||
|
|
||||||
|
# Artifact arithmetic, all four values read out of the pinned tree: the codec
|
||||||
|
# runs at 12.5 Hz (1,920 samples per frame at 24 kHz), packs 16 codebooks at 11
|
||||||
|
# bits = 22 bytes per frame, and the Base talker's speaker encoder emits
|
||||||
|
# enc_dim 2048 f32 = 8,192 bytes. The .rvq window is 22 x 248 = 5,456 B up to
|
||||||
|
# 22 x 312 = 6,864 B, i.e. crops of 19.84-25 s: an 18 s crop is a legal
|
||||||
|
# reference.wav but too little code for the engine, and the builder says so
|
||||||
|
# before it runs anything.
|
||||||
|
VOICE_PACK_FRAME_RATE=12.5
|
||||||
|
VOICE_PACK_RVQ_BYTES_PER_FRAME=22
|
||||||
|
VOICE_PACK_SPK_BYTES=8192
|
||||||
|
VOICE_PACK_RVQ_MIN_BYTES=5456
|
||||||
|
VOICE_PACK_RVQ_MAX_BYTES=6864
|
||||||
|
|
||||||
|
# Where a --force rebuild puts the pack it replaced. Nothing is ever deleted;
|
||||||
|
# the retired directory stays readable beside its replacement until a human
|
||||||
|
# clears it.
|
||||||
|
VOICE_PACK_TRASH_DIRNAME=.AGENTTRASH
|
||||||
@@ -81,7 +81,7 @@ The packs themselves are host state at `/home/dank/voices/<name>/reference.wav`
|
|||||||
|
|
||||||
## Status visibility
|
## Status visibility
|
||||||
|
|
||||||
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). Its up-state is visible in four places, and the runbook uses all of them:
|
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). The pair's public signal is the endpoint in front of it: [[Frank TTS]] carries the card and is what a consumer can dial. Its up-state is visible in four places, and the runbook uses all of them:
|
||||||
|
|
||||||
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
|
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
|
||||||
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
|
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
|
||||||
@@ -109,9 +109,9 @@ The **façade** on 7772 is the pair's public surface and carries the probe. Note
|
|||||||
Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblocks the podcast pipeline.
|
Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblocks the podcast pipeline.
|
||||||
|
|
||||||
1. **Fetch the weights** — `scripts/download-frank-tts-engine-qwentts.sh` (1.4 GiB, byte + sha256 verified, idempotent; `--verify-only` is what the deployer calls).
|
1. **Fetch the weights** — `scripts/download-frank-tts-engine-qwentts.sh` (1.4 GiB, byte + sha256 verified, idempotent; `--verify-only` is what the deployer calls).
|
||||||
2. **Deploy the engine** — `./scripts/deploy.sh dankclaw frank-tts-engine`. Builds the image (needs github.com once), installs the unit and the network file, restarts, gates on the container's health status, then warms.
|
2. **Deploy the engine** — `./scripts/deploy.sh dankclaw --service frank-tts-engine` (`--service` is the only way to name one service; `deploy-remote.sh` is the remote half and takes `<target> <git-revision> [service]...`, so never call it by hand with a bare service name). Builds the image (needs github.com once), installs the unit and the network file, restarts, gates on the container's health status, then warms.
|
||||||
3. **Confirm the endpoint is alive** — `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` → `{"status":"ok"}`, and `systemctl --user status frank-tts-engine` showing the unit `active` with the warm pass behind it.
|
3. **Confirm the endpoint is alive** — `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` → `{"status":"ok"}`, and `systemctl --user status frank-tts-engine` showing the unit `active` with the warm pass behind it.
|
||||||
4. **Confirm the voices are known** — the warmer's journal lines must name every pack; once the façade is up, `curl http://127.0.0.1:7772/v1/voices` is the authoritative list.
|
4. **Confirm the voices are known** — the warmer's journal lines must name every pack; once the façade is up, `curl http://127.0.0.1:7772/v1/audio/voices` is the authoritative list (the façade has exactly three routes: `/health`, `/v1/audio/voices`, `/v1/audio/speech`).
|
||||||
5. **Prove the cold-voice law is paid at boot, not by a listener** — two identical synthesis requests for one voice, back to back, from inside the engine's namespace; the **second** must answer well under a second (`warm=` in the journal is already that number; the manual pair is the confirmation on the real artifact).
|
5. **Prove the cold-voice law is paid at boot, not by a listener** — two identical synthesis requests for one voice, back to back, from inside the engine's namespace; the **second** must answer well under a second (`warm=` in the journal is already that number; the manual pair is the confirmation on the real artifact).
|
||||||
6. Then the façade on **7772** (its own page): nothing may be published there until the engine answers, because open-notebook's render fails the moment the façade is up and the engine is not.
|
6. Then the façade on **7772** (its own page): nothing may be published there until the engine answers, because open-notebook's render fails the moment the façade is up and the engine is not.
|
||||||
7. Nothing on 7772 exists yet — that is why open-notebook's podcast render cannot run. See [[Open Notebook]].
|
7. Nothing on 7772 exists yet — that is why open-notebook's podcast render cannot run. See [[Open Notebook]].
|
||||||
@@ -126,4 +126,6 @@ Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblock
|
|||||||
| Everything is ~2× slower after a systemd tweak | a cpuset/cap pin changed `hardware_concurrency()` and the engine resized its thread pools | remove the pin (law 2) |
|
| Everything is ~2× slower after a systemd tweak | a cpuset/cap pin changed `hardware_concurrency()` and the engine resized its thread pools | remove the pin (law 2) |
|
||||||
| `502` from the façade with `frank-tts upstream request failed` | the façade cannot resolve or reach the engine | `podman network inspect systemd-frank-tts-engine` and `podman inspect frank-tts --format '{{json .NetworkSettings.Networks}}'` — the façade must be attached to the bridge; a `127.0.0.1:8880` or `host.containers.internal:8880` upstream in `scripts/frank-tts.env` is the old shape and cannot work |
|
| `502` from the façade with `frank-tts upstream request failed` | the façade cannot resolve or reach the engine | `podman network inspect systemd-frank-tts-engine` and `podman inspect frank-tts --format '{{json .NetworkSettings.Networks}}'` — the façade must be attached to the bridge; a `127.0.0.1:8880` or `host.containers.internal:8880` upstream in `scripts/frank-tts.env` is the old shape and cannot work |
|
||||||
| A voice is missing from the list | its directory has no `reference.wav`, or the name is not lowercase | fix the pack; both are refused loudly by design |
|
| A voice is missing from the list | its directory has no `reference.wav`, or the name is not lowercase | fix the pack; both are refused loudly by design |
|
||||||
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | rebuild: `build_compose_image` skips on an unchanged `Containerfile.engine`, and the `COPY` of the warmer changed it |
|
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see [[deployment]] |
|
||||||
|
| The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this |
|
||||||
|
| A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces |
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
description: "frank-tts is the fleet's cloned-voice speech endpoint on Dankclaw :7772 — the one voice surface consumers address, in front of frank-tts-engine; repo-owned, not yet deployed."
|
||||||
|
tags:
|
||||||
|
- dankclaw
|
||||||
|
- tts
|
||||||
|
- voice
|
||||||
|
- quadlet
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frank TTS
|
||||||
|
|
||||||
|
`frank-tts` is the fleet's only speech endpoint: an OpenAI-shaped
|
||||||
|
`POST /v1/audio/speech` on **Dankclaw :7772** that answers `voice=<name>` with
|
||||||
|
audio in that cloned voice. Everything else in the voice stack sits behind it —
|
||||||
|
the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers
|
||||||
|
never learn a path.
|
||||||
|
|
||||||
|
**Nothing here is deployed.** No host was touched while this was written: no
|
||||||
|
image built, no container started, nothing listening on 7772. That is also why
|
||||||
|
[[Open Notebook]] cannot render a podcast episode yet — its pipeline calls this
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
|
||||||
|
| Port | **7772**, published (`PublishPort=7772:7772`); the family is 7770 dragoon, 7771 zealot, 7772 tts |
|
||||||
|
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
|
||||||
|
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
|
||||||
|
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
|
||||||
|
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
|
||||||
|
| Upstream | `http://frank-tts-engine:8880` (`scripts/frank-tts.env`, `TTS_UPSTREAM`) |
|
||||||
|
| Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
|
||||||
|
|
||||||
|
## Naming a voice
|
||||||
|
|
||||||
|
A voice **is** a directory name. `voice=narrator1` resolves to
|
||||||
|
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
|
||||||
|
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
|
||||||
|
shim accepts either.
|
||||||
|
|
||||||
|
The usable shape is `^[a-z0-9][a-z0-9._-]*$`: lowercase, starting with a letter
|
||||||
|
or digit, then letters, digits, `.`, `_`, `-`, at most 64 characters. No slash,
|
||||||
|
no `..`, no space, no uppercase. The lowercase requirement is where two rules
|
||||||
|
meet: `app.py` would accept `[A-Za-z0-9…]`, but `tts-server` **lowercases
|
||||||
|
names**, so a pack called `Narrator1` resolves through the façade and not
|
||||||
|
through the engine. The boot warmer therefore refuses a mixed-case directory
|
||||||
|
loudly instead of letting it half-work.
|
||||||
|
|
||||||
|
Names are user-visible: an unknown voice comes back as
|
||||||
|
`400 unknown voice 'x'; available voices: …`, and that list is the honest
|
||||||
|
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
|
||||||
|
[[Open Notebook]] is configured with exactly those three, so renaming them
|
||||||
|
breaks episode rendering, not just speech. A personal voice gets the person's
|
||||||
|
name, lowercase.
|
||||||
|
|
||||||
|
## Voice packs
|
||||||
|
|
||||||
|
One directory per voice under `/home/dank/voices`:
|
||||||
|
|
||||||
|
```
|
||||||
|
reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio
|
||||||
|
reference.spk engine speaker embedding (8 192 B)
|
||||||
|
reference.rvq reference codec codes (22 B per 12.5 Hz frame)
|
||||||
|
manifest.json source file, offsets, engine, sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
`reference.wav` is the engine-agnostic term the façade gates on; the other two
|
||||||
|
are what makes a voice reusable by this engine, and they come from the pack
|
||||||
|
builder ([[Voice Packs]]) — never by hand, never fabricated from the WAV. Two
|
||||||
|
laws apply to the
|
||||||
|
source material: offsets always start **past the first 10 seconds** of the
|
||||||
|
sample, and packs are host state that stays out of this repo. Because the
|
||||||
|
container runs as uid 10000, files must be world-readable (`644`/`755`), and the
|
||||||
|
directory must exist before the first deploy: rootless podman auto-creates a
|
||||||
|
missing bind source **root-owned**, which reads as "no voices" rather than as an
|
||||||
|
error.
|
||||||
|
|
||||||
|
Adding a voice to a running stack is a two-step thing — drop the pack, then
|
||||||
|
register **and warm** it with
|
||||||
|
`podman exec frank-tts-engine /app/warm-voices.sh` — because registering alone
|
||||||
|
does not warm it. The reason is the cold-voice law in [[Frank TTS Engine]], and
|
||||||
|
the procedure with its proof (the second request under a second) is the
|
||||||
|
`frank-tts-add-cloned-voice` skill.
|
||||||
|
|
||||||
|
## Why the upstream is a container name
|
||||||
|
|
||||||
|
`scripts/frank-tts.env` used to point at `http://host.containers.internal:8880`.
|
||||||
|
That cannot work, and it is the trap this shape exists to avoid: a bridge
|
||||||
|
container reaches the host's network namespace, netavark DNATs **published**
|
||||||
|
ports only, and an engine on the host's loopback is published to nobody — the
|
||||||
|
same shape that 502'd hermes ([[Container Network Namespaces]]). So both units
|
||||||
|
attach to one committed Quadlet network and the façade dials the engine's
|
||||||
|
container DNS name.
|
||||||
|
|
||||||
|
The consequence worth stating before an operator meets it: **the engine is not
|
||||||
|
reachable from the host at all** — not over the LAN, not over loopback. Its
|
||||||
|
health, voice registry and logs are observable only through
|
||||||
|
`podman exec frank-tts-engine …` and `systemctl --user status`. And the façade's
|
||||||
|
own `/health` answers 200 with the backend name and the voice list it resolved
|
||||||
|
without dialing the engine, so "façade healthy, engine down" is a real state: it
|
||||||
|
shows up as a failed synthesis, never as a down probe.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `dankclaw/voiceservice/app.py` | the shim: stdlib only — resolves the voice, forwards, re-encodes mp3 |
|
||||||
|
| `dankclaw/voiceservice/Containerfile` | python 3.12-alpine pinned by digest + ffmpeg, uid 10000 |
|
||||||
|
| `dankclaw/voiceservice/quadlet/frank-tts.container` | the unit: two networks, `Restart=always`, `HealthCmd`, `[Install]` |
|
||||||
|
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the shared bridge, named after the engine unit and attached by both |
|
||||||
|
| `scripts/frank-tts.env` | backend, upstream, port, limits — paths and numbers, no credentials |
|
||||||
|
| `tests/frank_tts_unit.bats` | the shim against stub engines over real sockets |
|
||||||
|
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer |
|
||||||
|
|
||||||
|
## Deploying it
|
||||||
|
|
||||||
|
`./scripts/deploy.sh dankclaw --service frank-tts` (or `--service frank-tts-engine --service frank-tts` for the pair):
|
||||||
|
builds the façade image, installs **both** Quadlet units — including the
|
||||||
|
engine's `.network` file, because a referenced `Network=` file the generator
|
||||||
|
cannot find means the façade unit is not produced at all — creates
|
||||||
|
`/home/dank/voices` as the deploy user, restarts the unit (whose `Wants=` and
|
||||||
|
`After=` pull the engine and its warm pass ahead of it) and gates on podman's
|
||||||
|
recorded health. It never runs `systemctl --user enable`; boot persistence is
|
||||||
|
the `[Install]` section in each unit. Order matters in a full deploy:
|
||||||
|
[[Frank TTS Engine]] is planned before this, and both before [[Open Notebook]].
|
||||||
|
|
||||||
|
## Not scraped, and why
|
||||||
|
|
||||||
|
`shared/prometheus/` gets **no** frank-tts job, and that is a considered
|
||||||
|
absence. The façade exports no `/metrics` — `app.py` serves `/health` plus the
|
||||||
|
speech route only — and this Prometheus 3 install refuses non-metrics content
|
||||||
|
types: [[Prometheus]] records that the existing HTML probes (`comfyui`, the two
|
||||||
|
`services` targets) sit **down** with
|
||||||
|
`received unsupported Content-Type … and no fallback_scrape_protocol specified`.
|
||||||
|
A `/health` probe job would therefore be structurally never-green, and because
|
||||||
|
`LlmEndpointDown` is scoped `up{job=~"frank-.*"} == 0`, a permanently red
|
||||||
|
`frank-tts` target would page on Signal every few minutes for an endpoint that
|
||||||
|
is fine — the frank-archon lesson pointed the other way. The engine cannot be
|
||||||
|
scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
|
||||||
|
`podman healthcheck run`, the two unit journals, and the homepage card, which is
|
||||||
|
gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause |
|
||||||
|
| --- | --- |
|
||||||
|
| `400 unknown voice 'x'; available voices: …` | the name is not a pack: case, spelling, missing `reference.wav`, or an empty/root-owned `/home/dank/voices` |
|
||||||
|
| `(none: no voice pack with a reference.wav in /voices)` | the mount, not the voices: `podman inspect frank-tts --format '{{json .Mounts}}'`, then the directory owner |
|
||||||
|
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
|
||||||
|
| First request of the day takes ~5.5 s | the warm pass did not run — cold-voice law in [[Frank TTS Engine]] |
|
||||||
|
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
description: "How a frank-tts voice pack is built on Dankclaw: the three files every pack must carry, the 10-second source-material gate scripts/build-voice-pack.sh enforces, how to add a fourth voice, and what to do when a pinned engine bump changes the extractor's output sizes."
|
||||||
|
tags:
|
||||||
|
- dankclaw
|
||||||
|
- frank-tts
|
||||||
|
- voices
|
||||||
|
- tts
|
||||||
|
timestamp: 2026-10-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Voice Packs
|
||||||
|
|
||||||
|
A **voice pack** is the unit of identity for **frank-tts**: one directory under
|
||||||
|
`/home/dank/voices`, named by the voice, carrying the reference audio that makes
|
||||||
|
the name real plus the pinned engine's own speaker artifacts and a provenance
|
||||||
|
record. `voice=narrator1` on `POST /v1/audio/speech` resolves to
|
||||||
|
`/home/dank/voices/narrator1/reference.wav` and to nothing else — a name without
|
||||||
|
that file is a 400 that lists the names that do exist, because a silent fallback
|
||||||
|
voice is how a wrong-voice endpoint stays wrong for weeks.
|
||||||
|
|
||||||
|
**Host:** Dankclaw (192.168.0.143) — **Pack root:** `/home/dank/voices`, host
|
||||||
|
state that stays out of the repo, mounted read-only at `/voices` by the façade
|
||||||
|
unit — **Builder:** `scripts/build-voice-pack.sh` with tunables in
|
||||||
|
`scripts/voice-packs.env` — **Seam:** `tests/voice_pack_unit.bats` — **Engine:**
|
||||||
|
arm A `qwentts.cpp`, decided in [[Local TTS and Voice Cloning]].
|
||||||
|
|
||||||
|
Packs are **world-readable on purpose** (`drwxr-xr-x`, owner `dank`): the façade
|
||||||
|
container runs as uid 10000 and reads the mount from outside its own uid map.
|
||||||
|
|
||||||
|
## The required files
|
||||||
|
|
||||||
|
| File | What it is | Who reads it |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| `reference.wav` | the crop: 24 kHz mono `pcm_s16le`, **18–25 s** of clean single-speaker audio | the façade (it is the existence gate for the name) and the engine's boot warmer, which base64s it and registers the voice with no transcript |
|
||||||
|
| `reference.spk` | the engine's x-vector: **8,192 B** = `enc_dim` 2048 × f32 | the engine, for the ICL/`--ref-spk` path |
|
||||||
|
| `reference.rvq` | the engine's RVQ code stream: **5,456–6,864 B**, 16 codebooks × T frames packed at 11 bits = 22 B per 12.5 Hz frame | the engine, for the ICL/`--ref-rvq` path |
|
||||||
|
| `manifest.json` | provenance: source path and sha256, crop start/end offsets, sample rate, duration, the tool and engine commit that made the artifacts, and the `voice` name | humans, and the builder itself on its second gate assertion |
|
||||||
|
|
||||||
|
`qwen-codec.log` rides along with the extractor's own record of the run; the
|
||||||
|
façade ignores anything it does not name and never deletes anything.
|
||||||
|
|
||||||
|
Division of enforcement, which is deliberate and not a gap: the **façade** gates
|
||||||
|
on `reference.wav` because that is the only engine-agnostic term (see the
|
||||||
|
voice-pack law in `scripts/frank-tts.env`); the **builder** is what refuses to
|
||||||
|
emit a pack without its engine artifacts; the **engine** registers a voice from
|
||||||
|
the WAV alone because its own `POST /v1/audio/voices` refuses a lone embedding —
|
||||||
|
it wants `wav_b64` or **both** `spk_b64` and `rvq_b64`. So the artifacts are in
|
||||||
|
the pack to make ICL a per-voice opt-in, not to boot it.
|
||||||
|
|
||||||
|
## How a pack is built
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# everything except the extractor: gate, crop, probe, plan, the manifest it would write
|
||||||
|
scripts/build-voice-pack.sh --dry-run narrator1 /home/dank/voices
|
||||||
|
|
||||||
|
# the real thing, on the host that holds the engine build
|
||||||
|
scripts/build-voice-pack.sh narrator1 /home/dank/voices
|
||||||
|
```
|
||||||
|
|
||||||
|
Crops come from `scripts/voice-packs.env` — per voice: source path, offset,
|
||||||
|
duration, and the source's pinned sha256 — and any of them can be overridden on
|
||||||
|
the command line (`--source`, `--offset`, `--duration`, `--sha256`, `--tool`).
|
||||||
|
`--all` walks `VOICE_PACKS`. The builder assumes no GPU, no container, no fleet
|
||||||
|
endpoint and no network: `ffmpeg` cuts the crop, the pinned extractor makes the
|
||||||
|
artifacts, and both run `nice -n 19` so a build never competes with the text
|
||||||
|
endpoints on that box.
|
||||||
|
|
||||||
|
The extractor is `qwen-codec` in the `qwentts.cpp` tree at
|
||||||
|
`VOICE_PACK_ENGINE_COMMIT` (`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4`). Its
|
||||||
|
usage text, read out of that tree and not guessed:
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: qwen-codec --model <gguf> [-i <input>] [--talker <gguf>] [--format <fmt>]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--model <gguf> Codec GGUF (qwen-tokenizer-12hz-*.gguf)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--device <name> Compute device: CUDA0, Vulkan0, MTL0, CPU...
|
||||||
|
(default: best available)
|
||||||
|
-i <path> Input. WAV -> encode, .rvq -> decode
|
||||||
|
--talker <gguf> Talker GGUF (Base only). Encode also extracts the speaker
|
||||||
|
embedding and writes it next to the .rvq as a .spk file
|
||||||
|
--format <fmt> WAV output format: wav16, wav24, wav32 (default: wav16)
|
||||||
|
|
||||||
|
Output is auto-named next to input : clip.wav -> clip.rvq, clip.rvq -> clip.wav.
|
||||||
|
```
|
||||||
|
|
||||||
|
Three consequences shape the builder. **There is no `-o`**, so the pack's WAV is
|
||||||
|
staged under its final stem and the artifacts land beside it with nothing to
|
||||||
|
rename. **`--talker` must be a _Base_ talker** — the customvoice and voicedesign
|
||||||
|
GGUFs carry no speaker encoder and the tool says `has no speaker encoder (Base
|
||||||
|
only)`. **Encode truncates to the hop boundary**, so the `.rvq` size is arithmetic,
|
||||||
|
not chance: `22 × floor(samples / 1920)` bytes, which is where the recorded
|
||||||
|
5,456–6,864 B window comes from and why the **effective crop window is
|
||||||
|
19.84–25 s** even though the WAV law says 18–25 s. A crop shorter than that is
|
||||||
|
refused before the extractor runs, with the number in the message.
|
||||||
|
|
||||||
|
The build is staged and published, never assembled in place: `reference.wav` is
|
||||||
|
the file the façade reads, so it moves into position **last**, after the
|
||||||
|
artifacts and the manifest. A failed extractor therefore leaves a pack directory
|
||||||
|
that is not a voice. Nothing is ever deleted — an existing pack is refused unless
|
||||||
|
`--force`, and `--force` moves the whole old directory to
|
||||||
|
`/home/dank/voices/.AGENTTRASH/<voice>-<UTC>/` and builds fresh beside it.
|
||||||
|
|
||||||
|
Measured on the shipped `narrator1` crop (2026-10-18, `--dry-run` against the
|
||||||
|
real source): 98.739–118.636 s → `955,378 B` of 24 kHz mono `pcm_s16le`, 19.897
|
||||||
|
s, planned `.rvq` 5,456 B — the same byte counts the trial produced by hand, and
|
||||||
|
the extractor itself costs 1.8–2.1 s and 1.3–1.5 GiB peak per voice.
|
||||||
|
|
||||||
|
## The 10-second gate
|
||||||
|
|
||||||
|
Project-wide source-material invariant: **the first 10 seconds of any sample
|
||||||
|
file used to generate voices is forbidden material.** An offset that starts
|
||||||
|
inside it is refused with exit 1 and a message that names the invariant, before
|
||||||
|
a directory is created:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR: source-material invariant violated: the crop must start past the first
|
||||||
|
10 s of the source file, and 'narrator1' asks for 4.5 s into … — nothing was
|
||||||
|
written.
|
||||||
|
```
|
||||||
|
|
||||||
|
The same gate is then asserted **from the manifest** that was just written —
|
||||||
|
`.crop.offset_seconds >= .invariant.forbidden_source_seconds`, plus the recorded
|
||||||
|
sha256 against the source it claims — so a pack cannot reach disk carrying a
|
||||||
|
forbidden crop even if the offsets in the env file are edited into nonsense.
|
||||||
|
`10` is not a knob: `scripts/voice-packs.env` carries the offsets, the sample
|
||||||
|
rate, the windows and the engine facts, but the forbidden region is a constant in
|
||||||
|
the builder. `tests/voice_pack_unit.bats` pins both halves (a refusal that
|
||||||
|
creates nothing, and one legal crop that builds the whole pack) and applies the
|
||||||
|
invariant to every crop in the tracked pack list.
|
||||||
|
|
||||||
|
## Adding a fourth voice
|
||||||
|
|
||||||
|
1. Pick the source file and hash it: `sha256sum /path/to/sample.wav`. A crop
|
||||||
|
offset is a claim about one exact file, so the digest goes in the env with it.
|
||||||
|
2. Find a speech-dense stretch **past 10 s**. The trial's recipe, applied once:
|
||||||
|
`ffmpeg -ss 60 -t 600 -i SRC -af silencedetect=noise=-35dB:d=0.6 -f null -`,
|
||||||
|
build the runs of non-silence, keep runs ≥ 18 s, take the longest (earliest
|
||||||
|
on ties), and cap at 25 s. Aim for 20–25 s: shorter than 19.84 s and the
|
||||||
|
`.rvq` falls under the floor.
|
||||||
|
3. Add the four lines to `scripts/voice-packs.env` and the name to
|
||||||
|
`VOICE_PACKS`:
|
||||||
|
|
||||||
|
```
|
||||||
|
VOICE_PACKS="narrator1 narrator2 narrator3 narrator4"
|
||||||
|
|
||||||
|
VOICE_PACK_NARRATOR4_SOURCE=/home/dank/voice-sources/audiosample4.m4b
|
||||||
|
VOICE_PACK_NARRATOR4_OFFSET=142.31
|
||||||
|
VOICE_PACK_NARRATOR4_DURATION=24
|
||||||
|
VOICE_PACK_NARRATOR4_SHA256=<64 hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
The name must be lowercase `[a-z0-9._-]`: the façade matches the directory
|
||||||
|
name verbatim while `tts-server` lowercases names internally, so an uppercase
|
||||||
|
name would be reported one way by the façade and another by the engine. The
|
||||||
|
builder refuses one anyway.
|
||||||
|
4. `scripts/build-voice-pack.sh --dry-run narrator4 /home/dank/voices`, read the
|
||||||
|
plan, then drop `--dry-run`.
|
||||||
|
5. `./tests/run.sh` (the pack-list invariant test covers the new row for free).
|
||||||
|
6. Register it with the running engine. The engine's voice registry is
|
||||||
|
memory-only and warm-on-boot, but a restart re-decodes **every** voice (6–8 s
|
||||||
|
each), so adding one pack uses the hook instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec frank-tts-engine /app/warm-voices.sh
|
||||||
|
curl -s localhost:7772/v1/audio/voices # the façade's list, not the engine's
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook walks `/voices`, registers any pack with a `reference.wav` it has not
|
||||||
|
seen, and logs `voice <name> cold=…ms warm=…ms` to the unit's journal. The
|
||||||
|
rootless unit restart — `systemctl --user restart frank-tts-engine.service` —
|
||||||
|
is for when the unit or its env changed, not for a new pack.
|
||||||
|
|
||||||
|
## When the extractor's output size changes on an engine bump
|
||||||
|
|
||||||
|
The builder treats the two artifact sizes as properties of the pinned engine, so
|
||||||
|
an engine bump that changes them **fails the build**: `reference.spk is N B,
|
||||||
|
expected 8192 B` or `reference.rvq is N B, expected …`. That is the failure being
|
||||||
|
loud, not a bug. Work through it in order:
|
||||||
|
|
||||||
|
1. Re-read the tool's own numbers rather than trusting this page: `qwen-codec`
|
||||||
|
prints `SpeakerEncoder] Loaded: enc_dim=…` and `Pipeline] Ready: hop … , …
|
||||||
|
codebooks @ … Hz` on every run, and the pinned tree's
|
||||||
|
`tools/qwen-codec.cpp` documents the packing. `enc_dim × 4` is the `.spk`;
|
||||||
|
`codebooks × code_bits / 8` bytes per frame × frames is the `.rvq`.
|
||||||
|
2. Decide whether the new number is intended. A changed `enc_dim` means the old
|
||||||
|
`.spk` files are unreadable by the new engine, so **every** pack must be
|
||||||
|
rebuilt, not just the failing one.
|
||||||
|
3. Update the four arithmetic lines in `scripts/voice-packs.env`
|
||||||
|
(`VOICE_PACK_SPK_BYTES`, `VOICE_PACK_RVQ_MIN_BYTES`, `VOICE_PACK_RVQ_MAX_BYTES`,
|
||||||
|
and `VOICE_PACK_RVQ_BYTES_PER_FRAME` if the codebook packing changed) **and**
|
||||||
|
`VOICE_PACK_ENGINE_COMMIT`, `VOICE_PACK_TOOL`, `VOICE_PACK_CODEC_MODEL`,
|
||||||
|
`VOICE_PACK_TALKER_MODEL` to the new build. An env diff that moves the sizes
|
||||||
|
without moving the commit is the mistake to avoid.
|
||||||
|
4. Rebuild all three packs with `--force` (the retired directories keep the old
|
||||||
|
bytes readable beside their replacements until a human clears them), then
|
||||||
|
re-run the warm hook: `podman exec frank-tts-engine /app/warm-voices.sh`. That
|
||||||
|
hook registers **every** pack it finds on every run, and the engine replaces a
|
||||||
|
name it already holds — `tools/tts-server.cpp:148` at the pinned commit says so
|
||||||
|
in its own words: *"Re-registering a name replaces the previous entry."* So the
|
||||||
|
rebuilt `reference.wav` is what the running engine speaks afterwards, and the
|
||||||
|
hook's cold/warm pass is what re-snapshots it. A restart
|
||||||
|
(`systemctl --user restart frank-tts-engine.service`) is what an ENGINE bump
|
||||||
|
needs — the registry is memory-only and lives in that process — and the unit
|
||||||
|
runs the same hook on every start anyway, so a restart is never worse, only
|
||||||
|
slower.
|
||||||
|
5. `./tests/voice_pack_unit.bats` — the stub in that seam reproduces the packing
|
||||||
|
law, so it needs the same arithmetic update as the env file. The seam's
|
||||||
|
engine-bump test is exactly this scenario with a wrong `.spk` size, and its
|
||||||
|
assertion — no `reference.wav`, no manifest — is the property that matters.
|
||||||
|
|
||||||
|
## Host state (census 2026-10-18, read-only)
|
||||||
|
|
||||||
|
- `/home/dank/voices/` holds `narrator1`, `narrator2`, `narrator3`, each with
|
||||||
|
`reference.wav`, a `reference16k.wav`, and a `manifest.json` **in the trial's
|
||||||
|
own schema** (`kind: voice-reference-pack`, a silence-scan record). They carry
|
||||||
|
**no** `reference.spk` / `reference.rvq`: the trial's artifacts live in its own
|
||||||
|
work directory (`/home/dank/voice-audition/qwentts/voices/`, and only for
|
||||||
|
narrator1 and narrator2). The builder's first real run is therefore a
|
||||||
|
migration, and it should be a `--force` run per voice so the old directories
|
||||||
|
are retired rather than left half-updated.
|
||||||
|
- The three source files (`/home/blank/audiosample.mp3`,
|
||||||
|
`audiosample2.m4b`, `audiosample3.m4b`, digests pinned in
|
||||||
|
`scripts/voice-packs.env`) live on the workstation, not on Dankclaw; the
|
||||||
|
builder reads them from `$VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources`, so
|
||||||
|
they have to be copied there once before a build.
|
||||||
|
- `VOICE_PACK_TOOL` points at the trial's build tree
|
||||||
|
(`/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec`, checked out
|
||||||
|
at exactly `5916bf7c`), and the two GGUFs at
|
||||||
|
`/home/dank/models/Qwen3-TTS-GGUF/`, which is where the engine slice's
|
||||||
|
download script puts them — that directory does not exist yet, so until it
|
||||||
|
does the models have to come from the trial's `models/` directory.
|
||||||
|
- Nothing is deployed: nothing listens on 7772 or 8880 today, so building packs
|
||||||
|
is possible on that host while serving them is not.
|
||||||
|
|
||||||
|
## Known gaps, on purpose
|
||||||
|
|
||||||
|
- **No transcript in the manifest.** ICL wants a `ref_text` that matches the
|
||||||
|
crop, and the trial's machine transcripts came back wrong ("Demon Souls" for
|
||||||
|
*Dark Souls*). Transcribing on the host is not a fix, so the pack carries none
|
||||||
|
and the engine registers the WAV-only voice, which is what its boot warmer
|
||||||
|
does anyway. When ICL becomes a per-voice opt-in, the curated transcript
|
||||||
|
belongs in this manifest — a human-supplied field, not something the builder
|
||||||
|
can derive.
|
||||||
|
- **No 16 kHz variant.** The packs the trial left behind carry
|
||||||
|
`reference16k.wav` for the `llama-tts-cli` rollback engine; arm A is native
|
||||||
|
24 kHz, so the builder emits one reference. `--force` retires the whole
|
||||||
|
directory including that variant, so if the rollback engine is ever revived:
|
||||||
|
`ffmpeg -i reference.wav -ar 16000 -c:a pcm_s16le reference16k.wav`.
|
||||||
|
- **No loudness pass.** The trial's pipeline also measured and normalised
|
||||||
|
(two-pass `loudnorm`, `astats`, `ebur128`, spectrograms) to a `-16 LUFS` spec.
|
||||||
|
None of that is in the pack law, which asks for 24 kHz mono inside the
|
||||||
|
duration window, so the builder does only that; a future engine that is
|
||||||
|
loudness-sensitive should get the pass as an explicit, documented step.
|
||||||
@@ -116,6 +116,34 @@ On a later ordinary deployment, a service remains pending only while its
|
|||||||
recorded revision differs from the checkout and the planner still finds a
|
recorded revision differs from the checkout and the planner still finds a
|
||||||
change in that service's inputs.
|
change in that service's inputs.
|
||||||
|
|
||||||
|
## Locally built images
|
||||||
|
|
||||||
|
`build_compose_image` in `scripts/deploy-remote.sh` builds the images that have
|
||||||
|
no registry digest — `comfyui-rocm`, `franksdank/loki`, `franksdank/alloy` and
|
||||||
|
the voice pair `franksdank/frank-tts` / `franksdank/frank-tts-engine` — and
|
||||||
|
**skips** the build when a `cfhash` label on the installed image matches a hash
|
||||||
|
it computes from the checkout now. That skip is what keeps the engine's
|
||||||
|
ten-minute `git clone --recursive` off every re-deploy, so the rule has to be
|
||||||
|
honest about what the image bakes:
|
||||||
|
|
||||||
|
- The hash covers the `Containerfile` **plus the files its `COPY` lines pull
|
||||||
|
in**, named as `extra=(…)` in each case arm. Hashing the `Containerfile`
|
||||||
|
alone was wrong in practice: editing `dankclaw/voiceservice/app.py` or
|
||||||
|
`tts-engine-warm.sh` left the label matching, `podman build` never ran, and
|
||||||
|
the redeployed container still carried the previous code while `/health`
|
||||||
|
answered — invisible to every gate. Corrected 2026-10-18;
|
||||||
|
`tests/frank_tts_engine.bats` drives the function against a stub `podman`
|
||||||
|
(first deploy builds, unchanged tree does not, a `COPY`'d file rebuilds).
|
||||||
|
- A base-image assumption is part of the image's identity too. The engine's
|
||||||
|
runtime stage creates its unprivileged user with `useradd`, not `adduser`,
|
||||||
|
because the pinned `ubuntu:24.04` base ships no `adduser` — checked by
|
||||||
|
running the command against the digest on 2026-10-18. That failure mode is a
|
||||||
|
build that dies at the `RUN` line, so it is loud, unlike the stale-hash one.
|
||||||
|
|
||||||
|
Both engines keep serving during a rebuild: `build_compose_image` runs after
|
||||||
|
`install_quadlet` only for the LLM-style handlers, and the voice deploy gates on
|
||||||
|
recorded health rather than a fixed sleep — see [[Frank TTS Engine]].
|
||||||
|
|
||||||
## Manual recovery
|
## Manual recovery
|
||||||
|
|
||||||
If the LLM handler fails after the target pull:
|
If the LLM handler fails after the target pull:
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
|
|||||||
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
||||||
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
|
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
|
||||||
- [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; repo-owned config, not deployed
|
- [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; repo-owned config, not deployed
|
||||||
|
- [[Frank TTS]] — the 7772 cloned-voice endpoint consumers address: voice naming, pack layout, why the upstream is a container name; repo-owned config, not deployed
|
||||||
|
- [[Voice Packs]] — how a frank-tts voice is built: reference.wav + the pinned engine's speaker artifacts, cut past the 10-second source-material gate
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -2490,3 +2490,156 @@ every restart since the cutover.
|
|||||||
nothing and exposes no metrics, so there is nothing to probe, which is said out
|
nothing and exposes no metrics, so there is nothing to probe, which is said out
|
||||||
loud in the page rather than papered over. No ICL/`ref_text` work: the packs
|
loud in the page rather than papered over. No ICL/`ref_text` work: the packs
|
||||||
keep `reference.spk`/`reference.rvq` so it stays a per-voice opt-in.
|
keep `reference.spk`/`reference.rvq` so it stays a per-voice opt-in.
|
||||||
|
|
||||||
|
## 2026-10-18 — frank-tts: the 7772 endpoint gets its deploy wiring, and voice naming is written down
|
||||||
|
|
||||||
|
- Stage 2 of the voice stack: the **façade** in front of the engine is now a
|
||||||
|
deployable service too. Nothing deployed, no host touched. Runbook:
|
||||||
|
[[Frank TTS]].
|
||||||
|
- **Two attached networks, in a meaningful order.** `dankclaw_default` first — it
|
||||||
|
owns the default route and is where consumers live (open-notebook dials
|
||||||
|
`http://frank-tts:7772/v1` by container name) — then the voice pair's private
|
||||||
|
bridge. `scripts/frank-tts.env`'s `TTS_UPSTREAM` moves from
|
||||||
|
`host.containers.internal:8880` to `http://frank-tts-engine:8880`, which is the
|
||||||
|
actual fix for a shape that could never have worked: a bridge container cannot
|
||||||
|
reach a host-loopback bind, and the engine publishes nothing.
|
||||||
|
- **`Wants=` + `After=` on the engine, never `Requires=`:** the façade still
|
||||||
|
carries the `llama-tts-cli` rollback backend, which needs no upstream, so an
|
||||||
|
engine that will not boot must delay this endpoint, not take it down.
|
||||||
|
- The façade handler installs the **engine's** quadlet pair as well. Not tidiness:
|
||||||
|
with a referenced `Network=` file absent, the v1 Quadlet generator produces no
|
||||||
|
container unit at all, and `--only frank-tts` has no other way to obtain it. It
|
||||||
|
also `mkdir -p /home/dank/voices` as the deploy user, because rootless podman
|
||||||
|
auto-creates a missing bind source **root-owned** and the endpoint then reports
|
||||||
|
"no voices" instead of an error.
|
||||||
|
- **A voice is a lowercase directory name.** `^[a-z0-9][a-z0-9._-]*$`, ≤ 64 chars,
|
||||||
|
no slash/`..`/space. The shape is the intersection of two rules that do not
|
||||||
|
agree: `app.py` would accept mixed case, `tts-server` lowercases names — so a
|
||||||
|
pack named `Narrator1` resolves through the façade and not through the engine,
|
||||||
|
and the boot warmer refuses such a directory loudly. `narrator1/2/3` are
|
||||||
|
load-bearing for open-notebook, and `frank-tts-add-cloned-voice` is the skill
|
||||||
|
that walks the pack → register → warm → prove-a-second-request flow.
|
||||||
|
- Registered everywhere the open-notebook entry named as law (planner order puts
|
||||||
|
the engine before the façade, so a warm registry precedes the first synthesis),
|
||||||
|
plus the homepage card — `wip: down` and hidden, because nothing listens on
|
||||||
|
7772 yet.
|
||||||
|
- **No Prometheus job, on purpose.** The façade exports no `/metrics`, Prometheus 3
|
||||||
|
refuses HTML/JSON content types (the `comfyui` and `services` probes are
|
||||||
|
documented down for exactly that), and `LlmEndpointDown` is scoped
|
||||||
|
`up{job=~"frank-.*"}` — so a `/health` scrape would page on Signal every few
|
||||||
|
minutes for a healthy endpoint. That is the frank-archon never-green lesson
|
||||||
|
pointed the other way, and it is written in the page instead of being quietly
|
||||||
|
ignored.
|
||||||
|
- Tests added to the voice seam: the generator's own rendering of the pair (both
|
||||||
|
attachments, first-network order, one publish, Wants-not-Requires), the
|
||||||
|
env-file upstream checked as a **network join** rather than a string
|
||||||
|
comparison, planner separation and order, the façade handler's steps, the
|
||||||
|
homepage card's gating, and routing that selects both seams for either
|
||||||
|
deployable's paths.
|
||||||
|
|
||||||
|
## 2026-10-18 — voice-pack builder: the 10-second source-material gate becomes code
|
||||||
|
|
||||||
|
- Third slice of the voice stack (after the façade and the engine decision):
|
||||||
|
`scripts/build-voice-pack.sh` turns a source recording plus a crop offset into
|
||||||
|
a deployable pack — `reference.wav` (24 kHz mono `pcm_s16le`, 18–25 s), the
|
||||||
|
engine's own `reference.spk` (8,192 B) + `reference.rvq` (5,456–6,864 B) made
|
||||||
|
from it, and `manifest.json` provenance. Tracked tunables in
|
||||||
|
`scripts/voice-packs.env`: pack list, per-voice source/offset/duration/pinned
|
||||||
|
source sha256, extractor path and engine commit, artifact arithmetic. New runbook
|
||||||
|
[[Voice Packs]].
|
||||||
|
- **The gate is the product.** The project-wide invariant — no voice material
|
||||||
|
from the first 10 s of a sample file — had only ever been a sentence in
|
||||||
|
`scripts/frank-tts.env`. An offset inside it now exits 1 before a directory is
|
||||||
|
created, and the same assertion runs again against the manifest that was just
|
||||||
|
written, next to the recorded sha256, so a pack cannot carry a forbidden crop
|
||||||
|
even if the env file is edited into nonsense. `10` stays a constant in the
|
||||||
|
builder; the env file carries offsets, never the law.
|
||||||
|
- **The extractor's contract was read, not guessed:** `qwen-codec` in qwentts.cpp
|
||||||
|
`5916bf7c` takes `--model <codec gguf> --talker <base talker gguf> -i <wav>`
|
||||||
|
with an optional `--device`, has **no `-o`** and names both artifacts next to
|
||||||
|
the input with the extension swapped. So the WAV is staged under its final stem
|
||||||
|
and `reference.wav` — the file the façade gates on — is moved into place
|
||||||
|
**last**, after the artifacts and the manifest: a failed extractor leaves a
|
||||||
|
directory that is not a voice. `--force` retires an existing pack whole to
|
||||||
|
`.AGENTTRASH/` instead of touching bytes; nothing in this script deletes.
|
||||||
|
- Sizes are arithmetic, so they are checked: 16 codebooks × 11 bits = 22 B per
|
||||||
|
12.5 Hz frame (`22 × floor(samples/1920)`) and `enc_dim 2048 × f32` = 8,192 B.
|
||||||
|
That makes the **effective crop window 19.84–25 s**, narrower than the WAV
|
||||||
|
law's 18–25 s, and a too-short crop is refused before the extractor runs. An
|
||||||
|
engine bump that changes either number fails the build by design; the bump
|
||||||
|
procedure is in [[Voice Packs]].
|
||||||
|
- Verified against reality, not against the model: the pinned `narrator1` crop
|
||||||
|
(98.739–118.636 s of `audiosample.mp3`, sha256 `2299510…`) comes out of a
|
||||||
|
`--dry-run` at 955,378 B / 19.897 s with 5,456 B of `.rvq` planned — byte-for-byte
|
||||||
|
the trial's hand-cut numbers — and all three pinned source digests match the
|
||||||
|
files on disk.
|
||||||
|
- Seam: `tests/voice_pack_unit.bats` (13 tests) — refusal inside the forbidden
|
||||||
|
region creates nothing, a legal crop builds the whole pack, `ffprobe` on the
|
||||||
|
generated reference, manifest round-trip through `jq` with a sha that matches
|
||||||
|
the source, re-run-without-`--force` leaves every byte alone, `--force` retires
|
||||||
|
rather than deletes, `--dry-run` needs no extractor, a wrong-sized artifact
|
||||||
|
from a bumped engine cannot publish a voice, and every crop in the tracked pack
|
||||||
|
list clears the invariant. `qwen-codec` is stood in by a stub with the real
|
||||||
|
tool's I/O shape, so no engine, no GPU, no network, no fleet host. Suite green:
|
||||||
|
**416/416** (`./tests/run.sh`, wall 77 s) — 403 before this branch.
|
||||||
|
- Deliberately NOT done: no transcript in the manifest (ICL wants a curated
|
||||||
|
`ref_text`; the trial's machine transcripts were wrong, and the engine
|
||||||
|
registers WAV-only voices anyway), no 16 kHz variant (arm A is native 24 kHz),
|
||||||
|
no loudness pass (not in the pack law), and no pack built on the host — the
|
||||||
|
three sources still live on the workstation and the engine GGUFs have not been
|
||||||
|
fetched to `/home/dank/models/Qwen3-TTS-GGUF` yet, so the first real run is a
|
||||||
|
host step.
|
||||||
|
|
||||||
|
## 2026-10-18 — voice stack audit: three faults that no gate could see
|
||||||
|
|
||||||
|
- The voice stack had never been run end to end — no image built, nothing on
|
||||||
|
7772 — so the whole path was audited as static code against the tools it
|
||||||
|
calls, and three faults came out of it. All three are the same species: a
|
||||||
|
step that **reports success while doing nothing**.
|
||||||
|
- **The build would not have happened.** `Containerfile.engine` created its
|
||||||
|
unprivileged user with `adduser`; the pinned ubuntu:24.04 base has no
|
||||||
|
`adduser` at all. Proven in the base on Dankclaw rather than argued from
|
||||||
|
memory: `command -v adduser` prints nothing, and the replacement line
|
||||||
|
`useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts`
|
||||||
|
answers `uid=10000(tts) gid=999(tts)`. Its `SYS_UID_MAX` warning is cosmetic
|
||||||
|
and is the price of matching the façade's uid, so the comment says that
|
||||||
|
before someone "fixes" it by dropping `--system`.
|
||||||
|
- **The warm pass would not have warmed.** The unit hands its env file to podman
|
||||||
|
via `--env-file`, which performs no shell parsing and takes the literal
|
||||||
|
remainder of the line, while `source` strips one quoting layer. `WARM_TEXT`
|
||||||
|
therefore reached the container with its quotes, the hook interpolated them
|
||||||
|
into the synthesis body, the JSON was invalid, and every voice would log
|
||||||
|
"registered, but the warm synthesis failed" beside an `active` unit. Fixed in
|
||||||
|
the hook — strip exactly one matched pair, only when the inner content also
|
||||||
|
carries a quote or backslash, then JSON-escape — and the seam now reads
|
||||||
|
`scripts/frank-tts-engine.env` **both** ways and asserts the engine gets the
|
||||||
|
same sentence in both shapes. The bug survived the first deploy precisely
|
||||||
|
because every existing test used `source`.
|
||||||
|
- **A rebuilt image would not have been rebuilt.** `build_compose_image` skips
|
||||||
|
`podman build` on a matching `cfhash` label, and that hash covered the
|
||||||
|
`Containerfile` alone — so editing `app.py` or `tts-engine-warm.sh` (both
|
||||||
|
`COPY`'d) skipped the build, the deploy reported success, `/health` answered,
|
||||||
|
and the container served old code. Each case arm now names the files its
|
||||||
|
`Containerfile` copies and hashes them with it; comfyui/loki/alloy have no
|
||||||
|
extras, so their labels are byte-unchanged and they do not rebuild here.
|
||||||
|
- Tests as behaviour, not as greps: the new seam extracts the real
|
||||||
|
`build_compose_image` from the deployer and drives it against a stub `podman`
|
||||||
|
that replays per-image label state, asserting the observable question — did
|
||||||
|
the deploy ask podman to build, or did it skip? — through build, skip,
|
||||||
|
engine-hook-edit-rebuilds-engine-only, and `app.py`-rebuilds-the-façade.
|
||||||
|
Reverting `scripts/deploy-remote.sh` turns it red at the warm-hook step, which
|
||||||
|
is the fault being prevented. Suite green: **443/443** (`./tests/run.sh`,
|
||||||
|
wall 110 s); each fix commit was left green on its own so the branch bisects.
|
||||||
|
- Docs carry the lessons where an operator will hit them: a **Locally built
|
||||||
|
images** section in [[deployment]], two new troubleshooting lines in
|
||||||
|
[[Frank TTS Engine]] (all-voices-cold = quoted `WARM_TEXT`; façade 502 beside
|
||||||
|
200 = model loaded ≠ voices registered), and `Voice Packs.md` step 4
|
||||||
|
corrected — the warm hook re-registers **every** pack on every run, because
|
||||||
|
the engine replaces a name it already holds (`tools/tts-server.cpp:148` at
|
||||||
|
the pinned commit: *"Re-registering a name replaces the previous entry."*),
|
||||||
|
so a restart is what an engine bump needs and not what a rebuilt pack needs.
|
||||||
|
- Opened as PR **#14** against `main`. Deliberately NOT done: no deploy (the
|
||||||
|
first `--service frank-tts-engine` run is a ~10-minute recursive source clone
|
||||||
|
and a new unit on a host that serves people — Frank's call), no voice pack
|
||||||
|
built on the host, no Playwright run (this branch publishes no host port and
|
||||||
|
touches no vhost), and Gitea **#2** phase 1 stays open until 7772 answers.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#101a12" stroke="#7ee787" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#7ee787" text-anchor="middle">FT</text></svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
@@ -536,5 +536,18 @@
|
|||||||
"wip": false,
|
"wip": false,
|
||||||
"showLink": true,
|
"showLink": true,
|
||||||
"art": "/img/apps/mono-cu.svg"
|
"art": "/img/apps/mono-cu.svg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frank-tts",
|
||||||
|
"category": "admin",
|
||||||
|
"section": "AI",
|
||||||
|
"description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Public on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. Hidden until deployed.",
|
||||||
|
"url": "",
|
||||||
|
"docs": "",
|
||||||
|
"probe": "127.0.0.1:7772",
|
||||||
|
"method": "http",
|
||||||
|
"wip": "down",
|
||||||
|
"showLink": false,
|
||||||
|
"art": "/img/apps/mono-ft.svg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+298
-8
@@ -20,6 +20,10 @@ load helpers
|
|||||||
|
|
||||||
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
|
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
|
||||||
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
|
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
|
||||||
|
# The façade this engine serves: its wiring is tested here because the bridge
|
||||||
|
# both units attach to is what makes the pair one deployable shape.
|
||||||
|
FACADE="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
|
||||||
|
FACADE_ENV="$REPO_ROOT/scripts/frank-tts.env"
|
||||||
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
|
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
|
||||||
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
||||||
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
||||||
@@ -184,19 +188,34 @@ quadlet_generator() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# render_units — run the generator in a throwaway HOME over a copy of the two
|
# render_units — run the generator in a throwaway HOME over a copy of the voice
|
||||||
# source files and print everything it emits. --dryrun prints instead of
|
# stack's source files and print everything it emits. Both units are rendered
|
||||||
# writing, so this neither contacts the podman daemon nor creates a network.
|
# together on purpose: the façade references the engine's .network, and a
|
||||||
|
# referenced file that is not in the directory makes the generator drop that
|
||||||
|
# container unit outright — which is the failure this seam must be able to see.
|
||||||
|
# --dryrun prints instead of writing, so this neither contacts the podman daemon
|
||||||
|
# nor creates a network.
|
||||||
render_units() {
|
render_units() {
|
||||||
local gen home
|
local gen home
|
||||||
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
||||||
home="$TEST_TMP/fakehome"
|
home="$TEST_TMP/fakehome"
|
||||||
mkdir -p "$home/.config/containers/systemd"
|
mkdir -p "$home/.config/containers/systemd"
|
||||||
cp "$NETF" "$UNIT" "$home/.config/containers/systemd/"
|
cp "$NETF" "$UNIT" "$FACADE" "$home/.config/containers/systemd/"
|
||||||
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
|
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
|
||||||
cat "$TEST_TMP/generated.txt"
|
cat "$TEST_TMP/generated.txt"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# unit_block <name> — one unit's block from the last render_units output. The
|
||||||
|
# end of the block is the next `---` marker: two units are rendered now, and a
|
||||||
|
# slice that ran to the end of the output would read the façade's RestartSec
|
||||||
|
# into the engine's assertions, which is exactly how a generator test starts
|
||||||
|
# asserting the wrong container.
|
||||||
|
unit_block() {
|
||||||
|
awk -v u="$1" 'index($0, "---" u "---") == 1 { f = 1; next }
|
||||||
|
/^---/ { f = 0 }
|
||||||
|
f' "$TEST_TMP/generated.txt"
|
||||||
|
}
|
||||||
|
|
||||||
# free_port — an address with nothing bound to it, for the tests that need the
|
# free_port — an address with nothing bound to it, for the tests that need the
|
||||||
# warmer to be dialing before a listener exists.
|
# warmer to be dialing before a listener exists.
|
||||||
free_port() {
|
free_port() {
|
||||||
@@ -251,7 +270,7 @@ s.close()'
|
|||||||
# Boot persistence and self-healing, as the generator actually emits them —
|
# Boot persistence and self-healing, as the generator actually emits them —
|
||||||
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
||||||
# Restart=always plus a failing warmer is a crash loop on the endpoint.
|
# Restart=always plus a failing warmer is a crash loop on the endpoint.
|
||||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
unit="$(unit_block frank-tts-engine.service)"
|
||||||
printf '%s' "$unit" | grep -qE '^Restart=always$'
|
printf '%s' "$unit" | grep -qE '^Restart=always$'
|
||||||
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
||||||
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
|
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
|
||||||
@@ -266,12 +285,12 @@ s.close()'
|
|||||||
|
|
||||||
@test "the generator renders the bridge creation command with the pinned subnet" {
|
@test "the generator renders the bridge creation command with the pinned subnet" {
|
||||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||||
net="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine-network.service---$/,$p')"
|
net="$(unit_block frank-tts-engine-network.service)"
|
||||||
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
|
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
|
||||||
# The container unit must be tied to the network unit, not merely mention it:
|
# The container unit must be tied to the network unit, not merely mention it:
|
||||||
# a referenced .network that the generator cannot find kills the container
|
# a referenced .network that the generator cannot find kills the container
|
||||||
# unit outright, and an untied one starts the container before the bridge.
|
# unit outright, and an untied one starts the container before the bridge.
|
||||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
unit="$(unit_block frank-tts-engine.service)"
|
||||||
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
||||||
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
|
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
|
||||||
}
|
}
|
||||||
@@ -375,6 +394,51 @@ assert {s["response_format"] for s in speech} == {"wav"}, speech
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the warm sentence survives both readers of the env file intact" {
|
||||||
|
# scripts/frank-tts-engine.env is read two ways and they disagree about
|
||||||
|
# quotes. bash (`set -a; source`, what the seams and a human do) strips the
|
||||||
|
# quotes around a value with spaces. podman's --env-file — how the unit really
|
||||||
|
# reads it — does NOT: measured 2026-10-18 against the image's pinned base,
|
||||||
|
# the value lands in the container WITH its quotes. Interpolated raw into the
|
||||||
|
# hook's JSON body that is `""Warm up…"",` — the engine answers 400, no voice
|
||||||
|
# warms, and the boot still reads healthy. So the hook unwraps one
|
||||||
|
# shell-style layer and then escapes, and BOTH shapes must arrive as the
|
||||||
|
# sentence the operator wrote.
|
||||||
|
start_engine_stub
|
||||||
|
|
||||||
|
# Shape A: what podman delivers for `WARM_TEXT="Warm up, one two three."`.
|
||||||
|
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||||
|
WARM_TEXT='"Warm up, one two three."' bash "$WARM"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
|
||||||
|
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||||
|
assert not [e for e in entries if e.get("invalid_json")], entries
|
||||||
|
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
|
||||||
|
assert len(speech) == 2, entries
|
||||||
|
assert {s["input"] for s in speech} == {"Warm up, one two three."}, speech
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Shape B: a value that starts AND ends with a quote but carries more quotes
|
||||||
|
# INSIDE — exactly the shape an unwrap rule must refuse, because peeling the
|
||||||
|
# outer pair would delete characters the operator wrote. It must arrive
|
||||||
|
# character for character (trailing quote included), which also proves the
|
||||||
|
# escaping does not mangle what it is protecting.
|
||||||
|
: > "$TEST_TMP/journal.jsonl"
|
||||||
|
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
|
||||||
|
WARM_TEXT='"He said "hi"\ once"' bash "$WARM"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
entries = [json.loads(l) for l in open(sys.argv[1])]
|
||||||
|
assert not [e for e in entries if e.get("invalid_json")], entries
|
||||||
|
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
|
||||||
|
assert len(speech) == 2, entries
|
||||||
|
assert {s["input"] for s in speech} == {'"He said "hi"\\ once"'}, speech
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
@test "a directory without reference.wav is not a voice, and a mixed-case name is refused" {
|
@test "a directory without reference.wav is not a voice, and a mixed-case name is refused" {
|
||||||
# `justamanifest` must be invisible; `Narrator2` must be loud. The engine
|
# `justamanifest` must be invisible; `Narrator2` must be loud. The engine
|
||||||
# lowercases every voice name (tts_voice_name) while frank-tts matches the
|
# lowercases every voice name (tts_voice_name) while frank-tts matches the
|
||||||
@@ -562,9 +626,11 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
|
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
|
||||||
|
# Everything named after the engine, EXCEPT the bridge file: both units
|
||||||
|
# attach to it, so a subnet change is a change to two deployables and that
|
||||||
|
# pairing is asserted in the façade's planner test below.
|
||||||
run python3 "$PLANNER" --target dankclaw --paths \
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
||||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network \
|
|
||||||
dankclaw/voiceservice/Containerfile.engine \
|
dankclaw/voiceservice/Containerfile.engine \
|
||||||
dankclaw/voiceservice/tts-engine-start.sh \
|
dankclaw/voiceservice/tts-engine-start.sh \
|
||||||
scripts/frank-tts-engine.env \
|
scripts/frank-tts-engine.env \
|
||||||
@@ -654,3 +720,227 @@ PY
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the generator puts the façade on both bridges and behind the engine" {
|
||||||
|
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||||
|
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts ')"
|
||||||
|
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
|
||||||
|
|
||||||
|
# Two attachments, and the fleet-wide bridge FIRST: that is the network
|
||||||
|
# consumers address (open-notebook dials `http://frank-tts:7772/v1` by
|
||||||
|
# container name), so it owns the default route. The engine's private bridge
|
||||||
|
# is second and exists so the shim can resolve the engine at all.
|
||||||
|
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | head -1)" = "--network dankclaw_default" ]
|
||||||
|
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
|
||||||
|
# Published exactly once, on the fleet port — and this is the only publish in
|
||||||
|
# the voice stack.
|
||||||
|
[ "$(printf '%s' "$line" | grep -oE -- '--publish [^ ]+' | wc -l)" = "1" ]
|
||||||
|
printf '%s' "$line" | grep -qE -- '--publish (\[::\]:)?7772:7772( |$)'
|
||||||
|
|
||||||
|
unit="$(unit_block frank-tts.service)"
|
||||||
|
printf '%s' "$unit" | grep -qE '^Wants=frank-tts-engine\.service$'
|
||||||
|
printf '%s' "$unit" | grep -qE '^After=.*frank-tts-engine\.service'
|
||||||
|
# Wants, never Requires: the CLI rollback backend needs no upstream, and an
|
||||||
|
# engine that will not start must not take this endpoint's unit down.
|
||||||
|
! printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine\.service$'
|
||||||
|
# And it is tied to the bridge as well, or the container would be started
|
||||||
|
# before netavark had created the network it dials over.
|
||||||
|
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the upstream the env file names is only resolvable on a network the unit joins" {
|
||||||
|
# The pair's one real wiring contract, checked as a join rather than as a
|
||||||
|
# string: take the hostname out of TTS_UPSTREAM the way the container will
|
||||||
|
# get it, and require the façade unit to attach to the Quadlet network file
|
||||||
|
# named after that hostname. A revert to host.containers.internal, or a unit
|
||||||
|
# that drops the second Network= line, breaks here — both mistakes leave the
|
||||||
|
# endpoint answering and every synthesis failing.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$FACADE_ENV"
|
||||||
|
set +a
|
||||||
|
host="${TTS_UPSTREAM#*://}"
|
||||||
|
host="${host%%:*}"
|
||||||
|
host="${host%%/*}"
|
||||||
|
[ -n "$host" ] || { echo "TTS_UPSTREAM names no host: $TTS_UPSTREAM"; false; }
|
||||||
|
[ "$host" != localhost ] || { echo "localhost is not reachable across netns"; false; }
|
||||||
|
if ! grep -qE "^Network=${host}\.network$" "$FACADE"; then
|
||||||
|
echo "frank-tts.container does not attach to ${host}.network"
|
||||||
|
false
|
||||||
|
fi
|
||||||
|
if ! [ -f "$REPO_ROOT/dankclaw/voiceservice/quadlet/${host}.network" ]; then
|
||||||
|
echo "no Quadlet network file for $host — the generator would drop the unit"
|
||||||
|
false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the planner keeps the two voice deployables separate and ordered" {
|
||||||
|
# `--only` on the façade must not drag the engine's rebuild along, and a
|
||||||
|
# bridge change must restart both: the network file is named after the engine
|
||||||
|
# unit only because install_quadlet copies `$unit.network` beside the unit.
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/app.py
|
||||||
|
[ "$output" = "frank-tts" ]
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/Containerfile
|
||||||
|
[ "$output" = "frank-tts" ]
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts.container
|
||||||
|
[ "$output" = "frank-tts" ]
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts-engine.container
|
||||||
|
[ "$output" = "frank-tts-engine" ]
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts-engine.network
|
||||||
|
printf '%s\n' "$output" | grep -qx frank-tts
|
||||||
|
printf '%s\n' "$output" | grep -qx frank-tts-engine
|
||||||
|
|
||||||
|
# Order: dependency before consumer, so a full dankclaw deploy starts a warm
|
||||||
|
# engine and then the endpoint that forwards into it.
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts.container \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts-engine.container
|
||||||
|
local e f
|
||||||
|
e="$(printf '%s\n' "$output" | grep -n '^frank-tts-engine$' | cut -d: -f1)"
|
||||||
|
f="$(printf '%s\n' "$output" | grep -n '^frank-tts$' | cut -d: -f1)"
|
||||||
|
[ -n "$e" ] && [ -n "$f" ] && [ "$e" -lt "$f" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the façade handler builds its image, installs the bridge file, and gates on recorded health" {
|
||||||
|
grep -qF 'dankclaw:frank-tts|' "$REMOTE"
|
||||||
|
grep -qF 'dankclaw:frank-tts|' "$REPO_ROOT/scripts/deploy.sh"
|
||||||
|
grep -qF 'frank-tts) deploy_frank_tts ;;' "$REMOTE"
|
||||||
|
# build_compose_image's default arm returns 0 without building: an unlisted
|
||||||
|
# service means a unit whose Pull=never image was never made.
|
||||||
|
grep -qE '^\s+containerfile=dankclaw/voiceservice/Containerfile$' "$REMOTE"
|
||||||
|
|
||||||
|
handler="$(sed -n '/^deploy_frank_tts()/,/^}/p' "$REMOTE")"
|
||||||
|
[ -n "$handler" ] || { echo "no deploy_frank_tts handler"; false; }
|
||||||
|
printf '%s' "$handler" | grep -qF 'build_compose_image frank-tts'
|
||||||
|
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts'
|
||||||
|
# The engine's quadlet pair is installed by this handler too: with the
|
||||||
|
# referenced .network file missing the generator produces no façade unit at
|
||||||
|
# all, and `--only frank-tts` has no other way to get it onto the host.
|
||||||
|
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine'
|
||||||
|
printf '%s' "$handler" | grep -qE 'mkdir -p /home/dank/voices'
|
||||||
|
printf '%s' "$handler" | grep -qE 'systemctl --user restart frank-tts\.service'
|
||||||
|
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts [0-9]+'
|
||||||
|
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an image rebuild is triggered by every file the image bakes in" {
|
||||||
|
# build_compose_image skips `podman build` when the image's cfhash label
|
||||||
|
# matches a hash — the thing that keeps the engine's 10-minute source clone
|
||||||
|
# from running on every re-deploy. If that hash covers only the Containerfile,
|
||||||
|
# then editing app.py (COPY'd into the façade) or the warm hook (COPY'd into
|
||||||
|
# the engine) rebuilds NOTHING: the deploy reports success, /health answers,
|
||||||
|
# and the container is still running last week's code. Observable output, not
|
||||||
|
# a grep of the handler: did the deploy ask podman to build, or did it skip?
|
||||||
|
awk '/^build_compose_image\(\) \{/,/^\}/' "$REMOTE" > "$TEST_TMP/bci.sh"
|
||||||
|
[ -s "$TEST_TMP/bci.sh" ] || { echo "build_compose_image not extractable from $REMOTE"; false; }
|
||||||
|
|
||||||
|
repo="$TEST_TMP/repo"
|
||||||
|
mkdir -p "$repo/dankclaw/voiceservice"
|
||||||
|
: > "$repo/dankclaw/voiceservice/Containerfile"
|
||||||
|
: > "$repo/dankclaw/voiceservice/Containerfile.engine"
|
||||||
|
: > "$repo/dankclaw/voiceservice/app.py"
|
||||||
|
: > "$repo/dankclaw/voiceservice/tts-engine-start.sh"
|
||||||
|
: > "$repo/dankclaw/voiceservice/tts-engine-warm.sh"
|
||||||
|
|
||||||
|
# Stub podman: `image inspect` replays the label this stub stored on the last
|
||||||
|
# build of THAT image (nothing the first time), `build` records the tag and
|
||||||
|
# writes the label it was given. One state file per image, so the pair's two
|
||||||
|
# images cannot borrow each other's freshness.
|
||||||
|
mkdir -p "$TEST_TMP/bin" "$TEST_TMP/labels"
|
||||||
|
cat > "$TEST_TMP/bin/podman" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Image tags contain a slash (`franksdank/foo:latest`), so the state file name
|
||||||
|
# flattens it — otherwise the write lands in a directory that does not exist and
|
||||||
|
# every image looks permanently unbuilt.
|
||||||
|
case "$1" in
|
||||||
|
image) # invoked as `podman image inspect <image> --format …`, so $3 is the tag
|
||||||
|
f="$LABEL_DIR/${3//\//_}"
|
||||||
|
[[ -s "$f" ]] && { cat "$f"; exit 0; }
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
# Two passes: `podman build` puts `--label cfhash=…` BEFORE `-t <tag>`, so
|
||||||
|
# the tag is not known when the label argument goes by.
|
||||||
|
tag=""
|
||||||
|
label=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
*:*) tag="$a" ;;
|
||||||
|
cfhash=*) label="${a#cfhash=}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "$label" ]] && printf '%s' "$label" > "$LABEL_DIR/${tag//\//_}"
|
||||||
|
printf 'BUILD %s\n' "$tag" >> "$BUILD_LOG"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
chmod +x "$TEST_TMP/bin/podman"
|
||||||
|
cat > "$TEST_TMP/drive.sh" <<'DRV'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
source "$BCI"
|
||||||
|
build_compose_image "$1"
|
||||||
|
DRV
|
||||||
|
: > "$TEST_TMP/builds"
|
||||||
|
builds() { grep -cF "BUILD $1" "$TEST_TMP/builds"; }
|
||||||
|
drive() {
|
||||||
|
( cd "$repo" && BCI="$TEST_TMP/bci.sh" PATH="$TEST_TMP/bin:$PATH" \
|
||||||
|
LABEL_DIR="$TEST_TMP/labels" BUILD_LOG="$TEST_TMP/builds" \
|
||||||
|
bash "$TEST_TMP/drive.sh" "$1" )
|
||||||
|
}
|
||||||
|
|
||||||
|
drive frank-tts-engine
|
||||||
|
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \
|
||||||
|
|| { echo "the first deploy of an unbuilt image must build"; false; }
|
||||||
|
drive frank-tts-engine
|
||||||
|
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \
|
||||||
|
|| { echo "an unchanged tree must not rebuild — that is the 10-minute clone"; false; }
|
||||||
|
|
||||||
|
printf '# the boot warmer grew a line\n' >> "$repo/dankclaw/voiceservice/tts-engine-warm.sh"
|
||||||
|
drive frank-tts-engine
|
||||||
|
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 2 ] \
|
||||||
|
|| { echo "editing a script the image COPYs must rebuild it"; false; }
|
||||||
|
|
||||||
|
drive frank-tts
|
||||||
|
[ "$(builds franksdank/frank-tts:latest)" -eq 1 ] || { echo "the façade must build once"; false; }
|
||||||
|
printf '# the shim grew a route\n' >> "$repo/dankclaw/voiceservice/app.py"
|
||||||
|
drive frank-tts
|
||||||
|
[ "$(builds franksdank/frank-tts:latest)" -eq 2 ] \
|
||||||
|
|| { echo "editing app.py must rebuild the façade image"; false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the voice stack's homepage state: one card for the pair, and it is hidden while undeployed" {
|
||||||
|
# The engine is not on the homepage and must not be: it publishes no port, so
|
||||||
|
# there is no address the checker could dial, and a card that can never be
|
||||||
|
# green is worse than no card. The façade carries the pair's signal.
|
||||||
|
python3 - "$REPO_ROOT" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
root = sys.argv[1]
|
||||||
|
cards = {c["name"]: c for c in json.load(open(f"{root}/shared/homepage/services.json"))}
|
||||||
|
assert "frank-tts-engine" not in cards, \
|
||||||
|
"the engine publishes no port: a card for it could never be probed green"
|
||||||
|
fts = cards["frank-tts"]
|
||||||
|
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
|
||||||
|
assert fts["wip"] == "down" and fts["showLink"] is False, \
|
||||||
|
"nothing listens on 7772 until the deploy, so a visible card reads as down forever"
|
||||||
|
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "either deployable's paths select both voice seams" {
|
||||||
|
# A change to the bridge or the env file can break the other half of the
|
||||||
|
# pair, and the seams are cheap; routing them together is what keeps a
|
||||||
|
# façade-only commit from landing without the engine's wiring tests.
|
||||||
|
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
|
||||||
|
dankclaw/voiceservice/quadlet/frank-tts.container
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
|
||||||
|
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
|
||||||
|
|
||||||
|
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/voiceservice/app.py
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
|
||||||
|
}
|
||||||
Executable
+268
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: the voice-pack builder (scripts/build-voice-pack.sh).
|
||||||
|
#
|
||||||
|
# The contract this pins is the one frank-tts's packs live or die by. A crop is
|
||||||
|
# only usable voice material if it starts PAST the first 10 seconds of the
|
||||||
|
# source file — the project-wide source-material invariant — and this builder is
|
||||||
|
# the thing that enforces it, because nothing downstream looks at offsets again.
|
||||||
|
# A pack is three files plus provenance: reference.wav at 24 kHz mono inside the
|
||||||
|
# 18-25 s window, the engine's own speaker artifacts beside it, and a
|
||||||
|
# manifest.json whose sha256 still matches the bytes the crop came from. Builds
|
||||||
|
# refuse to clobber what is already on disk, and --force retires rather than
|
||||||
|
# deletes.
|
||||||
|
#
|
||||||
|
# No engine binary, no model, no GPU, no network and no fleet host: the crop is
|
||||||
|
# cut from a locally generated tone and the pinned extractor is stood in by a
|
||||||
|
# stub that mirrors the real tool's I/O shape (no -o flag: outputs named next to
|
||||||
|
# the input with the extension swapped, at the sizes the codec's config
|
||||||
|
# implies), which is how frank_tts_unit.bats drives its backends too.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
BUILDER="$REPO_ROOT/scripts/build-voice-pack.sh"
|
||||||
|
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
|
||||||
|
TEST_TMP="$(mktemp -d)"
|
||||||
|
PACKS="$TEST_TMP/packs"
|
||||||
|
mkdir -p "$PACKS"
|
||||||
|
STUB_ARGV="$TEST_TMP/stub-argv.tsv"
|
||||||
|
export STUB_ARGV
|
||||||
|
|
||||||
|
# Forty seconds of "recording": stereo 44.1 kHz, so a legal build has to
|
||||||
|
# downmix and resample just as it does on a real audiobook. Every crop this
|
||||||
|
# file takes starts at or past 12 s, well out of the forbidden region.
|
||||||
|
ffmpeg -v error -y -f lavfi -i "sine=frequency=330:sample_rate=44100:duration=40" \
|
||||||
|
-ac 2 -c:a pcm_s16le "$TEST_TMP/source.wav"
|
||||||
|
SOURCE_SHA="$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')"
|
||||||
|
|
||||||
|
write_stub_tool
|
||||||
|
# The shipped tunables file, then the three machine-local overrides that
|
||||||
|
# point the engine at the stub: the real env stays under test.
|
||||||
|
cat "$ENV_FILE" > "$TEST_TMP/env"
|
||||||
|
{
|
||||||
|
printf 'VOICE_PACK_DIR=%s\n' "$PACKS"
|
||||||
|
printf 'VOICE_PACK_TOOL=%s\n' "$TEST_TMP/qwen-codec"
|
||||||
|
printf 'VOICE_PACK_CODEC_MODEL=%s\n' "$TEST_TMP/codec.gguf"
|
||||||
|
printf 'VOICE_PACK_TALKER_MODEL=%s\n' "$TEST_TMP/talker.gguf"
|
||||||
|
} >> "$TEST_TMP/env"
|
||||||
|
printf 'stub-codec-weights\n' > "$TEST_TMP/codec.gguf"
|
||||||
|
printf 'stub-talker-weights\n' > "$TEST_TMP/talker.gguf"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TEST_TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_stub_tool — stand-in for qwen-codec. Records what it was handed and
|
||||||
|
# writes <input-stem>.spk / <input-stem>.rvq, exactly where the real tool would.
|
||||||
|
# STUB_SPK_BYTES lets one test be an engine that changed its x-vector size.
|
||||||
|
write_stub_tool() {
|
||||||
|
cat > "$TEST_TMP/qwen-codec" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
input="" model="" talker=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--model) model="$2"; shift 2 ;;
|
||||||
|
--talker) talker="$2"; shift 2 ;;
|
||||||
|
--device) shift 2 ;;
|
||||||
|
-i) input="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'model=%s\ttalker=%s\tinput=%s\n' "$model" "$talker" "$input" >> "$STUB_ARGV"
|
||||||
|
[ -n "$input" ] || exit 2
|
||||||
|
[ -s "$model" ] || exit 3
|
||||||
|
[ -s "$talker" ] || exit 4
|
||||||
|
dur="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$input")"
|
||||||
|
frames="$(awk -v d="$dur" 'BEGIN { printf "%d\n", int(int(d * 24000 + 0.5) / 1920) }')"
|
||||||
|
head -c "${STUB_SPK_BYTES:-8192}" /dev/zero > "${input%.wav}.spk"
|
||||||
|
head -c "$((22 * frames))" /dev/zero > "${input%.wav}.rvq"
|
||||||
|
printf '[Stub] Wrote %s.rvq: %d packed bytes\n' "${input%.wav}" "$((22 * frames))" >&2
|
||||||
|
STUB
|
||||||
|
chmod +x "$TEST_TMP/qwen-codec"
|
||||||
|
}
|
||||||
|
|
||||||
|
# build — a real (non-dry) run against the stub tool with the tone as source.
|
||||||
|
build() {
|
||||||
|
local voice="$1"
|
||||||
|
shift
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" \
|
||||||
|
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" "$@" "$voice" "$PACKS"
|
||||||
|
}
|
||||||
|
|
||||||
|
pack_files() {
|
||||||
|
ls "$PACKS/$1" 2>/dev/null | sort | tr '\n' ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop offset inside the first ten seconds is refused and names the invariant" {
|
||||||
|
build narrator1 --offset 4.5 --duration 20
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"source-material invariant"* ]]
|
||||||
|
[[ "$output" == *"first 10 s"* ]]
|
||||||
|
# Refused before anything was created, not cleaned up afterwards.
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the identical crop at a legal offset builds the whole pack" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"CROP 12 s .. 32 s"* ]]
|
||||||
|
[[ "$output" == *"VOICE_PACK_BUILT narrator1 $PACKS/narrator1"* ]]
|
||||||
|
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||||
|
[ -f "$PACKS/narrator1/reference.spk" ]
|
||||||
|
[ -f "$PACKS/narrator1/reference.rvq" ]
|
||||||
|
[ -f "$PACKS/narrator1/manifest.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "reference.wav comes out 24 kHz mono pcm inside the 18-25 s window" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
local wav="$PACKS/narrator1/reference.wav"
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$wav")" = "pcm_s16le" ]
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$wav")" = "24000" ]
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$wav")" = "1" ]
|
||||||
|
local dur
|
||||||
|
dur="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$wav")"
|
||||||
|
awk -v d="$dur" 'BEGIN { exit !(d >= 18 && d <= 25) }'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the manifest round-trips through jq with offsets and a sha that matches the source" {
|
||||||
|
build narrator2 --offset 15 --duration 22.5
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
local manifest="$PACKS/narrator2/manifest.json"
|
||||||
|
jq -e '
|
||||||
|
.voice == "narrator2"
|
||||||
|
and (.crop.offset_seconds == 15)
|
||||||
|
and (.crop.end_seconds == 37.5)
|
||||||
|
and (.reference.sample_rate == 24000)
|
||||||
|
and (.reference.channels == 1)
|
||||||
|
and (.reference.duration_seconds >= 18 and .reference.duration_seconds <= 25)
|
||||||
|
and (.artifacts.engine_commit | test("^[0-9a-f]{40}$"))
|
||||||
|
and (.artifacts.produced == true)
|
||||||
|
and (.invariant.forbidden_source_seconds == 10)
|
||||||
|
' "$manifest"
|
||||||
|
# The recorded hash is the source's hash, not a hash of something else.
|
||||||
|
[ "$(jq -r .source.sha256 "$manifest")" = "$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')" ]
|
||||||
|
# The pack's provenance names the tool that made the artifacts and the files
|
||||||
|
# it wrote beside the WAV.
|
||||||
|
[ "$(jq -r .artifacts.tool "$manifest")" = "$TEST_TMP/qwen-codec" ]
|
||||||
|
grep -q "input=$PACKS/narrator2/staging-reference.wav" "$STUB_ARGV"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a wrong pinned sha stops the build before a crop is cut" {
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||||
|
--sha256 "$(printf '0%.0s' {1..64})" --offset 12 --duration 20 narrator1 "$PACKS"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"sha256"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop that runs off the end of the source is refused" {
|
||||||
|
build narrator1 --offset 30 --duration 20
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"crop would come out short"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop under the engine's artifact window is refused before the extractor runs" {
|
||||||
|
# 18.5 s is inside the reference window but packs to 22 * 231 = 5,082 B of
|
||||||
|
# .rvq, under the 5,456 B the pinned codec config produces — the builder
|
||||||
|
# says so rather than running an extractor that would emit it anyway.
|
||||||
|
build narrator1 --offset 12 --duration 18.5
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"5456"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an extractor whose artifact size changed does not get a published voice" {
|
||||||
|
# The engine-bump case: a 4,096 B x-vector is not the 8,192 B the pinned
|
||||||
|
# encoder emits, so the pack must not reach the state the shim reads.
|
||||||
|
export STUB_SPK_BYTES=4096
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"reference.spk"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1/reference.wav" ]
|
||||||
|
[ ! -e "$PACKS/narrator1/manifest.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "re-running without --force refuses and leaves every byte alone" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
sha256sum "$PACKS"/narrator1/* > "$TEST_TMP/before.sha"
|
||||||
|
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"--force"* ]]
|
||||||
|
sha256sum -c --quiet "$TEST_TMP/before.sha"
|
||||||
|
[ "$(find "$PACKS" -name .AGENTTRASH -prune -o -type f -newer "$TEST_TMP/before.sha" -print | wc -l)" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--force retires the previous pack instead of deleting anything" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
cp "$PACKS/narrator1/reference.wav" "$TEST_TMP/first-reference.wav"
|
||||||
|
|
||||||
|
build narrator1 --offset 12 --duration 20 --force
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"RETIRED"* ]]
|
||||||
|
# The old bytes survive, whole, under the trash directory beside the pack.
|
||||||
|
local retired
|
||||||
|
retired="$(find "$PACKS/.AGENTTRASH" -name reference.wav | head -n1)"
|
||||||
|
[ -n "$retired" ]
|
||||||
|
cmp "$retired" "$TEST_TMP/first-reference.wav"
|
||||||
|
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--dry-run needs no extractor at all" {
|
||||||
|
# Everything up to the engine: the gate, the crop, the probe, the plan and
|
||||||
|
# the manifest it would write — with the tool path pointed at nothing.
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --tool "$TEST_TMP/no-such-tool" \
|
||||||
|
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" \
|
||||||
|
--offset 12 --duration 20 --dry-run narrator1 "$PACKS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"not present"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
printf '%s\n' "$output" | awk '/^MANIFEST_BEGIN$/{p=1;next} /^MANIFEST_END$/{p=0} p' \
|
||||||
|
| jq -e '.voice == "narrator1" and .crop.offset_seconds == 12 and .artifacts.produced == false'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a name the façade could not resolve is refused" {
|
||||||
|
# app.py matches the directory name verbatim while the engine lowercases
|
||||||
|
# names, and a slash would escape $VOICES_DIR: both are refused here, at the
|
||||||
|
# only moment anyone can still fix them.
|
||||||
|
for bad in 'Narrator1' '../evil' 'voice name'; do
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||||
|
--offset 12 --duration 20 "$bad" "$PACKS"
|
||||||
|
[ "$status" -ne 0 ] || { echo "accepted '$bad': $output"; false; }
|
||||||
|
[[ "$output" == *"voice name"* ]]
|
||||||
|
done
|
||||||
|
[ ! -e "$PACKS/Narrator1" ]
|
||||||
|
[ ! -e "$PACKS/evil" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "every crop in the tracked pack list clears the invariant" {
|
||||||
|
# The law applied to the values the repo actually ships: no pack may be
|
||||||
|
# described by an offset inside the forbidden region, a length outside the
|
||||||
|
# reference window, or a source with no digest to verify it against.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
local checked=0 pack upper
|
||||||
|
read -r -a packs <<<"$VOICE_PACKS"
|
||||||
|
for pack in "${packs[@]}"; do
|
||||||
|
upper="$(printf '%s' "$pack" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
local offset_var="VOICE_PACK_${upper}_OFFSET" duration_var="VOICE_PACK_${upper}_DURATION"
|
||||||
|
local sha_var="VOICE_PACK_${upper}_SHA256" source_var="VOICE_PACK_${upper}_SOURCE"
|
||||||
|
[ -n "${!source_var:-}" ] || { echo "$pack has no source path"; false; }
|
||||||
|
printf '%s' "${!sha_var}" | grep -Eq '^[0-9a-f]{64}$' || { echo "$pack has no pinned sha256"; false; }
|
||||||
|
awk -v o="${!offset_var}" 'BEGIN { exit !(o >= 10) }' || { echo "$pack crops at ${!offset_var} s, inside the forbidden first 10 s"; false; }
|
||||||
|
awk -v d="${!duration_var}" 'BEGIN { exit !(d >= 18 && d <= 25) }' || { echo "$pack crops ${!duration_var} s, outside 18-25 s"; false; }
|
||||||
|
checked=$((checked + 1))
|
||||||
|
done
|
||||||
|
[ "$checked" -gt 0 ]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user