nodes/audio.pyΒΆ
Part of Q1K3.
1"""Synthesised SFX bank for the Q1K3 port: built on AudioSynth.
2
3Upstream uses Sonant-X for both sound effects and the soundtrack. We
4synthesise short noise/tone bursts via the engine's `AudioSynth` API
5so the port ships with no .wav files (matters for web export: no
6file fetch + Pyodide-side decode).
7
8Each public ``sfx_*`` entry is lazily computed and cached.
9
10Filtered noise (shotgun, hurt, etc.) uses `Oscillator.noise.white(seed) +
11LowPass(cutoff)` to bake the warm low-passed burst directly. Swept tones
12(nailgun, grenade lob, plasma) keep a small amount of bespoke numpy
13because AudioSynth's oscillators have constant frequency, the cumsum-
14integrated linear sweep doesn't fit the synth's voice model.
15"""
16
17from __future__ import annotations
18
19import math
20
21import numpy as np
22
23from simvx.core import (
24 ADSR,
25 AudioClip,
26 AudioSynth,
27 Exponential,
28 LowPass,
29 Oscillator,
30)
31
32_SAMPLE_RATE = 44100
33_NCHANNELS = 2
34
35_CACHE: dict[str, AudioClip] = {}
36
37
38def _to_stereo(mono: np.ndarray) -> np.ndarray:
39 n = mono.shape[0]
40 out = np.empty(n * _NCHANNELS, dtype=np.float32)
41 out[0::_NCHANNELS] = mono
42 out[1::_NCHANNELS] = mono
43 return out
44
45
46def _bake_noise_burst(duration: float, lp_hz: float, volume: float, seed: int) -> AudioClip:
47 """Filtered noise burst: uses AudioSynth + LowPass.
48
49 Replaces the legacy manual one-pole loop with the engine's `LowPass`
50 filter (same algorithm under the hood).
51 """
52 synth = AudioSynth()
53 synth.add(
54 Oscillator.noise.white(seed=seed),
55 envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.05),
56 filter=LowPass(lp_hz),
57 gain=volume,
58 )
59 return synth.bake(duration=duration, sample_rate=_SAMPLE_RATE, channels=_NCHANNELS)
60
61
62def _bake_tone(freq: float, duration: float, volume: float = 0.4) -> AudioClip:
63 """Pure-tone sine with the standard ADSR."""
64 synth = AudioSynth()
65 synth.add(
66 Oscillator.sine(freq),
67 envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.05),
68 gain=volume,
69 )
70 return synth.bake(duration=duration, sample_rate=_SAMPLE_RATE, channels=_NCHANNELS)
71
72
73def _bake_swept_tone(f0: float, f1: float, duration: float, volume: float = 0.35) -> AudioClip:
74 """Linear frequency sweep from f0 to f1: bespoke (cumsum integration).
75
76 Kept manual because AudioSynth's oscillators have constant
77 frequency; expressing a sweep as a sequence of bakes would lose
78 phase continuity, and ``Oscillator.sine.freq = ...`` mid-render is
79 not supported.
80 """
81 n = int(_SAMPLE_RATE * duration)
82 freq = np.linspace(f0, f1, n, dtype=np.float32)
83 phase = 2 * math.pi * np.cumsum(freq) / _SAMPLE_RATE
84 sig = np.sin(phase).astype(np.float32)
85 # Standard envelope (5 ms attack, 50 ms release).
86 a = max(1, int(_SAMPLE_RATE * 0.005))
87 r = max(1, int(_SAMPLE_RATE * 0.05))
88 env = np.ones(n, dtype=np.float32)
89 if a < n:
90 env[:a] = np.linspace(0, 1, a, dtype=np.float32)
91 if r < n:
92 env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
93 sig = sig * env * volume
94 return AudioClip.from_pcm(
95 _to_stereo(sig),
96 sample_rate=_SAMPLE_RATE,
97 channels=2,
98 name="swept",
99 )
100
101
102def _bake_explosion(duration: float = 0.5, volume: float = 0.7, seed: int = 7) -> AudioClip:
103 """Big LP-noise burst with exponential decay + makeup gain.
104
105 Combines AudioSynth's white-noise + LowPass(600) + Exponential
106 envelope. We still apply a small post-bake makeup gain
107 (``out *= 4`` in the legacy code) because the LP loss is heavy;
108 that scaling happens in Python after `bake()`.
109 """
110 synth = AudioSynth()
111 synth.add(
112 Oscillator.noise.white(seed=seed),
113 envelope=Exponential(start=1.0, end=0.05, power=1.0),
114 filter=LowPass(600.0),
115 gain=volume,
116 )
117 stream = synth.bake(duration=duration, sample_rate=_SAMPLE_RATE, channels=_NCHANNELS, soft_clip=False)
118 # Compensate for the LP rolloff (matches the legacy 4x makeup).
119 arr = stream.backend_data * 4.0
120 np.clip(arr, -1.0, 1.0, out=arr)
121 stream.backend_data = arr
122 return stream
123
124
125def _get(name: str, factory) -> AudioClip:
126 if name not in _CACHE:
127 _CACHE[name] = factory()
128 return _CACHE[name]
129
130
131# Public stream accessors -----------------------------------------------------
132
133
134def sfx_shotgun_shoot() -> AudioClip:
135 return _get("shotgun_shoot", lambda: _bake_noise_burst(0.18, lp_hz=1500, volume=0.6, seed=1))
136
137
138def sfx_shotgun_reload() -> AudioClip:
139 return _get("shotgun_reload", lambda: _bake_noise_burst(0.06, lp_hz=2400, volume=0.35, seed=2))
140
141
142def sfx_nailgun_shoot() -> AudioClip:
143 return _get("nailgun_shoot", lambda: _bake_swept_tone(1800, 600, 0.05, volume=0.4))
144
145
146def sfx_nailgun_hit() -> AudioClip:
147 return _get("nailgun_hit", lambda: _bake_noise_burst(0.05, lp_hz=4000, volume=0.4, seed=3))
148
149
150def sfx_grenade_shoot() -> AudioClip:
151 return _get("grenade_shoot", lambda: _bake_swept_tone(180, 60, 0.15, volume=0.5))
152
153
154def sfx_grenade_bounce() -> AudioClip:
155 return _get("grenade_bounce", lambda: _bake_tone(280, 0.08, volume=0.35))
156
157
158def sfx_grenade_explode() -> AudioClip:
159 return _get("grenade_explode", lambda: _bake_explosion(duration=0.5, volume=0.6, seed=4))
160
161
162def sfx_plasma_shoot() -> AudioClip:
163 return _get("plasma_shoot", lambda: _bake_swept_tone(1200, 300, 0.18, volume=0.4))
164
165
166def sfx_no_ammo() -> AudioClip:
167 return _get("no_ammo", lambda: _bake_tone(180, 0.06, volume=0.25))
168
169
170def sfx_pickup() -> AudioClip:
171 return _get("pickup", lambda: _bake_swept_tone(440, 880, 0.15, volume=0.3))
172
173
174def sfx_hurt() -> AudioClip:
175 return _get("hurt", lambda: _bake_noise_burst(0.18, lp_hz=900, volume=0.5, seed=5))
176
177
178def sfx_enemy_hit() -> AudioClip:
179 return _get("enemy_hit", lambda: _bake_noise_burst(0.1, lp_hz=1200, volume=0.4, seed=6))
180
181
182def sfx_enemy_gib() -> AudioClip:
183 return _get("enemy_gib", lambda: _bake_explosion(duration=0.4, volume=0.5, seed=8))
184
185
186def sfx_hound_attack() -> AudioClip:
187 return _get("hound_attack", lambda: _bake_swept_tone(900, 200, 0.18, volume=0.45))
188
189
190def warm_all() -> None:
191 """Pre-bake every SFX (call once at boot to avoid first-fire latency spike)."""
192 for fn in (
193 sfx_shotgun_shoot,
194 sfx_shotgun_reload,
195 sfx_nailgun_shoot,
196 sfx_nailgun_hit,
197 sfx_grenade_shoot,
198 sfx_grenade_bounce,
199 sfx_grenade_explode,
200 sfx_plasma_shoot,
201 sfx_no_ammo,
202 sfx_pickup,
203 sfx_hurt,
204 sfx_enemy_hit,
205 sfx_enemy_gib,
206 sfx_hound_attack,
207 ):
208 fn()