#!/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 ] }