nodes/audio.pyΒΆ

Part of Mr. Rescue.

  1"""Procedural audio for Mr. Rescue: synthesized SFX + a looping ambient bed.
  2
  3No sample files. Every clip is generated at runtime with ``AudioSynth``
  4(oscillators + ADSR + filters) or a little bespoke numpy for the noise-based
  5cues (water hiss, impacts) that the oscillator set doesn't cover. Clips are
  6baked into ``AudioClip``s so they play on either backend (desktop / web).
  7
  8Routing uses the engine's built-in ``SFX -> Master`` / ``Music -> Master``
  9buses. The :class:`SfxBank` node owns one ``AudioPlayer`` per cue. No guards are
 10needed around playback: when no audio device is available the engine falls back
 11to its null backend and every ``play``/``stop`` is silently a no-op.
 12"""
 13
 14from __future__ import annotations
 15
 16from functools import cache
 17
 18import numpy as np
 19
 20from simvx.core import ADSR, AudioClip, AudioPlayer, AudioSynth, Node, Oscillator
 21
 22SFX_BUS = "SFX"
 23MUSIC_BUS = "Music"
 24SAMPLE_RATE = 48000
 25CHANNELS = 2
 26
 27
 28# --------------------------------------------------------------------- helpers
 29
 30
 31def _stereo(mono: np.ndarray, *, name: str) -> AudioClip:
 32    out = np.empty(mono.size * CHANNELS, dtype=np.float32)
 33    out[0::CHANNELS] = mono
 34    out[1::CHANNELS] = mono
 35    return AudioClip.from_pcm(out, sample_rate=SAMPLE_RATE, channels=CHANNELS, name=name)
 36
 37
 38def _mix(*signals: np.ndarray) -> np.ndarray:
 39    """Sum signals of differing lengths, zero-padding to the longest."""
 40    n = max(s.size for s in signals)
 41    out = np.zeros(n, dtype=np.float32)
 42    for s in signals:
 43        out[: s.size] += s
 44    return out
 45
 46
 47def _fade(mono: np.ndarray, ms: float = 4.0) -> np.ndarray:
 48    n = min(int(SAMPLE_RATE * ms / 1000.0), mono.size // 2)
 49    if n <= 0:
 50        return mono
 51    ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)
 52    mono[:n] *= ramp
 53    mono[-n:] *= ramp[::-1]
 54    return mono
 55
 56
 57def _noise(duration: float, *, cutoff: float, gain: float, decay: float = 1.0) -> np.ndarray:
 58    """Low-passed white noise with an exponential decay envelope."""
 59    n = int(duration * SAMPLE_RATE)
 60    rng = np.random.default_rng(0xF12E)
 61    sig = rng.standard_normal(n).astype(np.float32)
 62    # Simple one-pole low-pass.
 63    a = np.exp(-2.0 * np.pi * cutoff / SAMPLE_RATE)
 64    out = np.empty_like(sig)
 65    prev = 0.0
 66    for i in range(n):
 67        prev = (1 - a) * sig[i] + a * prev
 68        out[i] = prev
 69    env = np.exp(-np.linspace(0.0, decay * 5.0, n)).astype(np.float32)
 70    return _fade(out * env * gain)
 71
 72
 73def _sweep(f0: float, f1: float, duration: float, *, gain: float, kind: str = "sine") -> np.ndarray:
 74    n = int(duration * SAMPLE_RATE)
 75    t = np.linspace(0.0, duration, n, dtype=np.float32)
 76    freq = np.linspace(f0, f1, n, dtype=np.float32)
 77    phase = 2.0 * np.pi * np.cumsum(freq) / SAMPLE_RATE
 78    if kind == "square":
 79        wave = np.sign(np.sin(phase)).astype(np.float32)
 80    elif kind == "saw":
 81        wave = (2.0 * (phase / (2 * np.pi) % 1.0) - 1.0).astype(np.float32)
 82    else:
 83        wave = np.sin(phase).astype(np.float32)
 84    env = np.minimum(1.0, np.exp(-t * 4.0) + 0.0).astype(np.float32)
 85    return _fade(wave * env * gain)
 86
 87
 88@cache
 89def get_sfx(name: str) -> AudioClip:
 90    if name == "spray":
 91        # Seamless-ish hiss bed for the water stream (player loops this).
 92        mono = _noise(0.45, cutoff=2600, gain=0.18, decay=0.0)
 93        return _stereo(mono, name="spray")
 94    if name == "extinguish":
 95        mono = _mix(
 96            _noise(0.30, cutoff=1800, gain=0.32, decay=1.4),
 97            _sweep(520, 180, 0.30, gain=0.12),
 98        )
 99        return _stereo(_fade(mono), name="extinguish")
100    if name == "jump":
101        return _stereo(_sweep(280, 520, 0.16, gain=0.30, kind="square"), name="jump")
102    if name == "grab":
103        syn = AudioSynth()
104        syn.add(Oscillator.square(150.0), envelope=ADSR(attack=0.004, decay=0.04, sustain=0.0, release=0.04), gain=0.30)
105        return syn.bake(0.12, sample_rate=SAMPLE_RATE, channels=CHANNELS)
106    if name == "throw":
107        return _stereo(_sweep(420, 240, 0.14, gain=0.26, kind="saw"), name="throw")
108    if name == "climb":
109        syn = AudioSynth()
110        env = ADSR(attack=0.005, decay=0.05, sustain=0.0, release=0.05)
111        syn.add(Oscillator.triangle(330.0), envelope=env, gain=0.18)
112        return syn.bake(0.10, sample_rate=SAMPLE_RATE, channels=CHANNELS)
113    if name == "rescue":
114        syn = AudioSynth()
115        for semi in (0, 4, 7, 12):  # arpeggiated major chord
116            f = 392.0 * (2.0 ** (semi / 12.0))
117            syn.add(
118                Oscillator.sine(f),
119                envelope=ADSR(attack=0.01, decay=0.10, sustain=0.4, release=0.18),
120                gain=0.22,
121                pan=0.0,
122            )
123        return syn.bake(0.5, sample_rate=SAMPLE_RATE, channels=CHANNELS)
124    if name == "enemy_kill":
125        mono = _mix(
126            _sweep(300, 90, 0.22, gain=0.32, kind="square"),
127            _noise(0.18, cutoff=2200, gain=0.18, decay=2.0),
128        )
129        return _stereo(_fade(mono), name="enemy_kill")
130    if name == "civ_die":
131        return _stereo(_sweep(260, 70, 0.45, gain=0.28, kind="sine"), name="civ_die")
132    if name == "overheat":
133        syn = AudioSynth()
134        syn.add(Oscillator.square(110.0), envelope=ADSR(attack=0.002, decay=0.03, sustain=0.0, release=0.02), gain=0.22)
135        return syn.bake(0.07, sample_rate=SAMPLE_RATE, channels=CHANNELS)
136    if name == "menu_confirm":
137        syn = AudioSynth()
138        syn.add(Oscillator.square(523.0), envelope=ADSR(attack=0.005, decay=0.06, sustain=0.3, release=0.10), gain=0.20)
139        syn.add(Oscillator.square(784.0), envelope=ADSR(attack=0.02, decay=0.06, sustain=0.3, release=0.12), gain=0.16)
140        return syn.bake(0.28, sample_rate=SAMPLE_RATE, channels=CHANNELS)
141    raise KeyError(f"Unknown sfx: {name}")
142
143
144@cache
145def build_music() -> AudioClip:
146    """A slow, tense ambient loop: low drone + a sparse pulsing fifth."""
147    dur = 6.0
148    n = int(dur * SAMPLE_RATE)
149    t = np.linspace(0.0, dur, n, dtype=np.float32)
150    drone = 0.10 * np.sin(2 * np.pi * 65.4 * t)  # C2
151    fifth = 0.06 * np.sin(2 * np.pi * 98.0 * t)  # G2
152    # Slow tremolo so it breathes.
153    trem = 0.6 + 0.4 * np.sin(2 * np.pi * 0.25 * t)
154    pulse = 0.05 * np.sin(2 * np.pi * 196.0 * t) * (0.5 + 0.5 * np.sin(2 * np.pi * 0.5 * t))
155    mono = ((drone + fifth) * trem + pulse).astype(np.float32)
156    return _stereo(_fade(mono, ms=30.0), name="music")
157
158
159# --------------------------------------------------------------------- bank node
160
161_ONESHOTS = (
162    "extinguish",
163    "jump",
164    "grab",
165    "throw",
166    "climb",
167    "rescue",
168    "enemy_kill",
169    "civ_die",
170    "overheat",
171    "menu_confirm",
172)
173
174
175class SfxBank(Node):
176    """Owns one AudioPlayer per cue. Add to the scene once and reuse."""
177
178    def __init__(self, *, music: bool = True, **kwargs):
179        super().__init__(**kwargs)
180        self._want_music = music
181        self._players: dict[str, AudioPlayer] = {}
182        self._spray: AudioPlayer | None = None
183        self._music: AudioPlayer | None = None
184        self._spraying = False
185
186    def on_ready(self):
187        for name in _ONESHOTS:
188            self._players[name] = self.add_child(AudioPlayer(stream=get_sfx(name), bus=SFX_BUS))
189        self._spray = self.add_child(AudioPlayer(stream=get_sfx("spray"), bus=SFX_BUS, loop=True))
190        if self._want_music:
191            self._music = self.add_child(AudioPlayer(stream=build_music(), bus=MUSIC_BUS, loop=True))
192
193    def play(self, name: str):
194        player = self._players.get(name)
195        if player is not None:
196            player.play()
197
198    def set_spraying(self, on: bool):
199        if self._spray is None or on == self._spraying:
200            return
201        self._spraying = on
202        if on:
203            self._spray.play()
204        else:
205            self._spray.stop()
206
207    def start_music(self):
208        if self._music is not None:
209            self._music.play()
210
211    def stop_music(self):
212        if self._music is not None:
213            self._music.stop()