feat: add selectable animalese voice sets

This commit is contained in:
Alexander Sellite 2026-08-16 12:26:05 -04:00
parent 364e7dc282
commit ce733818e2
247 changed files with 2675 additions and 302 deletions

View file

@ -1,71 +0,0 @@
extends SceneTree
const OUTPUT_DIRECTORY := "res://sound/dialogue/animalese/placeholder"
const SAMPLE_RATE: int = 22050
const SAMPLE_SECONDS: float = 0.055
const CHARACTERS := "abcdefghijklmnopqrstuvwxyz"
const VOWELS := "aeiou"
func _initialize() -> void:
var absolute_directory := ProjectSettings.globalize_path(OUTPUT_DIRECTORY)
var directory_error: Error = DirAccess.make_dir_recursive_absolute(
absolute_directory
)
if directory_error != OK:
push_error("Could not create animalese sample directory.")
quit(1)
return
for character_index: int in range(CHARACTERS.length()):
var character := CHARACTERS.substr(character_index, 1)
_save_sample(character, character_index)
_save_sample("fallback", CHARACTERS.length())
quit()
func _save_sample(sample_name: String, sample_index: int) -> void:
var stream := _build_sample(sample_name, sample_index)
var output_path := "%s/%s" % [OUTPUT_DIRECTORY, sample_name]
var save_error: Error = stream.save_to_wav(output_path)
if save_error != OK:
push_error("Could not save animalese sample: %s" % sample_name)
func _build_sample(sample_name: String, sample_index: int) -> AudioStreamWAV:
var frame_count := roundi(SAMPLE_RATE * SAMPLE_SECONDS)
var pcm := PackedByteArray()
pcm.resize(frame_count * 2)
var base_frequency := 245.0 + float(sample_index % 7) * 18.0
var formant_one := 620.0 + float(sample_index % 5) * 85.0
var formant_two := 1320.0 + float(sample_index % 6) * 115.0
var is_vowel := VOWELS.contains(sample_name)
var noise_seed := sample_index * 7919 + 104729
for frame_index: int in range(frame_count):
var time := float(frame_index) / SAMPLE_RATE
var attack := minf(time / 0.004, 1.0)
var release := minf((SAMPLE_SECONDS - time) / 0.014, 1.0)
var envelope := maxf(minf(attack, release), 0.0)
noise_seed = int(
(noise_seed * 1103515245 + 12345) & 0x7fffffff
)
var noise := float(noise_seed % 65536) / 32767.5 - 1.0
var voiced := (
sin(TAU * base_frequency * time) * 0.42
+ sin(TAU * base_frequency * 2.0 * time) * 0.16
)
var formants := (
sin(TAU * formant_one * time) * 0.18
+ sin(TAU * formant_two * time) * 0.10
)
var consonant_noise := noise * (0.08 if is_vowel else 0.24)
var value := envelope * (voiced + formants + consonant_noise) * 0.72
var signed_sample := clampi(roundi(value * 32767.0), -32768, 32767)
var encoded_sample := signed_sample if signed_sample >= 0 else signed_sample + 65536
pcm[frame_index * 2] = encoded_sample & 0xff
pcm[frame_index * 2 + 1] = (encoded_sample >> 8) & 0xff
var stream := AudioStreamWAV.new()
stream.set("format", AudioStreamWAV.FORMAT_16_BITS)
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.data = pcm
return stream

112
scripts/import_animalese_voice.sh Executable file
View file

@ -0,0 +1,112 @@
#!/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
printf 'example: %s /path/to/voice_kim kim\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"

View file

@ -9,6 +9,7 @@ readonly TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}"
readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
readonly -a QUICK_TESTS=(
"scripts/validate_animalese_samples.gd"
"tests/android_readiness_validation.gd"
"tests/controller_focus_presentation_validation.gd"
"tests/controller_focus_recovery_validation.gd"

View file

@ -0,0 +1,73 @@
extends SceneTree
const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd")
const VoiceProfilesType = preload(
"res://player/animalese_voice_profiles.gd"
)
const EXPECTED_CHARACTERS := "abcdefghijklmnopqrstuvwxyz0123456789"
const ROBOT_CHARACTERS := "abcdefghijklmnopqrstuvwxyz"
var _failures: Array[String] = []
func _initialize() -> void:
_expect(
AnimaleseVoiceType.SUPPORTED_CHARACTERS == EXPECTED_CHARACTERS,
"animalese supports every letter and digit",
)
_expect(
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID == "kat",
"kat is the default animalese voice set",
)
_expect(
VoiceProfilesType.is_valid_sample_set("robot"),
"the original tones remain available as robot",
)
_expect(
VoiceProfilesType.is_valid_sample_set("kim"),
"kim is available as an animalese voice set",
)
_validate_set("kat", EXPECTED_CHARACTERS, false)
_validate_set("robot", ROBOT_CHARACTERS, true)
_validate_set("kim", EXPECTED_CHARACTERS, false)
if _failures.is_empty():
print("Animalese sample validation: PASS")
quit(0)
return
for failure: String in _failures:
push_error(failure)
quit(1)
func _validate_set(
sample_set_id: String,
characters: String,
expects_fallback: bool,
) -> void:
var sample_directory := VoiceProfilesType.sample_directory_for(
sample_set_id
)
_expect(
not sample_directory.is_empty(),
"%s has a sample directory" % sample_set_id,
)
for character_index: int in range(characters.length()):
var character := characters.substr(character_index, 1)
var sample_path := "%s/%s.wav" % [sample_directory, character]
_expect(
ResourceLoader.exists(sample_path, "AudioStreamWAV"),
"%s sample loads for %s" % [sample_set_id, character],
)
if expects_fallback:
_expect(
ResourceLoader.exists(
"%s/fallback.wav" % sample_directory,
"AudioStreamWAV",
),
"%s supplies a fallback for digits and symbols" % sample_set_id,
)
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)