292 lines
7.6 KiB
GDScript
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()
|