music.pyΒΆ

Part of Deep Sea Aquarium.

  1"""Procedural ambient music synthesis: evolving drone, pentatonic pads, reactive chimes.
  2
  3Pad and chime layers are baked via the engine's `AudioSynth` API
  4(oscillators + ADSR / Exponential envelopes). The drone keeps its
  5bespoke render so it can apply a slow LFO that modulates amplitude
  6across the entire clip (a pattern AudioSynth's envelope system
  7doesn't currently express).
  8"""
  9
 10import numpy as np
 11
 12from simvx.core import (
 13    ADSR,
 14    AudioClip,
 15    AudioPlayer,
 16    AudioSynth,
 17    Exponential,
 18    Node,
 19    Oscillator,
 20)
 21
 22SAMPLE_RATE = 44100
 23
 24# Pad mixing: level each pad plays at, and how long one pad takes to give way
 25# to the next.
 26PAD_VOLUME_DB = -12.0
 27PAD_CROSSFADE = 2.5
 28
 29
 30def _make_clip(name: str, mono: np.ndarray) -> AudioClip:
 31    """Normalise mono float32 samples and wrap them as a stereo AudioClip."""
 32    peak = float(np.abs(mono).max())
 33    if peak > 0:
 34        mono = mono * (0.25 / peak)
 35    stereo = np.repeat(mono.astype(np.float32), 2)  # duplicate each frame into L/R
 36    return AudioClip.from_pcm(stereo, sample_rate=SAMPLE_RATE, channels=2, name=name)
 37
 38
 39# ============================================================================
 40# Drone: Sub-bass evolving texture
 41# ============================================================================
 42
 43
 44def generate_drone(duration: float = 30.0, base_freq: float = 55.0) -> np.ndarray:
 45    """Two sub-bass sines + breathing LFO + gentle 3rd harmonic."""
 46    n = int(SAMPLE_RATE * duration)
 47    t = np.linspace(0, duration, n, dtype=np.float32)
 48    # Fundamental + perfect fifth
 49    sig = np.sin(2 * np.pi * base_freq * t)
 50    sig += 0.6 * np.sin(2 * np.pi * base_freq * 1.5 * t)
 51    # Gentle 3rd harmonic
 52    sig += 0.15 * np.sin(2 * np.pi * base_freq * 3.0 * t)
 53    # Slow breathing LFO (0.1 Hz)
 54    lfo = 0.6 + 0.4 * np.sin(2 * np.pi * 0.1 * t)
 55    sig *= lfo
 56    # Fade in/out: short attack (0.5 s) so ambient is audible right after
 57    # startup; longer release for a gentle tail when the clip wraps.
 58    fade_in = min(SAMPLE_RATE // 2, n // 4)  # 0.5 s
 59    fade_out = min(int(SAMPLE_RATE * 1.5), n // 4)  # 1.5 s
 60    sig[:fade_in] *= np.linspace(0, 1, fade_in, dtype=np.float32)
 61    sig[-fade_out:] *= np.linspace(1, 0, fade_out, dtype=np.float32)
 62    return sig
 63
 64
 65# ============================================================================
 66# Pad: Pentatonic notes with slow envelope
 67# ============================================================================
 68
 69# Pentatonic frequencies
 70PAD_NOTES = [130.81, 146.83, 164.81, 196.0, 220.0]  # C3, D3, E3, G3, A3
 71
 72
 73def _make_pad_stream(freq: float, duration: float = 12.0) -> AudioClip:
 74    """Warm pad as a baked AudioClip via AudioSynth.
 75
 76    Fundamental + octave + lightly detuned chorus, shaped by an ADSR
 77    that ramps in over 200 ms and out over 500 ms (sustained note in
 78    the middle).
 79    """
 80    synth = AudioSynth()
 81    env = ADSR(attack=0.2, decay=0.0, sustain=1.0, release=0.5)
 82    synth.add(Oscillator.sine(freq), envelope=env, gain=0.25)
 83    synth.add(Oscillator.sine(freq * 2.0), envelope=env, gain=0.10)  # octave
 84    synth.add(Oscillator.sine(freq * 1.003), envelope=env, gain=0.05)  # detuned chorus
 85    return synth.bake(duration=duration, sample_rate=SAMPLE_RATE, channels=2)
 86
 87
 88# ============================================================================
 89# Chime: Sharp sine with exponential decay
 90# ============================================================================
 91
 92CHIME_NOTES = [523.25, 587.33, 659.25, 783.99, 880.0]  # C5, D5, E5, G5, A5
 93
 94
 95def _make_chime_stream(freq: float, duration: float = 3.0) -> AudioClip:
 96    """Crystal chime as a baked AudioClip via AudioSynth.
 97
 98    Fundamental + 2nd harmonic, each with its own exponential decay
 99    (the harmonic decays faster for a metallic shimmer that mellows
100    quickly). Both share a short common ADSR attack so the onset is
101    click-free.
102    """
103    synth = AudioSynth()
104    # Fundamental decays over the full duration.
105    synth.add(
106        Oscillator.sine(freq),
107        envelope=Exponential(start=1.0, end=0.005),
108        gain=0.3,
109    )
110    # 2nd harmonic decays ~2x faster for the metallic shimmer.
111    synth.add(
112        Oscillator.sine(freq * 2.0),
113        envelope=Exponential(start=1.0, end=0.001),
114        gain=0.03,
115    )
116    return synth.bake(duration=duration, sample_rate=SAMPLE_RATE, channels=2)
117
118
119# ============================================================================
120# Controller Node
121# ============================================================================
122
123
124class AmbientMusicController(Node):
125    """Manages three audio layers: drone, pad, chime."""
126
127    def __init__(self, **kw):
128        super().__init__(name="AmbientMusic", **kw)
129        self._drone_player: AudioPlayer | None = None
130        self._pad_players: list[AudioPlayer] = []
131        self._chime_player: AudioPlayer | None = None
132        self._chime_streams: list[AudioClip] = []
133        self._pad_streams: list[AudioClip] = []
134        self._pad_index = 0
135        self._pad_switch_interval = 12.0
136        self._pad_timer = 0.0
137        self._active_pad = 0
138
139    def on_ready(self):
140        # Drone: -6 dB, loud enough to read as the bed of the mix from the first
141        # second (its 0.5 s fade-in in generate_drone() keeps the entry soft).
142        drone_stream = _make_clip("drone", generate_drone(30.0))
143        self._drone_player = AudioPlayer(stream=drone_stream, name="Drone")
144        self._drone_player.loop = True
145        self._drone_player.volume_db = -6.0
146        self._drone_player.autoplay = True
147        self.add_child(self._drone_player)
148
149        # Pre-generate pad streams (AudioSynth bakes them, no manual stereo conversion).
150        for freq in PAD_NOTES:
151            self._pad_streams.append(_make_pad_stream(freq, 12.0))
152
153        # Two pad players: one holds the current note while the other fades in
154        # with the next, so the harmony always overlaps.
155        for i in range(2):
156            p = AudioPlayer(name=f"Pad_{i}")
157            p.volume_db = PAD_VOLUME_DB
158            p.loop = True
159            self._pad_players.append(p)
160            self.add_child(p)
161
162        # Start the first pad. The players are already in the tree, so autoplay
163        # (which only fires on ready) has come and gone: start it by hand.
164        if self._pad_streams:
165            self._pad_players[0].stream = self._pad_streams[0]
166            self._pad_players[0].play()
167
168        # Pre-generate chime cache (AudioSynth bakes them).
169        for freq in CHIME_NOTES:
170            self._chime_streams.append(_make_chime_stream(freq, 3.0))
171
172        self._chime_player = AudioPlayer(name="Chime")
173        self._chime_player.volume_db = -6.0
174        self.add_child(self._chime_player)
175
176    def on_update(self, dt: float):
177        self._pad_timer += dt
178
179        # Cycle pad notes: hand the harmony over with a crossfade so the note
180        # change is a swell rather than a cut.
181        if self._pad_timer >= self._pad_switch_interval:
182            self._pad_timer = 0.0
183            self._pad_index = (self._pad_index + 1) % len(PAD_NOTES)
184            old = self._active_pad
185            new = 1 - old
186            self._active_pad = new
187            if self._pad_streams:
188                # The incoming pad may still be ringing from two swaps ago; a
189                # stream assigned over a live channel would not be heard.
190                self._pad_players[new].stop()
191                self._pad_players[new].stream = self._pad_streams[self._pad_index]
192                self._pad_players[new].volume_db = PAD_VOLUME_DB
193                self._pad_players[old].crossfade(self._pad_players[new], PAD_CROSSFADE, curve="equal_gain")
194
195    def play_chime(self, index: int | None = None):
196        """Play a chime sound. If index is None, pick randomly."""
197        if not self._chime_streams or not self._chime_player:
198            return
199        if index is None:
200            index = int(np.random.default_rng().integers(0, len(self._chime_streams)))
201        index = index % len(self._chime_streams)
202        self._chime_player.stop()
203        self._chime_player.stream = self._chime_streams[index]
204        self._chime_player.play()