chobit/shared/godot/audio/sound_engine.gd
Claude Code 7067b6dded refactor(shared): ♻️ Improve shared utility structure for better maintainability
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-28 14:55:37 -07:00

292 lines
7.6 KiB
GDScript

extends Node
## Sound synthesis + sample playback engine for UI feedback and companion reactions.
## Synthesized sounds generated in-memory from oscillator recipes.
## UwU pack uses real audio recordings with pitch/speed variations.
## Inspired by @lilith/ui-effects-sound.
const SAMPLE_RATE: int = 44100
const MASTER_VOLUME: float = 0.4
const UWU_PATH: String = "res://audio/uwu/uwu-base.mp3"
var _player: AudioStreamPlayer
var _uwu_stream: AudioStreamMP3
func setup(audio_player: AudioStreamPlayer) -> void:
_player = audio_player
_uwu_stream = load(UWU_PATH)
func play_sound(sound_name: String) -> void:
if _player == null:
return
if sound_name == "uwu":
_play_uwu()
return
var samples := _generate(sound_name)
if samples.is_empty():
push_warning("SoundEngine: Unknown sound '%s'" % sound_name)
return
_play_samples(samples)
func get_sound_names() -> Array[String]:
return [
"chirp",
"sparkle",
"pyon",
"chime",
"whoosh_up",
"whoosh_down",
"error",
"success",
"blip",
"uwu",
]
# --- File-based playback (uwu pack) ---
func _play_uwu() -> void:
if _uwu_stream == null:
push_warning("SoundEngine: UwU file not loaded")
return
_player.stream = _uwu_stream
_player.pitch_scale = 1.0
_player.play()
EventBus.audio_started.emit()
# --- Synthesized sound dispatch ---
func _generate(sound_name: String) -> PackedFloat32Array:
var generators := {
"chirp": _chirp,
"sparkle": _sparkle,
"pyon": _pyon,
"chime": _chime,
"whoosh_up": _whoosh_up,
"whoosh_down": _whoosh_down,
"error": _error_tone,
"success": _success_fanfare,
"blip": _blip,
}
var gen: Callable = generators.get(sound_name, Callable())
if gen.is_valid():
return gen.call()
return PackedFloat32Array()
# --- Synthesized sound recipes ---
func _chirp() -> PackedFloat32Array:
## Quick rising chirp — anime hover sound
var duration := 0.08
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var freq := _exp_ramp(400.0, 1200.0, t, duration)
var envelope := _decay_envelope(t, duration, 0.005)
samples[i] = _sine(freq, t) * envelope * 0.3
return samples
func _sparkle() -> PackedFloat32Array:
## Magic sparkle — three ascending sine tones (C6, E6, G6)
var freqs := [1047.0, 1319.0, 1568.0]
var offsets := [0.0, 0.05, 0.10]
var note_dur := 0.08
var count := _sample_count(0.18)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var val := 0.0
for n: int in range(freqs.size()):
var offset: float = offsets[n]
var freq: float = freqs[n]
if t >= offset and t < offset + note_dur:
var local_t := t - offset
var env := _decay_envelope(local_t, note_dur, 0.003)
val += _sine(freq, local_t) * env * 0.15
samples[i] = val
return samples
func _pyon() -> PackedFloat32Array:
## "Pyon!" bounce — square wave drop then bounce back
var duration := 0.12
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var freq: float
if t < 0.05:
freq = _exp_ramp(880.0, 440.0, t, 0.05)
else:
freq = _exp_ramp(440.0, 660.0, t - 0.05, 0.07)
var envelope := _decay_envelope(t, duration, 0.003)
samples[i] = _square(freq, t) * envelope * 0.2
return samples
func _chime() -> PackedFloat32Array:
## Gentle two-tone chime — C5 + E5 together
var duration := 0.3
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var envelope := _decay_envelope(t, duration, 0.01)
var val := _sine(523.25, t) * 0.15 + _sine(659.25, t) * 0.15
samples[i] = val * envelope
return samples
func _whoosh_up() -> PackedFloat32Array:
return _whoosh(200.0, 600.0, 0.25)
func _whoosh_down() -> PackedFloat32Array:
return _whoosh(600.0, 200.0, 0.20)
func _whoosh(start_freq: float, end_freq: float, duration: float) -> PackedFloat32Array:
## Filtered sweep — sawtooth through lowpass approximation
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
var prev := 0.0
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var freq := _exp_ramp(start_freq, end_freq, t, duration)
var envelope := _decay_envelope(t, duration, 0.01)
var raw := _sawtooth(freq, t) * envelope * 0.1
var cutoff_t := t / duration
var alpha := lerpf(0.05, 0.3, cutoff_t if start_freq < end_freq else 1.0 - cutoff_t)
prev = prev + alpha * (raw - prev)
samples[i] = prev
return samples
func _error_tone() -> PackedFloat32Array:
## Low warning — descending triangle wave
var duration := 0.15
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var freq := _exp_ramp(220.0, 180.0, t, duration)
var envelope := _decay_envelope(t, duration, 0.005)
samples[i] = _triangle(freq, t) * envelope * 0.25
return samples
func _success_fanfare() -> PackedFloat32Array:
## Ascending three-note arpeggio — C5, E5, G5
var freqs := [523.25, 659.25, 783.99]
var offsets := [0.0, 0.15, 0.30]
var note_dur := 0.35
var count := _sample_count(0.65)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var val := 0.0
for n: int in range(freqs.size()):
var offset: float = offsets[n]
var freq: float = freqs[n]
if t >= offset and t < offset + note_dur:
var local_t := t - offset
var env := _decay_envelope(local_t, note_dur, 0.01)
val += _triangle(freq, local_t) * env * 0.2
samples[i] = val
return samples
func _blip() -> PackedFloat32Array:
## Ultra-short UI blip
var duration := 0.04
var count := _sample_count(duration)
var samples := PackedFloat32Array()
samples.resize(count)
for i: int in range(count):
var t := float(i) / float(SAMPLE_RATE)
var envelope := _decay_envelope(t, duration, 0.002)
samples[i] = _sine(1000.0, t) * envelope * 0.1
return samples
# --- Oscillators ---
func _sine(freq: float, t: float) -> float:
return sin(t * TAU * freq)
func _square(freq: float, t: float) -> float:
return 1.0 if fmod(t * freq, 1.0) < 0.5 else -1.0
func _triangle(freq: float, t: float) -> float:
var phase := fmod(t * freq, 1.0)
return 4.0 * absf(phase - 0.5) - 1.0
func _sawtooth(freq: float, t: float) -> float:
return 2.0 * fmod(t * freq, 1.0) - 1.0
# --- Envelopes ---
func _decay_envelope(t: float, duration: float, attack: float) -> float:
if t < attack:
return t / attack
return maxf(0.0, 1.0 - (t - attack) / (duration - attack))
# --- Helpers ---
func _exp_ramp(start: float, end_val: float, t: float, duration: float) -> float:
if duration <= 0.0 or t >= duration:
return end_val
var ratio := t / duration
return start * pow(end_val / start, ratio)
func _sample_count(duration: float) -> int:
return int(duration * float(SAMPLE_RATE))
func _play_samples(samples: PackedFloat32Array) -> void:
var byte_data := PackedByteArray()
byte_data.resize(samples.size() * 2)
for i: int in range(samples.size()):
var val := clampf(samples[i] * MASTER_VOLUME, -1.0, 1.0)
var s16 := int(val * 32767.0)
byte_data[i * 2] = s16 & 0xFF
byte_data[i * 2 + 1] = (s16 >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.data = byte_data
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
_player.pitch_scale = 1.0
_player.stream = stream
_player.play()
EventBus.audio_started.emit()