diff --git a/scripts/build-voice-pack.sh b/scripts/build-voice-pack.sh new file mode 100755 index 0000000..ae9d736 --- /dev/null +++ b/scripts/build-voice-pack.sh @@ -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// 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/-/ 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