Compare commits

..
Author SHA1 Message Date
Frank 2427fc448f docs(voice-packs): runbook, index link, and the log entry
The page a new operator needs: what the three required files are and who
reads each one, how a pack is built (with the extractor's usage text
verbatim, because the flags are not guessable and there is no -o), the
10-second gate and where it fires twice, how to add a fourth voice
including the warm hook that registers a pack without restarting the
engine, and the engine-bump procedure for when the artifact sizes move.
Plus the host census as of 2026-10-18 — the trial packs carry no
artifacts and their manifest is the trial's own schema, so the builder's
first real run is a --force migration — and the three gaps left open on
purpose: no curated ICL transcript, no 16 kHz variant, no loudness pass.

Linked from the Dankclaw services list; log entry appended oldest-first.
2026-10-18 17:25:34 -07:00
Frank 3824d03ac5 Merge remote-tracking branch 'origin/main' into feat/voice-pack-builder 2026-10-18 17:18:31 -07:00
Frank 042003a969 feat(voice-packs): tracked tunables, the bats seam, and its wiring
scripts/voice-packs.env carries the decisions worth a diff and nothing
else: the pack list, each voice's source file, crop offset, crop length
and pinned source digest, the extractor path with its engine repo and
commit, and the artifact arithmetic. Paths and numbers only — the pack
directories themselves stay host state under /home/dank/voices.

tests/voice_pack_unit.bats pins behaviour, from inputs to observable
output: a crop inside the first ten seconds exits nonzero naming the
invariant and creates nothing, a legal one builds the whole pack, the
reference comes out 24 kHz mono inside 18-25 s (ffprobe, on a locally
generated tone), the manifest round-trips through jq with offsets and a
sha256 that matches the source bytes, a re-run without --force leaves
every byte untouched, --force retires rather than deletes, --dry-run
needs no extractor at all, and a stub extractor that changed its artifact
size cannot publish a voice. No engine binary, no GPU, no network, no
fleet host: qwen-codec is stood in by a stub with the real tool's I/O
shape, the way frank_tts_unit.bats drives its backends.

test-changed.sh routes both new paths to that seam instead of letting
them land on the generic scripts/* syntax fallback.
2026-10-18 17:18:30 -07:00
Frank 2242fcfec0 feat(voice-packs): builder — crop, pinned artifacts, manifest, 10 s gate
Turns a source recording plus a crop offset into the pack frank-tts
resolves by name: reference.wav (24 kHz mono pcm_s16le, 18-25 s), the
engine's own reference.spk / reference.rvq made from it, and manifest.json
provenance (source path + sha256, crop start/end, sample rate, duration,
tool, engine commit, voice name).

The extractor is qwen-codec from the pinned qwentts.cpp tree, and the
flags are its own usage text, not a guess: --model <codec gguf>, --talker
<base talker gguf>, -i <wav>, optional --device; there is no -o, so it
names both artifacts next to the input with the extension swapped, which
is why the pack's WAV is staged under its final stem.

The 10-second source-material invariant is enforced, not annotated: an
offset inside the forbidden region exits 1 before a directory is created,
and the gate is asserted a second time from the manifest that was just
written, together with the recorded sha256. Artifact sizes are checked
against the codec's own arithmetic (8,192 B x-vector; 22 B per 12.5 Hz
frame), so an engine bump fails the build instead of shipping a pack the
engine rejects at synthesis time — and the crop is refused up front when
it is too short to fill the .rvq window.

reference.wav is the file the façade gates on, so it lands last, after
the artifacts and the manifest: a voice name never exists on disk without
its artifacts behind it. Nothing is ever deleted — an existing pack is
refused unless --force, which retires it whole to .AGENTTRASH/ — and
--dry-run runs every step except the extractor, so the seam can test the
gate without the engine.
2026-10-18 17:18:21 -07:00
dank e13f875c94 Merge pull request 'docs(tts): record the measured CPU engine A/B behind frank-tts' (#10) from docs/tts-engine-trials into main 2026-10-18 17:15:00 -07:00
Frank 1d082a7f1d Merge remote-tracking branch 'origin/main' into docs/tts-engine-trials
# Conflicts:
#	serverAppConfigs-docs/log.md
2026-10-18 16:54:13 -07:00
Frank 5c8ac208a0 Merge remote-tracking branch 'origin/main' into docs/tts-engine-trials 2026-10-18 16:51:01 -07:00
Frank dbc2c67bcb docs(tts): record the arm A engine decision and the loopback-publish law
Frank heard every audition pair and passed it, so likeness stopped being the
tiebreak and the engine was picked on the live-output criterion: arm A
qwentts.cpp 5916bf7, resident tts-server, 1.7 B talker, llama-tts-cli kept as
the rollback. Records the discriminators (first audio byte, warm RTF, spread),
the three consequences that are configuration (boot-warm every voice, no cpuset
pin, WAV-only registration), the two follow-ups left open on purpose, and the
corrected addressing law: a host-loopback publish is unreachable from a bridge
container, so the pair shares one quadlet network.
2026-10-18 16:51:01 -07:00
dank 1eb8b2199a Merge pull request 'feat(open-notebook): podcast pipeline on Dankclaw (blocked on frank-tts)' (#12) from feat/open-notebook-podcast into main 2026-10-18 16:50:47 -07:00
dank 0e4bb327da Merge pull request 'docs: attribution footer for machine-opened issues and PRs' (#11) from chore/pr-attribution-trailer into main 2026-10-18 16:46:00 -07:00
Frank e030486ce6 docs: pin cwd meaning in attribution footer
Mirror of command-center clarification: first dogfooding round stamped the
build worktree instead of the session's own working directory. Rule text
now says which one, and to note the build worktree in the body.
2026-10-18 16:41:21 -07:00
Frank d89ae23195 docs: attribution footer for machine-opened issues and PRs
Mirror of dank/command-center's rule (there: PR off docs/session-trailer-rule).
Every agent-opened issue/PR body ends with cwd + omp session id so machine-filed
artifacts trace back to the box and session that produced them.

Refs #11 (command-center tracker)
2026-10-18 16:32:59 -07:00
Frank 0e7936f94e docs(tts): record the measured CPU engine A/B behind frank-tts
Both candidate :8880 engines were measured on Dankclaw's CPU (no GPU,
no container, nothing deployed). arm A qwentts.cpp 5916bf7 resident at
RTF 0.55-0.57 warm / 0.67 ICL / 1.65 cold; arm B mainline llama-tts at
0.93-0.99 with a +-0.25 spread. Records the four configuration laws the
trial produced (boot-warm every voice, .spk+.rvq replay is byte-identical,
streaming != buffered, /v1/audio/voices rejects a lone spk_b64), the
likeness measurement's resolution limit, and re-keys the stale
8890/voiceshim shape on that page to frank-tts on 7772. The engine pick
stays open: the likeness metric cannot rank clones, so the audition clips
decide.
2026-10-18 16:19:51 -07:00
9 changed files with 1406 additions and 25 deletions
+9
View File
@@ -71,6 +71,15 @@ the same regime.
7. **Pull** on the target (`deploy.sh` does this)
8. **Deploy** — `scripts/deploy.sh` plans and dispatches only changed services
**Attribution footer (issues and PRs)** — every issue or PR body you open MUST end with:
```
---
cwd: <working directory> · session: <omp session id>
```
Session id from `/status`; `cwd` is that session's own working directory — not the build checkout, when they differ (note the build worktree in the body instead). Machine-made artifacts stay attributable to the box and session that spawned them.
## Deployment
```
+483
View File
@@ -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
+8
View File
@@ -229,6 +229,14 @@ for path in "${CHANGED[@]}"; do
add_test tests/scripts_syntax.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/*)
add_test tests/scripts_syntax.bats
;;
+123
View File
@@ -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
@@ -0,0 +1,251 @@
---
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
restart the engine rather than using the warm hook: the hook only registers
packs it has **not** seen, and every voice here is already registered against
the old artifacts. `systemctl --user restart frank-tts-engine.service`
re-warms them all from the new bytes.
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.
@@ -1,7 +1,7 @@
---
type: research
title: Local TTS and Voice Cloning
description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the full local AI audio stack, and a recommended homelab deployment.
description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the measured CPU engine A/B behind frank-tts and the arm A decision, the full local AI audio stack, and a recommended homelab deployment.
tags:
- research
- tts
@@ -10,7 +10,7 @@ tags:
- stt
- vllm-omni
- kokoro
timestamp: 2026-10-18T00:00:00Z
timestamp: 2026-10-18T16:05:00Z
---
# Local TTS and Voice Cloning
@@ -47,13 +47,16 @@ greenfield: every fleet endpoint declares `input: [text, image]`
local server needs a speech-role entry, not a patch.
- **Fleet plan (2026-10-18): CPU-only on Dankclaw, not the 3090.** Gitea issue
#2 rules out every GPU option (the 3090 has carried frank-zealot since
2026-10-17; both R9700s are frank-archon's), so the shape is a VoxCPM2 GGUF
engine on **8880** behind a voice-registry shim on **8890**
(`scripts/voiceshim.env`, `dankclaw/voiceservice/`) that speaks OpenAI
`/v1/audio/speech` and resolves voices by name. Nothing is deployed yet — on
2026-10-18 `voiceshim` has no unit installed and nothing answers on 8880 or
8890. STT (Parakeet TDT 1.1B, CC-BY-4.0) stays on CPU. **No RTF or quality
number has been measured for this shape**, so this page states none.
2026-10-17; both R9700s are frank-archon's). The shape is a TTS engine on
**8880** behind **frank-tts**, the fleet's speech endpoint on **7772**
(`scripts/frank-tts.env`, `dankclaw/voiceservice/`): one OpenAI
`/v1/audio/speech` façade that resolves a voice *by name* and never shows a
path. Nothing is deployed — no `frank-tts` unit and no container exist on the
host and nothing listens on 7772 or 8880 (checked 2026-10-18). STT (Parakeet
TDT 1.1B, CC-BY-4.0) stays on CPU. **The engine is chosen: arm A**
`qwentts.cpp` **resident `tts-server`, 1.7 B talker** — measured in
*Measured CPU engine A/B* below and picked on the live-output criterion in
[Decision: arm A for live output](#decision-arm-a-for-live-output-2026-10-18).
## Can Kokoro learn a new voice?
@@ -133,11 +136,11 @@ accepts only its built-in voice list, so a custom pack needs a patch.
Paths for a Frank-cloned voice (2026-10-18):
1. **Local server + speech role, no patch.** Register the local engine (or the
8890 voice shim) as a provider model on the `openai-speech` API and point
`modelRoles.speech` at it; the shim turns a voice *name* into a reference
clip and forwards it as base64 `reference_audio`
(`scripts/voiceshim.env:19-35`). Not deployed as of 2026-10-18.
1. **Local server + speech role, no patch.** Register frank-tts (port 7772) as
a provider model on the `openai-speech` API and point `modelRoles.speech` at
it; the façade turns a voice *name* into that pack's reference clip and
forwards it as base64 `reference_audio` (`scripts/frank-tts.env`,
`TTS_BACKEND=omni-http`). Not deployed as of 2026-10-18.
2. **xAI console custom voice — works today, zero code.** Record 2–3 min clean
speech (≤120 s, 24 kHz 16-bit mono WAV) → console → `voice_id` → speech
tool. Free (≤30 voices), cloud, US-only.
@@ -206,29 +209,161 @@ The CPU shape does not need a Python server at all:
`VoxCPM2-Acoustic-F16.gguf`, Apache-2.0 card) or from the fork's own
`tools/omni/voxcpm2/convert_voxcpm2_to_gguf.py`.
Nothing has been measured on this fleet for any of these engines — no RTF, no
latency, no MOS. That absence is the finding, not an omission.
Arm B (`llama-tts`) and arm A (`qwentts.cpp`) have both been measured on this
box — RTF, first-audio latency and RSS are in the next section. The other
servers in the table above (vLLM-Omni, Kokoro-FastAPI, CosyVoice, F5-TTS,
GPT-SoVITS) remain unmeasured here; that absence is still the finding.
## Measured CPU engine A/B (2026-10-18)
Both candidate upstreams for frank-tts were measured on Dankclaw's CPU — 8
threads, no GPU touched, three runs per arm, seed 42, identical text, against
the fleet's own audition voice packs.
- **Arm A — [`ServeurpersoCom/qwentts.cpp`](https://github.com/ServeurpersoCom/qwentts.cpp)**
at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (2026-10-16), weights
from [`Serveurperso/Qwen3-TTS-GGUF`](https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF):
`qwen-talker-1.7b-base-Q4_K_M.gguf` (1,219,245,248 B),
`qwen-talker-0.6b-base-Q4_K_M.gguf` (628,905,056 B),
`qwen-tokenizer-12hz-Q8_0.gguf` (291,150,624 B) — byte counts verified
against the Hub API. Builds with zero compiler warnings; `GGML_BLAS=OFF` is
forced on this host (only `libopenblas.so.0` exists, no dev headers, so the
vendor `buildcpu.sh` cannot configure).
- **Arm B — mainline `llama-tts`** with `--tts-speaker-file`, i.e. the
`llama-tts-cli` backend `scripts/frank-tts.env` already implements.
RTF is **seconds of compute per second of audio** (lower is better; below 1 is
faster than real time).
| Arm | RTF | Run spread | Peak RSS |
|---|---|---|---|
| A · 0.6 B talker, CLI | 0.44 | ±0.002 | 3.4 GiB |
| A · 1.7 B, CLI, uncloned | 0.57–0.58 | ±0.006 | 4.7 GiB |
| A · 1.7 B, CLI, embedding-only clone | 0.63 | ±0.02 | n/m |
| A · 1.7 B, CLI, ICL clone | 0.92 | ±0.01 | n/m |
| A · 1.7 B, resident `tts-server`, warm | 0.55–0.57 | ±0.01 | 5.7 GiB |
| A · same, ICL voice warm / **cold (first request)** | 0.67 / **1.65** | one-shot | 5.7 GiB |
| B · mainline `llama-tts` | 0.93–0.99 | ±0.25 | n/m |
**Read RTF only within an arm.** Conditioning changes clip length — the same
seed yields 18.00 s of audio embedding-only and 15.12 s under ICL — so the
denominator is the arm's own audio duration.
Arm A's resident server is ~1.7× faster than arm B and about 40× tighter in
run-to-run spread, and it already serves the HTTP shape frank-tts's façade
speaks: `/health`, `/v1/models`, `/v1/audio/voices` (GET/POST/DELETE),
`/v1/audio/speech` (`wav`/`pcm`), chunked streaming with the first audio byte
at 86–451 ms and a server-side time-to-first-audio of 94–112 ms. Arm B has no
HTTP path at all — that is what the façade's CLI backend exists for.
Four laws came out of the trial, each worth a line of configuration:
- **Boot-warm every voice.** The first request for a registered voice decodes
the whole reference to snapshot codec state: 6.2–8.0 s, first audio byte at
5,530 ms. Later requests restore that state in 0.5 ms. An engine unit must
run one throwaway synthesis per voice at start, or the first request of the
day pays five seconds.
- **Speaker artifacts are reusable and exact.** `qwen-codec` turns a reference
WAV into `.spk` (8,192 B x-vector) + `.rvq` (5,456–6,864 B, 16 codebooks × T,
11-bit packed) in 1.8–2.1 s at 1.3–1.5 GiB peak; replaying them yields a
**byte-identical** WAV and saves 1.4 s per request. That pair is the
engine-artifact term the voice-pack law in `scripts/frank-tts.env` reserves a
slot for — arm A pins it as `.spk` + `.rvq` beside `reference.wav`.
- **Streaming is not bit-identical to buffered**, whatever the upstream README
claims: same text, seed and voice, 92.2 % of samples differ (peak delta
1139/32768, −41.6 dB RMS — inaudible). A fixed seed does not pin output
across `response_format`, so never mix the two paths in a cache or a golden
fixture.
- **A voice cannot be registered from an embedding alone**: `POST
/v1/audio/voices` accepts `wav_b64` or **both** `spk_b64` and `rvq_b64`
(`src/tts-server.h:480`), so an embedding-only voice has to be sent as the
WAV, or the lone-`spk` path has to be upstreamed.
**Likeness is not measurable at this resolution.** x-vector cosine against each
speaker's own reference: ICL 0.9886 / 0.9901, embedding-only 0.9844 / 0.9812,
uncloned 0.9228 — but **two different speakers score 0.9497** on the same
metric. The whole clone effect lives in a 0.04-wide band and the
ICL-over-embedding gap is inside noise, so the four audition clips
(`/home/blank/voice-audition/qwentts-*.mp3`, each against its `armB-*.mp3`
mainline partner, guide in `LISTEN-engine.md`) are the instrument. **The
operator heard them on 2026-10-18 and passed every clone**, which removed the
last axis measurement could not reach; the engine was then picked on
live-output grounds ([Decision](#decision-arm-a-for-live-output-2026-10-18)).
The cosine
extractor is still worth keeping: it is engine-neutral (it scores mainline
output through the same encoder) and it separates an uncloned voice cleanly.
Two operational details. The engine takes 8 threads and exposes **no `-t`
flag** — `src/backend.h` uses `hardware_concurrency()/2` and prints the choice
at load, so pinning a cpuset on the quadlet changes that number implicitly. And
ICL needs a reference transcript that did not exist: the fleet has no ASR
endpoint and no audio input, so the crops were transcribed with
`vosk-small-en-us` inside the work dir, and two of three transcripts came back
suspect ("demon souls" for *Dark Souls*) — a reason to curate `ref_text` into
the pack manifest instead of transcribing on the host.
## Decision: arm A for live output (2026-10-18)
Every audition pair passed, so likeness stopped being the tiebreak. The
question became **which engine is better for live output** — the harness
speaking a reply, an alert read aloud, a click-to-speak — and on that criterion
RTF only has to stay under 1.0 with margin: what separates the arms is **time to
first audio** and the **spread**, because an arm whose RTF can cross 1.0 stalls
a live stream mid-sentence.
| | arm A · `qwentts.cpp` `tts-server` | arm B · mainline `llama-tts` |
|---|---|---|
| first audio byte | 86–451 ms | model load, per request |
| warm RTF | 0.55–0.67 | 0.93–0.99 |
| run spread | ±0.01 | ±0.25 |
| HTTP | serves `/v1/audio/speech` | none — façade CLI only |
| RAM | 5.7 GiB resident | per-request, freed after |
**Chosen: arm A, 1.7 B talker, resident `tts-server` in a `frank-tts-engine`
container, with `TTS_BACKEND=llama-tts-cli` kept in the repo as the rollback.**
Three consequences are configuration, not opinion: the unit **boot-warms every
voice** (5.5 s cold first byte otherwise), it **does not pin a cpuset** casually
(threads are `hardware_concurrency()/2`, no flag), and every pack registers with
the **WAV** because the engine rejects a lone embedding.
Two follow-ups stay open on purpose, and neither blocks the deploy:
- **Talker size.** The 0.6 B arm measured 0.44 RTF at 3.4 GiB and has never
been auditioned — every clip above is 1.7 B. It is the lever for a CPU
co-tenancy problem, not a preemptive saving.
- **ICL vs embedding-only.** Both passed the ear test. Embedding-only is the
shipped default: 0.57–0.63 vs 0.67 warm, and it needs no `ref_text`, which
this fleet cannot produce honestly (the ASR crops came back suspect). Packs
still carry `.spk` + `.rvq`, so ICL remains a per-voice opt-in for batch work
like the podcast render, where total time matters more than first byte.
## Recommended deployment (Dankclaw)
As decided in Gitea issue #2, this stays **CPU-only on Dankclaw**: the RTX 3090
has carried frank-zealot since 2026-10-17 and both R9700s are frank-archon's,
so no card may be asked to co-host TTS. The shape is one engine plus a thin
voice shim, and **nothing here is deployed as of 2026-10-18** — `systemctl
--user is-enabled voiceshim` reports `not-found` and nothing answers on 8880 or
8890.
so no card may be asked to co-host TTS. The shape is one engine plus one
façade, and **nothing here is deployed as of 2026-10-18**: `frank-tts` has
neither a unit nor a container on the host, and nothing listens on 7772 or
8880.
| Service | Image / install | Port | CPU | Notes |
|---|---|---|---|---|
| TTS engine | `llama-tts-server` (`tc-mb/llama.cpp-omni`) on a VoxCPM2 GGUF; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 | engine decision still open | OpenAI `/v1/audio/speech`; `n_gpu_layers` is there for a GPU boot |
| Voice shim | `dankclaw/voiceservice/` (stdlib container) + `scripts/voiceshim.env` + `dankclaw/voiceservice/quadlet/voiceshim.container` | 8890 | yes | voice *name* → `reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) |
| TTS engine | **arm A `qwentts.cpp` `tts-server`** on the Qwen3-TTS GGUFs (decided 2026-10-18, above); arm B mainline `llama-tts` stays in the repo as the `llama-tts-cli` rollback; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 (container-internal) | open, evidence above | arm A serves OpenAI `/v1/audio/speech` itself and boot-warms each voice; arm B has no HTTP path and runs through the façade's CLI backend |
| frank-tts | `dankclaw/voiceservice/` (stdlib container) + `scripts/frank-tts.env` + its quadlet | 7772 | yes | voice *name* → `/voices/<name>/reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) |
| STT (English) | Parakeet TDT 1.1B behind a FastAPI wrapper, or whisper.cpp server | 8882 | yes | CC-BY-4.0; LibriSpeech-clean WER 1.39 (model card) |
| STT (multilingual, opt.) | faster-whisper `large-v3` int8 | 8883 | yes | MIT |
| VAD | Silero (embedded) | — | yes | MIT; model file 2 327 524 B |
8880/8890 are what `scripts/voiceshim.env:35,49` carry (`TTS_UPSTREAM=
http://host.containers.internal:8880`, `SHIM_PORT=8890`); 8882-8883 for STT
keep clear of the fleet's LLM ports 7770/7771.
7772 is frank-tts's host port — the next slot in the fleet's model-endpoint
family (7770 frank-dragoon, 7771 frank-zealot). The engine behind it keeps 8880,
but **not as a host-loopback publish**: netavark DNATs published ports only, and
a bridge container cannot reach a `127.0.0.1` bind on the host — that is the
lesson that 502'd hermes (`AGENTS.md`). `host.containers.internal`
(169.254.1.2) is the gateway, so it reaches a host service only when that
service binds a routable address; `scripts/frank-tts.env` names it as the
engine's address, which holds for a `0.0.0.0` bind and not for a loopback one.
The deployed pair therefore shares one quadlet network and the façade addresses
the engine by container name.
## Voice-creation workflow (the "from sample files" answer)
@@ -284,6 +419,10 @@ keep clear of the fleet's LLM ports 7770/7771.
https://huggingface.co/openbmb/VoxCPM2
- llama.cpp-omni TTS server: https://github.com/tc-mb/llama.cpp-omni ·
https://raw.githubusercontent.com/tc-mb/llama.cpp-omni/master/tools/omni/voxcpm2/README.md
- qwentts.cpp (arm A): https://github.com/ServeurpersoCom/qwentts.cpp at commit
`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` · weights
https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF (byte counts against the
Hub API, 2026-10-18)
- mainline `llama-tts`:
https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/tts/README.md ·
Pocket-TTS weights: https://huggingface.co/kyutai/pocket-tts (CC-BY-4.0)
+1
View File
@@ -80,6 +80,7 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
- [[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
- [[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
+99
View File
@@ -2311,6 +2311,52 @@ every restart since the cutover.
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
a `pi.registerProvider` extension, decided in phase 2.
## 2026-10-18 — TTS engine picked: arm A `qwentts.cpp`, on the live-output criterion
- Frank listened to the four audition pairs (`qwentts-*.mp3` against their
`armB-*.mp3` mainline partners) and **passed every clone**. That deleted the
only axis the A/B could not measure, and the question was reframed: **which
engine is better for live output**. On that criterion RTF only has to stay
under 1.0 with margin — the discriminators are time-to-first-audio and
run-to-run spread, because an arm whose RTF can cross 1.0 stalls a live
stream mid-sentence.
- **Chosen: arm A `qwentts.cpp` @ `5916bf7`, the resident `tts-server`, 1.7 B
talker Q4_K_M + Q8_0 tokenizer, in a `frank-tts-engine` container on 8880.**
The `TTS_BACKEND=llama-tts-cli` path in `scripts/frank-tts.env` stays in the
repo as the rollback. The numbers that carried it: first audio byte 86–451 ms
against a per-request model load, warm RTF 0.55–0.67 against 0.93–0.99, spread
±0.01 against ±0.25, and a native `/v1/audio/speech` against no HTTP path at
all. Three consequences are configuration, not preference: boot-warm every
voice, do not pin a cpuset (threads are `hardware_concurrency()/2`), register
every pack with its WAV (a lone embedding is rejected).
- Two follow-ups deliberately **not** decided: the **0.6 B talker** (0.44 RTF,
3.4 GiB, never auditioned — every clip is 1.7 B) and **ICL vs
embedding-only** conditioning. Embedding-only is the shipped default because
it is cheaper and needs no `ref_text`, which this fleet cannot transcribe
honestly; `.spk` + `.rvq` still ride in every pack, so ICL remains a per-voice
opt-in for batch work where total time beats first byte.
- **Addressing law corrected** in
[[Local TTS and Voice Cloning]]: the pair may not meet at a host-loopback
publish. netavark DNATs published ports only and a bridge container cannot
reach a host `127.0.0.1` bind — the lesson that 502'd hermes — so
`host.containers.internal:8880`, which `scripts/frank-tts.env` documents, is
correct only for a routable bind. The deployed pair shares one quadlet network
and the façade addresses the engine by container name.
- **PR #12** (`feat/open-notebook-podcast` @ `114dd7a`, 8 commits, 403 tests) is
**merged** as `1eb8b21` (Frank's OK, given in session): open-notebook +
SurrealDB quadlets on the private bridge `10.89.4.0/24`, the API published on
**5056** because seerr holds `127.0.0.1:5055`, and `ESPERANTO_TTS_TIMEOUT`
moved 600 → **660** to sit one round-trip outside frank-tts's own
`SHIM_TIMEOUT`. Merged is not runnable: nothing listens on 7772 until the
voice stack lands, and the episode destination (`/home/dank/media/podcasts`)
stays interim pending Gitea #7.
- Still in flight, not in this branch: `feat/frank-tts-engine` — the arm A image
build, the quadlet pair on one network, the boot-warm step, deployer /
planner / `test-changed.sh` routing, homepage and docs. This branch is also
where the two-branch overlap got resolved: merging `main` here conflicted on
`log.md` alone (both branches appended an entry) and took `index.md`,
`deploy-remote.sh`, `deploy-plan.py` and `test-changed.sh` clean.
## 2026-10-18 — open-notebook: the podcast generator's config lands (nothing deployed)
- Goal: **open-notebook** as the fleet's podcast generator on Dankclaw — notes
@@ -2395,3 +2441,56 @@ every restart since the cutover.
`~/.harbor/compose.opennotebook.yml` pins the deprecated `-single` image,
carries no encryption key and injects the whole harbor env — left untouched,
read-only, as the record of what not to copy.
## 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.
+268
View File
@@ -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 ]
}