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()