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