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