feat(voice-packs): the builder that turns a crop into a deployable voice pack #13

Merged
dank merged 4 commits from feat/voice-pack-builder into main 2026-10-18 17:41:11 -07:00
7 changed files with 1187 additions and 0 deletions
+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
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
+53
View File
@@ -2441,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 ]
}