feat(voice-packs): the builder that turns a crop into a deployable voice pack #13
Executable
+483
@@ -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/<voice>/ 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/<voice>-<UTC>/ 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
|
||||||
@@ -229,6 +229,14 @@ for path in "${CHANGED[@]}"; do
|
|||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
add_test tests/deployment_scoping.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/*)
|
scripts/*)
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <gguf> [-i <input>] [--talker <gguf>] [--format <fmt>]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--model <gguf> Codec GGUF (qwen-tokenizer-12hz-*.gguf)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--device <name> Compute device: CUDA0, Vulkan0, MTL0, CPU...
|
||||||
|
(default: best available)
|
||||||
|
-i <path> Input. WAV -> encode, .rvq -> decode
|
||||||
|
--talker <gguf> Talker GGUF (Base only). Encode also extracts the speaker
|
||||||
|
embedding and writes it next to the .rvq as a .spk file
|
||||||
|
--format <fmt> 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/<voice>-<UTC>/` 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 <name> 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.
|
||||||
@@ -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)
|
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
|
||||||
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
- [[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
|
- [[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
|
## Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -2441,3 +2441,56 @@ every restart since the cutover.
|
|||||||
`~/.harbor/compose.opennotebook.yml` pins the deprecated `-single` image,
|
`~/.harbor/compose.opennotebook.yml` pins the deprecated `-single` image,
|
||||||
carries no encryption key and injects the whole harbor env — left untouched,
|
carries no encryption key and injects the whole harbor env — left untouched,
|
||||||
read-only, as the record of what not to copy.
|
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 <codec gguf> --talker <base talker gguf> -i <wav>`
|
||||||
|
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.
|
||||||
|
|||||||
Executable
+268
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: the voice-pack builder (scripts/build-voice-pack.sh).
|
||||||
|
#
|
||||||
|
# The contract this pins is the one frank-tts's packs live or die by. A crop is
|
||||||
|
# only usable voice material if it starts PAST the first 10 seconds of the
|
||||||
|
# source file — the project-wide source-material invariant — and this builder is
|
||||||
|
# the thing that enforces it, because nothing downstream looks at offsets again.
|
||||||
|
# A pack is three files plus provenance: reference.wav at 24 kHz mono inside the
|
||||||
|
# 18-25 s window, the engine's own speaker artifacts beside it, and a
|
||||||
|
# manifest.json whose sha256 still matches the bytes the crop came from. Builds
|
||||||
|
# refuse to clobber what is already on disk, and --force retires rather than
|
||||||
|
# deletes.
|
||||||
|
#
|
||||||
|
# No engine binary, no model, no GPU, no network and no fleet host: the crop is
|
||||||
|
# cut from a locally generated tone and the pinned extractor is stood in by a
|
||||||
|
# stub that mirrors the real tool's I/O shape (no -o flag: outputs named next to
|
||||||
|
# the input with the extension swapped, at the sizes the codec's config
|
||||||
|
# implies), which is how frank_tts_unit.bats drives its backends too.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
BUILDER="$REPO_ROOT/scripts/build-voice-pack.sh"
|
||||||
|
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
|
||||||
|
TEST_TMP="$(mktemp -d)"
|
||||||
|
PACKS="$TEST_TMP/packs"
|
||||||
|
mkdir -p "$PACKS"
|
||||||
|
STUB_ARGV="$TEST_TMP/stub-argv.tsv"
|
||||||
|
export STUB_ARGV
|
||||||
|
|
||||||
|
# Forty seconds of "recording": stereo 44.1 kHz, so a legal build has to
|
||||||
|
# downmix and resample just as it does on a real audiobook. Every crop this
|
||||||
|
# file takes starts at or past 12 s, well out of the forbidden region.
|
||||||
|
ffmpeg -v error -y -f lavfi -i "sine=frequency=330:sample_rate=44100:duration=40" \
|
||||||
|
-ac 2 -c:a pcm_s16le "$TEST_TMP/source.wav"
|
||||||
|
SOURCE_SHA="$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')"
|
||||||
|
|
||||||
|
write_stub_tool
|
||||||
|
# The shipped tunables file, then the three machine-local overrides that
|
||||||
|
# point the engine at the stub: the real env stays under test.
|
||||||
|
cat "$ENV_FILE" > "$TEST_TMP/env"
|
||||||
|
{
|
||||||
|
printf 'VOICE_PACK_DIR=%s\n' "$PACKS"
|
||||||
|
printf 'VOICE_PACK_TOOL=%s\n' "$TEST_TMP/qwen-codec"
|
||||||
|
printf 'VOICE_PACK_CODEC_MODEL=%s\n' "$TEST_TMP/codec.gguf"
|
||||||
|
printf 'VOICE_PACK_TALKER_MODEL=%s\n' "$TEST_TMP/talker.gguf"
|
||||||
|
} >> "$TEST_TMP/env"
|
||||||
|
printf 'stub-codec-weights\n' > "$TEST_TMP/codec.gguf"
|
||||||
|
printf 'stub-talker-weights\n' > "$TEST_TMP/talker.gguf"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TEST_TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_stub_tool — stand-in for qwen-codec. Records what it was handed and
|
||||||
|
# writes <input-stem>.spk / <input-stem>.rvq, exactly where the real tool would.
|
||||||
|
# STUB_SPK_BYTES lets one test be an engine that changed its x-vector size.
|
||||||
|
write_stub_tool() {
|
||||||
|
cat > "$TEST_TMP/qwen-codec" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
input="" model="" talker=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--model) model="$2"; shift 2 ;;
|
||||||
|
--talker) talker="$2"; shift 2 ;;
|
||||||
|
--device) shift 2 ;;
|
||||||
|
-i) input="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'model=%s\ttalker=%s\tinput=%s\n' "$model" "$talker" "$input" >> "$STUB_ARGV"
|
||||||
|
[ -n "$input" ] || exit 2
|
||||||
|
[ -s "$model" ] || exit 3
|
||||||
|
[ -s "$talker" ] || exit 4
|
||||||
|
dur="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$input")"
|
||||||
|
frames="$(awk -v d="$dur" 'BEGIN { printf "%d\n", int(int(d * 24000 + 0.5) / 1920) }')"
|
||||||
|
head -c "${STUB_SPK_BYTES:-8192}" /dev/zero > "${input%.wav}.spk"
|
||||||
|
head -c "$((22 * frames))" /dev/zero > "${input%.wav}.rvq"
|
||||||
|
printf '[Stub] Wrote %s.rvq: %d packed bytes\n' "${input%.wav}" "$((22 * frames))" >&2
|
||||||
|
STUB
|
||||||
|
chmod +x "$TEST_TMP/qwen-codec"
|
||||||
|
}
|
||||||
|
|
||||||
|
# build — a real (non-dry) run against the stub tool with the tone as source.
|
||||||
|
build() {
|
||||||
|
local voice="$1"
|
||||||
|
shift
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" \
|
||||||
|
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" "$@" "$voice" "$PACKS"
|
||||||
|
}
|
||||||
|
|
||||||
|
pack_files() {
|
||||||
|
ls "$PACKS/$1" 2>/dev/null | sort | tr '\n' ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop offset inside the first ten seconds is refused and names the invariant" {
|
||||||
|
build narrator1 --offset 4.5 --duration 20
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"source-material invariant"* ]]
|
||||||
|
[[ "$output" == *"first 10 s"* ]]
|
||||||
|
# Refused before anything was created, not cleaned up afterwards.
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the identical crop at a legal offset builds the whole pack" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"CROP 12 s .. 32 s"* ]]
|
||||||
|
[[ "$output" == *"VOICE_PACK_BUILT narrator1 $PACKS/narrator1"* ]]
|
||||||
|
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||||
|
[ -f "$PACKS/narrator1/reference.spk" ]
|
||||||
|
[ -f "$PACKS/narrator1/reference.rvq" ]
|
||||||
|
[ -f "$PACKS/narrator1/manifest.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "reference.wav comes out 24 kHz mono pcm inside the 18-25 s window" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
local wav="$PACKS/narrator1/reference.wav"
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$wav")" = "pcm_s16le" ]
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$wav")" = "24000" ]
|
||||||
|
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$wav")" = "1" ]
|
||||||
|
local dur
|
||||||
|
dur="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$wav")"
|
||||||
|
awk -v d="$dur" 'BEGIN { exit !(d >= 18 && d <= 25) }'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the manifest round-trips through jq with offsets and a sha that matches the source" {
|
||||||
|
build narrator2 --offset 15 --duration 22.5
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
local manifest="$PACKS/narrator2/manifest.json"
|
||||||
|
jq -e '
|
||||||
|
.voice == "narrator2"
|
||||||
|
and (.crop.offset_seconds == 15)
|
||||||
|
and (.crop.end_seconds == 37.5)
|
||||||
|
and (.reference.sample_rate == 24000)
|
||||||
|
and (.reference.channels == 1)
|
||||||
|
and (.reference.duration_seconds >= 18 and .reference.duration_seconds <= 25)
|
||||||
|
and (.artifacts.engine_commit | test("^[0-9a-f]{40}$"))
|
||||||
|
and (.artifacts.produced == true)
|
||||||
|
and (.invariant.forbidden_source_seconds == 10)
|
||||||
|
' "$manifest"
|
||||||
|
# The recorded hash is the source's hash, not a hash of something else.
|
||||||
|
[ "$(jq -r .source.sha256 "$manifest")" = "$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')" ]
|
||||||
|
# The pack's provenance names the tool that made the artifacts and the files
|
||||||
|
# it wrote beside the WAV.
|
||||||
|
[ "$(jq -r .artifacts.tool "$manifest")" = "$TEST_TMP/qwen-codec" ]
|
||||||
|
grep -q "input=$PACKS/narrator2/staging-reference.wav" "$STUB_ARGV"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a wrong pinned sha stops the build before a crop is cut" {
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||||
|
--sha256 "$(printf '0%.0s' {1..64})" --offset 12 --duration 20 narrator1 "$PACKS"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"sha256"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop that runs off the end of the source is refused" {
|
||||||
|
build narrator1 --offset 30 --duration 20
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"crop would come out short"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a crop under the engine's artifact window is refused before the extractor runs" {
|
||||||
|
# 18.5 s is inside the reference window but packs to 22 * 231 = 5,082 B of
|
||||||
|
# .rvq, under the 5,456 B the pinned codec config produces — the builder
|
||||||
|
# says so rather than running an extractor that would emit it anyway.
|
||||||
|
build narrator1 --offset 12 --duration 18.5
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"5456"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an extractor whose artifact size changed does not get a published voice" {
|
||||||
|
# The engine-bump case: a 4,096 B x-vector is not the 8,192 B the pinned
|
||||||
|
# encoder emits, so the pack must not reach the state the shim reads.
|
||||||
|
export STUB_SPK_BYTES=4096
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"reference.spk"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1/reference.wav" ]
|
||||||
|
[ ! -e "$PACKS/narrator1/manifest.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "re-running without --force refuses and leaves every byte alone" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
sha256sum "$PACKS"/narrator1/* > "$TEST_TMP/before.sha"
|
||||||
|
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -ne 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"--force"* ]]
|
||||||
|
sha256sum -c --quiet "$TEST_TMP/before.sha"
|
||||||
|
[ "$(find "$PACKS" -name .AGENTTRASH -prune -o -type f -newer "$TEST_TMP/before.sha" -print | wc -l)" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--force retires the previous pack instead of deleting anything" {
|
||||||
|
build narrator1 --offset 12 --duration 20
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
cp "$PACKS/narrator1/reference.wav" "$TEST_TMP/first-reference.wav"
|
||||||
|
|
||||||
|
build narrator1 --offset 12 --duration 20 --force
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"RETIRED"* ]]
|
||||||
|
# The old bytes survive, whole, under the trash directory beside the pack.
|
||||||
|
local retired
|
||||||
|
retired="$(find "$PACKS/.AGENTTRASH" -name reference.wav | head -n1)"
|
||||||
|
[ -n "$retired" ]
|
||||||
|
cmp "$retired" "$TEST_TMP/first-reference.wav"
|
||||||
|
[ -f "$PACKS/narrator1/reference.wav" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--dry-run needs no extractor at all" {
|
||||||
|
# Everything up to the engine: the gate, the crop, the probe, the plan and
|
||||||
|
# the manifest it would write — with the tool path pointed at nothing.
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --tool "$TEST_TMP/no-such-tool" \
|
||||||
|
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" \
|
||||||
|
--offset 12 --duration 20 --dry-run narrator1 "$PACKS"
|
||||||
|
[ "$status" -eq 0 ] || { echo "$output"; false; }
|
||||||
|
[[ "$output" == *"not present"* ]]
|
||||||
|
[ ! -e "$PACKS/narrator1" ]
|
||||||
|
[ ! -s "$STUB_ARGV" ]
|
||||||
|
printf '%s\n' "$output" | awk '/^MANIFEST_BEGIN$/{p=1;next} /^MANIFEST_END$/{p=0} p' \
|
||||||
|
| jq -e '.voice == "narrator1" and .crop.offset_seconds == 12 and .artifacts.produced == false'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a name the façade could not resolve is refused" {
|
||||||
|
# app.py matches the directory name verbatim while the engine lowercases
|
||||||
|
# names, and a slash would escape $VOICES_DIR: both are refused here, at the
|
||||||
|
# only moment anyone can still fix them.
|
||||||
|
for bad in 'Narrator1' '../evil' 'voice name'; do
|
||||||
|
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
|
||||||
|
--offset 12 --duration 20 "$bad" "$PACKS"
|
||||||
|
[ "$status" -ne 0 ] || { echo "accepted '$bad': $output"; false; }
|
||||||
|
[[ "$output" == *"voice name"* ]]
|
||||||
|
done
|
||||||
|
[ ! -e "$PACKS/Narrator1" ]
|
||||||
|
[ ! -e "$PACKS/evil" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "every crop in the tracked pack list clears the invariant" {
|
||||||
|
# The law applied to the values the repo actually ships: no pack may be
|
||||||
|
# described by an offset inside the forbidden region, a length outside the
|
||||||
|
# reference window, or a source with no digest to verify it against.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
local checked=0 pack upper
|
||||||
|
read -r -a packs <<<"$VOICE_PACKS"
|
||||||
|
for pack in "${packs[@]}"; do
|
||||||
|
upper="$(printf '%s' "$pack" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
local offset_var="VOICE_PACK_${upper}_OFFSET" duration_var="VOICE_PACK_${upper}_DURATION"
|
||||||
|
local sha_var="VOICE_PACK_${upper}_SHA256" source_var="VOICE_PACK_${upper}_SOURCE"
|
||||||
|
[ -n "${!source_var:-}" ] || { echo "$pack has no source path"; false; }
|
||||||
|
printf '%s' "${!sha_var}" | grep -Eq '^[0-9a-f]{64}$' || { echo "$pack has no pinned sha256"; false; }
|
||||||
|
awk -v o="${!offset_var}" 'BEGIN { exit !(o >= 10) }' || { echo "$pack crops at ${!offset_var} s, inside the forbidden first 10 s"; false; }
|
||||||
|
awk -v d="${!duration_var}" 'BEGIN { exit !(d >= 18 && d <= 25) }' || { echo "$pack crops ${!duration_var} s, outside 18-25 s"; false; }
|
||||||
|
checked=$((checked + 1))
|
||||||
|
done
|
||||||
|
[ "$checked" -gt 0 ]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user