forked from woofmeow/straywild
feat: add selectable animalese voice sets
This commit is contained in:
parent
364e7dc282
commit
ce733818e2
247 changed files with 2675 additions and 302 deletions
|
|
@ -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
112
scripts/import_animalese_voice.sh
Executable 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
73
scripts/validate_animalese_samples.gd
Normal file
73
scripts/validate_animalese_samples.gd
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue