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