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