nodes/audio.pyΒΆ

Part of Dodge the Creeps.

 1"""Procedural audio: built on the engine's AudioSynth API.
 2
 3Replaces the upstream demo's bundled ``gameover.wav`` (whose origin the
 4upstream README never documents) with a sound generated at load time, so
 5the port bundles no third-party sound files. Same pattern as the other
 6ports' synth helpers.
 7"""
 8
 9from __future__ import annotations
10
11import numpy as np
12
13from simvx.core import ADSR, AudioClip, AudioSynth, Oscillator
14
15SAMPLE_RATE = 44100
16NCHANNELS = 2
17
18
19def _stereo(mono: np.ndarray) -> AudioClip:
20    out = np.empty(mono.size * NCHANNELS, dtype=np.float32)
21    out[0::NCHANNELS] = mono
22    out[1::NCHANNELS] = mono
23    return AudioClip.from_pcm(out, sample_rate=SAMPLE_RATE, channels=NCHANNELS, name="gameover")
24
25
26def _fade(n: int, attack: float, release: float) -> np.ndarray:
27    env = np.ones(n, dtype=np.float32)
28    a = min(max(1, int(SAMPLE_RATE * attack)), n)
29    r = min(max(1, int(SAMPLE_RATE * release)), n)
30    env[:a] = np.linspace(0, 1, a, dtype=np.float32)
31    env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
32    return env
33
34
35def make_gameover() -> AudioClip:
36    """Death cue: a descending three-note motif with a soft low rumble.
37
38    Kept partly bespoke because AudioSynth oscillators are constant
39    frequency and the API does not model note-concatenation: each note is
40    baked on its own and the notes are stitched with ``np.concatenate``.
41    """
42
43    def _note(freq: float, dur: float) -> np.ndarray:
44        synth = AudioSynth()
45        synth.add(
46            Oscillator.sine(freq),
47            envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.06),
48            gain=0.32,
49        )
50        synth.add(  # soft second harmonic for body
51            Oscillator.sine(freq * 2.0),
52            envelope=ADSR(attack=0.005, decay=0.0, sustain=1.0, release=0.06),
53            gain=0.08,
54        )
55        return synth.bake(duration=dur, sample_rate=SAMPLE_RATE, channels=1).backend_data
56
57    sig = np.concatenate([_note(330, 0.18), _note(262, 0.18), _note(196, 0.42)])
58
59    # Light low rumble under the motif (seeded for reproducibility).
60    rng = np.random.default_rng(7)
61    noise = rng.uniform(-1, 1, sig.size).astype(np.float32) * 0.05
62    noise *= _fade(sig.size, 0.001, 0.12)
63
64    return _stereo(sig + noise)
65
66
67__all__ = ["make_gameover"]