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 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/serverAppConfigs-docs/Dankclaw/Voice Packs.md b/serverAppConfigs-docs/Dankclaw/Voice Packs.md new file mode 100644 index 0000000..ce23c2d --- /dev/null +++ b/serverAppConfigs-docs/Dankclaw/Voice Packs.md @@ -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 [-i ] [--talker ] [--format ] + +Required: + --model Codec GGUF (qwen-tokenizer-12hz-*.gguf) + +Optional: + --device Compute device: CUDA0, Vulkan0, MTL0, CPU... + (default: best available) + -i Input. WAV -> encode, .rvq -> decode + --talker Talker GGUF (Base only). Encode also extracts the speaker + embedding and writes it next to the .rvq as a .spk file + --format 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/-/` 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 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. diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index fe38343..65571ad 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -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 diff --git a/serverAppConfigs-docs/log.md b/serverAppConfigs-docs/log.md index 81b1454..74fba84 100644 --- a/serverAppConfigs-docs/log.md +++ b/serverAppConfigs-docs/log.md @@ -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 --talker -i ` + 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. 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 ] +}