diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index be9af68..549e8ef 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -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 ;; diff --git a/scripts/voice-packs.env b/scripts/voice-packs.env new file mode 100644 index 0000000..14d8052 --- /dev/null +++ b/scripts/voice-packs.env @@ -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 diff --git a/tests/voice_pack_unit.bats b/tests/voice_pack_unit.bats new file mode 100755 index 0000000..77d1d01 --- /dev/null +++ b/tests/voice_pack_unit.bats @@ -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 .spk / .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 ] +}