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.
This commit is contained in:
Frank
2026-10-18 17:18:30 -07:00
parent 2242fcfec0
commit 042003a969
3 changed files with 399 additions and 0 deletions
+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
+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 ]
}