nodes/audio_fx.pyΒΆ
Part of GDQuest Open RPG.
1"""Procedural audio (AudioSynth-based) for SFX + music loops."""
2
3from __future__ import annotations
4
5import numpy as np
6
7from simvx.core import (
8 ADSR,
9 AudioClip,
10 AudioPlayer,
11 AudioSynth,
12 Node,
13 Oscillator,
14)
15
16_RATE = 44100
17
18
19def _bake_tone(freq: float, dur: float, vol: float = 0.3, waveform: str = "sine") -> AudioClip:
20 """Single-voice synth bake with the standard ADSR envelope."""
21 if waveform == "sine":
22 src = Oscillator.sine(freq)
23 elif waveform == "square":
24 src = Oscillator.square(freq)
25 elif waveform == "saw":
26 src = Oscillator.saw(freq)
27 elif waveform == "noise":
28 # Seed by freq to match the legacy `_tone(... "noise")` determinism.
29 src = Oscillator.noise.white(seed=int(freq))
30 else:
31 src = Oscillator.sine(freq)
32 synth = AudioSynth()
33 synth.add(
34 src,
35 envelope=ADSR(attack=0.01, decay=0.0, sustain=1.0, release=0.10),
36 gain=vol,
37 )
38 # bake() returns interleaved stereo float32: exactly what AudioClip wants.
39 return synth.bake(duration=dur, sample_rate=_RATE, channels=2)
40
41
42def _seq(notes: list[tuple[float, float]], vol: float = 0.25, waveform: str = "sine") -> AudioClip:
43 """Render a sequence of (freq, duration) notes by concatenating bakes.
44
45 Kept bespoke because AudioSynth doesn't currently model note
46 sequencing: each note is its own bake, stitched into a single
47 AudioClip. Zero-Hz "notes" emit silence (used for rests).
48 """
49 parts: list[np.ndarray] = []
50 for freq, dur in notes:
51 if freq <= 0:
52 parts.append(np.zeros(int(_RATE * dur * 2), dtype=np.float32)) # stereo
53 else:
54 parts.append(_bake_tone(freq, dur, vol, waveform).backend_data)
55 return AudioClip.from_pcm(
56 np.concatenate(parts),
57 sample_rate=_RATE,
58 channels=2,
59 name="seq",
60 )
61
62
63def _sfx_blip() -> AudioClip:
64 return _bake_tone(660.0, 0.08, 0.20, "sine")
65
66
67def _sfx_hit() -> AudioClip:
68 """Noise burst layered with a down-pitched sine body."""
69 synth = AudioSynth()
70 # Noise: slow attack, fast release.
71 synth.add(
72 Oscillator.noise.white(seed=7),
73 envelope=ADSR(attack=0.001, decay=0.0, sustain=1.0, release=0.15),
74 gain=0.5,
75 )
76 # Sine body (low): slightly punchier envelope.
77 synth.add(
78 Oscillator.sine(140.0),
79 envelope=ADSR(attack=0.001, decay=0.0, sustain=1.0, release=0.10),
80 gain=0.4,
81 )
82 return synth.bake(duration=0.18, sample_rate=_RATE, channels=2)
83
84
85def _sfx_miss() -> AudioClip:
86 return _bake_tone(220.0, 0.18, 0.15, "saw")
87
88
89def _sfx_heal() -> AudioClip:
90 return _seq([(523.0, 0.10), (784.0, 0.20)], vol=0.25)
91
92
93def _sfx_victory() -> AudioClip:
94 return _seq([(523.0, 0.15), (659.0, 0.15), (784.0, 0.15), (1047.0, 0.35)], vol=0.30)
95
96
97def _sfx_defeat() -> AudioClip:
98 return _seq([(440.0, 0.20), (370.0, 0.20), (294.0, 0.40)], vol=0.30, waveform="saw")
99
100
101def _sfx_save() -> AudioClip:
102 return _seq([(659.0, 0.10), (784.0, 0.10), (988.0, 0.18)], vol=0.25)
103
104
105def _sfx_encounter() -> AudioClip:
106 return _seq([(196.0, 0.10), (164.0, 0.10), (220.0, 0.10), (147.0, 0.30)], vol=0.30, waveform="saw")
107
108
109def _sfx_select() -> AudioClip:
110 return _bake_tone(1320.0, 0.04, 0.18, "sine")
111
112
113def _music_overworld() -> AudioClip:
114 # Simple 8-note loop in C major.
115 seq = [
116 (262.0, 0.40),
117 (330.0, 0.40),
118 (392.0, 0.40),
119 (523.0, 0.40),
120 (494.0, 0.40),
121 (392.0, 0.40),
122 (330.0, 0.40),
123 (262.0, 0.40),
124 ]
125 return _seq(seq, vol=0.10, waveform="sine")
126
127
128def _music_battle() -> AudioClip:
129 # Tense minor-key loop.
130 seq = [
131 (196.0, 0.30),
132 (220.0, 0.30),
133 (262.0, 0.30),
134 (294.0, 0.30),
135 (294.0, 0.30),
136 (262.0, 0.30),
137 (220.0, 0.30),
138 (165.0, 0.40),
139 ]
140 return _seq(seq, vol=0.10, waveform="square")
141
142
143class AudioFX(Node):
144 """Lightweight audio service. Caches one-shot SFX + holds a music player."""
145
146 def __init__(self) -> None:
147 super().__init__()
148 self._sfx_cache: dict[str, AudioClip] = {}
149 self._music_cache: dict[str, AudioClip] = {}
150 self._music_player: AudioPlayer | None = None
151 self._sfx_factories = {
152 "blip": _sfx_blip,
153 "hit": _sfx_hit,
154 "miss": _sfx_miss,
155 "heal": _sfx_heal,
156 "victory": _sfx_victory,
157 "defeat": _sfx_defeat,
158 "save": _sfx_save,
159 "encounter": _sfx_encounter,
160 "select": _sfx_select,
161 }
162 self._music_factories = {
163 "overworld": _music_overworld,
164 "battle": _music_battle,
165 }
166
167 def play_sfx(self, name: str) -> None:
168 try:
169 stream = self._sfx_cache.get(name)
170 if stream is None:
171 factory = self._sfx_factories.get(name)
172 if factory is None:
173 return
174 stream = factory()
175 self._sfx_cache[name] = stream
176 player = AudioPlayer(stream)
177 # One-shot: the engine frees the node when the clip ends, so firing
178 # hundreds of SFX over a session leaves nothing behind.
179 player.queue_free_on_end = True
180 self.add_child(player)
181 player.play()
182 except Exception:
183 # Audio backend may not be available in headless tests; ignore.
184 pass
185
186 def play_music(self, name: str) -> None:
187 try:
188 self.stop_music()
189 stream = self._music_cache.get(name)
190 if stream is None:
191 factory = self._music_factories.get(name)
192 if factory is None:
193 return
194 stream = factory()
195 self._music_cache[name] = stream
196 player = AudioPlayer(stream)
197 player.volume_db = -12.0
198 player.loop = True
199 self.add_child(player)
200 player.play()
201 self._music_player = player
202 except Exception:
203 pass
204
205 def stop_music(self) -> None:
206 if self._music_player is not None:
207 try:
208 self._music_player.destroy()
209 except Exception:
210 pass
211 self._music_player = None