nodes/audio_manager.py¶
Part of Dungeon Explorer.
1"""Centralised audio manager: procedural SFX via the engine AudioSynth API.
2
3Replaces the previous list-of-floats stub with real AudioSynth bakes
4played through pooled AudioPlayers. Each named effect maps to an
5oscillator (or noise) + ADSR envelope; the baked AudioClip is cached
6the first time it plays.
7
8In headless mode (no audio backend) the calls remain harmless: AudioSynth
9bakes the stream regardless, and AudioPlayer.play() resolves to a
10no-op when no backend is attached.
11"""
12
13from enum import StrEnum
14
15from simvx.core import (
16 ADSR,
17 AudioPlayer,
18 AudioSynth,
19 Node2D,
20 Oscillator,
21)
22
23# ── Sound definitions ─────────────────────────────────────────────────────
24
25
26class Sfx(StrEnum):
27 """Every sound effect the manager can play."""
28
29 SWING = "swing"
30 HIT = "hit"
31 CRIT = "crit"
32 BLOCK = "block"
33 DODGE = "dodge"
34 DEATH = "death"
35 PICKUP = "pickup"
36 CHEST_OPEN = "chest_open"
37 LEVEL_UP = "level_up"
38 COIN = "coin"
39 STAIRS = "stairs"
40 MENU_NAV = "menu_nav"
41 MENU_CONFIRM = "menu_confirm"
42 MENU_CANCEL = "menu_cancel"
43 POPUP = "popup"
44 ERROR = "error"
45 BOSS_ROAR = "boss_roar"
46 BOSS_CHARGE = "boss_charge"
47 BOSS_SLAM = "boss_slam"
48 BOSS_PHASE = "boss_phase"
49
50
51# Each entry: (oscillator factory args, ADSR shape, total duration).
52_SFX_DEFS: dict[Sfx, dict] = {
53 # Combat
54 Sfx.SWING: {"type": "noise", "duration": 0.08, "attack": 0.005, "release": 0.04, "gain": 0.6},
55 Sfx.HIT: {"type": "noise", "duration": 0.10, "attack": 0.002, "release": 0.06, "gain": 0.7},
56 Sfx.CRIT: {"type": "sine", "freq": 880, "duration": 0.15, "attack": 0.005, "release": 0.1, "gain": 0.7},
57 Sfx.BLOCK: {"type": "square", "freq": 200, "duration": 0.08, "attack": 0.002, "release": 0.04, "gain": 0.5},
58 Sfx.DODGE: {"type": "noise", "duration": 0.12, "attack": 0.002, "release": 0.08, "gain": 0.4},
59 Sfx.DEATH: {"type": "square", "freq": 110, "duration": 0.25, "attack": 0.005, "release": 0.2, "gain": 0.6},
60 # World
61 Sfx.PICKUP: {"type": "sine", "freq": 660, "duration": 0.12, "attack": 0.005, "release": 0.08, "gain": 0.5},
62 Sfx.CHEST_OPEN: {"type": "sine", "freq": 440, "duration": 0.20, "attack": 0.01, "release": 0.15, "gain": 0.6},
63 Sfx.LEVEL_UP: {"type": "sine", "freq": 523, "duration": 0.40, "attack": 0.02, "release": 0.3, "gain": 0.7},
64 Sfx.COIN: {"type": "sine", "freq": 1047, "duration": 0.08, "attack": 0.002, "release": 0.05, "gain": 0.4},
65 Sfx.STAIRS: {"type": "noise", "duration": 0.15, "attack": 0.005, "release": 0.1, "gain": 0.5},
66 # UI
67 Sfx.MENU_NAV: {"type": "sine", "freq": 440, "duration": 0.04, "attack": 0.002, "release": 0.02, "gain": 0.3},
68 Sfx.MENU_CONFIRM: {"type": "sine", "freq": 660, "duration": 0.08, "attack": 0.005, "release": 0.05, "gain": 0.4},
69 Sfx.MENU_CANCEL: {"type": "sine", "freq": 330, "duration": 0.06, "attack": 0.003, "release": 0.04, "gain": 0.4},
70 Sfx.POPUP: {"type": "sine", "freq": 550, "duration": 0.10, "attack": 0.005, "release": 0.07, "gain": 0.4},
71 Sfx.ERROR: {"type": "square", "freq": 150, "duration": 0.15, "attack": 0.003, "release": 0.1, "gain": 0.5},
72 # Boss
73 Sfx.BOSS_ROAR: {"type": "noise", "duration": 0.50, "attack": 0.02, "release": 0.3, "gain": 0.8},
74 Sfx.BOSS_CHARGE: {"type": "noise", "duration": 0.30, "attack": 0.01, "release": 0.2, "gain": 0.6},
75 Sfx.BOSS_SLAM: {"type": "noise", "duration": 0.40, "attack": 0.005, "release": 0.2, "gain": 0.9},
76 Sfx.BOSS_PHASE: {"type": "sine", "freq": 330, "duration": 0.60, "attack": 0.05, "release": 0.4, "gain": 0.7},
77}
78
79
80def _bake_sfx(key: Sfx):
81 """Bake the named SFX into an `AudioClip` using AudioSynth."""
82 defn = _SFX_DEFS[key]
83 synth = AudioSynth()
84 wtype = defn["type"]
85 duration = defn["duration"]
86 gain = defn.get("gain", 0.5)
87 envelope = ADSR(
88 attack=defn["attack"],
89 decay=0.01,
90 sustain=1.0,
91 release=defn["release"],
92 )
93 if wtype == "sine":
94 synth.add(Oscillator.sine(defn["freq"]), envelope=envelope, gain=gain)
95 elif wtype == "square":
96 synth.add(Oscillator.square(defn["freq"]), envelope=envelope, gain=gain)
97 elif wtype == "noise":
98 synth.add(Oscillator.noise.white(), envelope=envelope, gain=gain)
99 else:
100 raise ValueError(f"sound effect {key!r} has unknown oscillator type {wtype!r}")
101 return synth.bake(duration=duration, sample_rate=44100, channels=2)
102
103
104def _sfx(key: Sfx | str) -> Sfx:
105 try:
106 return Sfx(key)
107 except ValueError:
108 raise ValueError(f"unknown sound effect {key!r}; expected one of {', '.join(Sfx)}") from None
109
110
111class AudioManager(Node2D):
112 """Plays procedurally generated sound effects and ambient audio.
113
114 SFX are baked once via AudioSynth on first play and cached. The
115 manager owns a pool of AudioPlayers; concurrent plays beyond
116 the pool size are dropped (typical for arcade SFX).
117 """
118
119 def __init__(self, **kwargs):
120 super().__init__(name="AudioManager", **kwargs)
121 self._max_concurrent = 8
122 self._music_volume = 1.0
123 self._sfx_volume = 1.0
124 self._master_volume = 1.0
125 # Stream cache: key → AudioClip (baked lazily on first play).
126 self._streams: dict[str, object] = {}
127 # Player pool: round-robin allocation for concurrent SFX.
128 self._players: list[AudioPlayer] = []
129 self._next_player_idx = 0
130
131 def on_ready(self):
132 # Allocate the SFX player pool.
133 for i in range(self._max_concurrent):
134 p = AudioPlayer(name=f"SfxPlayer_{i}", bus="SFX")
135 self.add_child(p)
136 self._players.append(p)
137
138 # ── Volume control ────────────────────────────────────────────────
139
140 @property
141 def sfx_volume(self) -> float:
142 return self._sfx_volume
143
144 @sfx_volume.setter
145 def sfx_volume(self, value: float):
146 self._sfx_volume = max(0.0, min(1.0, value))
147
148 @property
149 def music_volume(self) -> float:
150 return self._music_volume
151
152 @music_volume.setter
153 def music_volume(self, value: float):
154 self._music_volume = max(0.0, min(1.0, value))
155
156 @property
157 def master_volume(self) -> float:
158 return self._master_volume
159
160 @master_volume.setter
161 def master_volume(self, value: float):
162 self._master_volume = max(0.0, min(1.0, value))
163
164 # ── SFX ───────────────────────────────────────────────────────────
165
166 def _ensure_stream(self, key: Sfx | str):
167 key = _sfx(key)
168 if key not in self._streams:
169 self._streams[key] = _bake_sfx(key)
170 return self._streams[key]
171
172 def play_sfx(self, key: Sfx | str):
173 """Play a sound effect by name. Cached on first use; an unknown name raises ``ValueError``."""
174 stream = self._ensure_stream(key)
175 if not self._players:
176 return
177 # Round-robin pick: stops whatever was on the chosen player.
178 player = self._players[self._next_player_idx]
179 self._next_player_idx = (self._next_player_idx + 1) % len(self._players)
180 # The round-robin pick may still be finishing its last sound, and a new
181 # stream only takes effect at the next play().
182 player.stop()
183 player.stream = stream
184 # Map the manager's 0..1 sfx_volume to dB; -80 = silent.
185 if self._sfx_volume <= 0:
186 return
187 import math
188
189 player.volume_db = 20.0 * math.log10(max(self._sfx_volume * self._master_volume, 1e-4))
190 player.play()