class_name AnimaleseVoice extends Node const SAMPLE_DIRECTORY := "res://sound/dialogue/animalese/placeholder" const SUPPORTED_CHARACTERS := "abcdefghijklmnopqrstuvwxyz" const DEFAULT_CHARACTERS_PER_SECOND: float = 28.0 const POLYPHONY: int = 8 const VoiceProfilesType = preload( "res://player/animalese_voice_profiles.gd" ) const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd") var base_pitch: float = 1.0 var volume_db: float = -13.0 var _samples: Dictionary[String, AudioStream] = {} var _player: AudioStreamPlayer var _playback: AudioStreamPlaybackPolyphonic func _ready() -> void: _load_samples() var polyphonic_stream := AudioStreamPolyphonic.new() polyphonic_stream.polyphony = POLYPHONY _player = AudioStreamPlayer.new() _player.name = "AnimaleseAudio" _player.stream = polyphonic_stream add_child(_player) _player.play() _playback = ( _player.get_stream_playback() as AudioStreamPlaybackPolyphonic ) func speak_text( owner: Node, text: String, voice_key: String, voice_profile_id: String = VoiceProfilesType.DEFAULT_ID, characters_per_second: float = -1.0, ) -> Tween: var speech_tween := owner.create_tween() var resolved_characters_per_second := ( characters_per_second if characters_per_second > 0.0 else TypewriterRevealType.get_characters_per_second() ) var character_seconds := 1.0 / maxf( resolved_characters_per_second, 1.0, ) var voice_pitch := ( base_pitch * VoiceProfilesType.pitch_for(voice_profile_id) ) for character_index: int in range(text.length()): speech_tween.tween_interval(character_seconds) speech_tween.tween_callback( _play_character.bind( text.substr(character_index, 1), character_index, text, voice_key, voice_pitch, ) ) return speech_tween func _load_samples() -> void: for character_index: int in range(SUPPORTED_CHARACTERS.length()): var character := SUPPORTED_CHARACTERS.substr(character_index, 1) _load_sample(character) _load_sample("fallback") func _load_sample(sample_name: String) -> void: var sample_path := "%s/%s.wav" % [SAMPLE_DIRECTORY, sample_name] var sample := load(sample_path) as AudioStream if sample != null: _samples[sample_name] = sample func _play_character( character: String, character_index: int, full_text: String, voice_key: String, voice_pitch: float, ) -> void: if _playback == null or character.strip_edges().is_empty(): return var normalized := character.to_lower() if normalized in [".", ",", "!", "?", ":", ";", "-", "_"]: return var sample_name := ( normalized if SUPPORTED_CHARACTERS.contains(normalized) else "fallback" ) var sample: AudioStream = _samples.get(sample_name) if sample == null: return var speaker_variation := ( float(posmod(hash(voice_key), 1001)) / 1000.0 - 0.5 ) * 0.16 var character_variation := ( float(posmod(hash("%s:%d" % [voice_key, character_index]), 1001)) / 1000.0 - 0.5 ) * 0.10 var question_lift := 0.0 if full_text.ends_with("?") and character_index >= full_text.length() * 3 / 4: var final_progress := ( float(character_index) / maxf(float(full_text.length() - 1), 1.0) ) question_lift = lerpf(0.0, 0.14, final_progress) var pitch := clampf( voice_pitch + speaker_variation + character_variation + question_lift, 0.72, 1.45, ) _playback.play_stream(sample, 0.0, volume_db, pitch)