straywild/scripts/import_animalese_voice.sh

113 lines
3.8 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
set -euo pipefail
readonly TARGET_RMS_DBFS="-18.0"
readonly PEAK_CEILING_DBFS="-3.0"
readonly OUTPUT_SAMPLE_RATE="48000"
usage() {
printf 'usage: %s SOURCE_DIRECTORY SAMPLE_SET_DIRECTORY\n' "$0" >&2
2026-08-21 16:39:33 -04:00
printf 'example: %s /path/to/voice_clips custom_voice\n' "$0" >&2
}
if (( $# != 2 )); then
usage
exit 2
fi
if ! command -v sox >/dev/null 2>&1; then
printf 'error: sox is required to import animalese samples\n' >&2
exit 1
fi
readonly SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd)"
readonly SOURCE_DIRECTORY="$(realpath -e -- "$1")"
readonly SAMPLE_SET_DIRECTORY="$2"
if [[ ! -d "$SOURCE_DIRECTORY" ]]; then
printf 'error: source is not a directory: %s\n' "$SOURCE_DIRECTORY" >&2
exit 1
fi
if [[ ! "$SAMPLE_SET_DIRECTORY" =~ ^[a-z0-9_-]+$ ]]; then
printf 'error: sample-set directory must use lowercase letters, numbers, underscores, or hyphens\n' >&2
exit 1
fi
readonly DESTINATION_DIRECTORY="$PROJECT_ROOT/sound/dialogue/animalese/$SAMPLE_SET_DIRECTORY"
readonly WORK_DIRECTORY="$(mktemp -d -t netfishing-animalese-import.XXXXXX)"
cleanup() {
if [[ "$WORK_DIRECTORY" == /tmp/netfishing-animalese-import.* ]]; then
find "$WORK_DIRECTORY" -depth -delete
fi
}
trap cleanup EXIT
mkdir -p -- "$WORK_DIRECTORY/converted" "$WORK_DIRECTORY/normalized"
mapfile -d '' SOURCE_FILES < <(
find "$SOURCE_DIRECTORY" -maxdepth 1 -type f -name '*.wav' -print0 \
| sort -z
)
if (( ${#SOURCE_FILES[@]} == 0 )); then
printf 'error: no .wav samples found in %s\n' "$SOURCE_DIRECTORY" >&2
exit 1
fi
for source_file in "${SOURCE_FILES[@]}"; do
filename="$(basename -- "$source_file")"
if [[ ! "$filename" =~ ^[a-z0-9_]+\.wav$ ]]; then
printf 'error: unsupported sample filename: %s\n' "$filename" >&2
exit 1
fi
converted_file="$WORK_DIRECTORY/converted/$filename"
normalized_file="$WORK_DIRECTORY/normalized/$filename"
# Measure the exact channel/rate conversion that will be shipped, then use
# the smaller of the RMS correction and peak-safe gain. Very short voice
# clips are not reliable inputs for program-loudness algorithms such as
# EBU R128, so this deterministic RMS/peak policy is intentional.
# Reserve headroom before high-quality resampling. Several supplied 8-bit
# clips sit near full scale and their interpolated waveform can otherwise
# clip before the normalization gain is calculated.
sox -D "$source_file" \
-r "$OUTPUT_SAMPLE_RATE" -c 1 -b 32 -e floating-point \
"$converted_file" gain -6 rate -v "$OUTPUT_SAMPLE_RATE"
statistics="$(sox "$converted_file" -n stat 2>&1)"
peak_amplitude="$(awk '/Maximum amplitude/ {print $3}' <<< "$statistics")"
rms_amplitude="$(awk '/RMS.*amplitude/ {print $3; exit}' <<< "$statistics")"
if ! awk -v peak="$peak_amplitude" -v rms="$rms_amplitude" \
'BEGIN {exit !(peak > 0.0 && rms > 0.0)}'; then
printf 'error: sample is silent or unreadable: %s\n' "$source_file" >&2
exit 1
fi
gain_db="$(
awk \
-v peak="$peak_amplitude" \
-v rms="$rms_amplitude" \
-v target_rms="$TARGET_RMS_DBFS" \
-v peak_ceiling="$PEAK_CEILING_DBFS" \
'BEGIN {
peak_db = 20.0 * log(peak) / log(10.0)
rms_db = 20.0 * log(rms) / log(10.0)
rms_gain = target_rms - rms_db
peak_gain = peak_ceiling - peak_db
gain = rms_gain < peak_gain ? rms_gain : peak_gain
printf "%.6f", gain
}'
)"
sox -D "$converted_file" \
-r "$OUTPUT_SAMPLE_RATE" -c 1 -b 16 -e signed-integer \
"$normalized_file" gain "$gain_db"
printf '%-16s %8s dB\n' "$filename" "$gain_db"
done
mkdir -p -- "$DESTINATION_DIRECTORY"
for normalized_file in "$WORK_DIRECTORY"/normalized/*.wav; do
cp -- "$normalized_file" "$DESTINATION_DIRECTORY/$(basename -- "$normalized_file")"
done
printf 'imported %d normalized samples to %s\n' \
"${#SOURCE_FILES[@]}" "$DESTINATION_DIRECTORY"