Music Streaming¶
file-backed loops that crossfade and duck under dialogue.
📄 Docs onlyTags: audio music streaming crossfade fades
What it demonstrates¶
Two short music loops synthesised at startup and written as WAV files into a temp directory with the stdlib
wavemodule. Only file-backed clips can stream (syntheticAudioClip.tone/from_pcmcannot), so this is the recipe for streaming music without shipping an asset.AudioPlayer(stream_mode="streaming", loop=True): the file is decoded in chunks each frame instead of being loaded whole, which is the mode for music.a.crossfade(b, 2.0): one call hands playback over on an equal-power curve, so the pair holds a steady loudness through the middle of the fade.Ducking under a fake dialogue line with
fade_to(0.25, 0.3), then restoring withfade_to(1.0, 0.5)once the line ends.Fades and the streaming feed both tick in
on_update, so the players must be in the tree; a detached player’s fade never advances.
Controls¶
SPACE: crossfade to the other track (2 s, equal power).D: play a dialogue line: duck the music to a quarter amplitude, restore after.ESC: quit.
Run: uv run python examples/features/audio/music.py Headless self-check: uv run python examples/features/audio/music.py –test
Source¶
1"""Music Streaming: file-backed loops that crossfade and duck under dialogue.
2
3# /// simvx
4# tags = ["audio", "music", "streaming", "crossfade", "fades"]
5# [web]
6# disabled = true
7# reason = "stream_mode='streaming' needs the native backend's chunk-fed decoder, absent in the browser runtime."
8# ///
9
10## What it demonstrates
11- Two short music loops synthesised at startup and written as WAV files into a
12 temp directory with the stdlib `wave` module. Only file-backed clips can
13 stream (synthetic `AudioClip.tone`/`from_pcm` cannot), so this is the recipe
14 for streaming music without shipping an asset.
15- `AudioPlayer(stream_mode="streaming", loop=True)`: the file is decoded in
16 chunks each frame instead of being loaded whole, which is the mode for music.
17- `a.crossfade(b, 2.0)`: one call hands playback over on an equal-power curve,
18 so the pair holds a steady loudness through the middle of the fade.
19- Ducking under a fake dialogue line with `fade_to(0.25, 0.3)`, then restoring
20 with `fade_to(1.0, 0.5)` once the line ends.
21- Fades and the streaming feed both tick in `on_update`, so the players must be
22 in the tree; a detached player's fade never advances.
23
24## Controls
25- `SPACE`: crossfade to the other track (2 s, equal power).
26- `D`: play a dialogue line: duck the music to a quarter amplitude, restore after.
27- `ESC`: quit.
28
29Run: uv run python examples/features/audio/music.py
30Headless self-check: uv run python examples/features/audio/music.py --test
31"""
32
33import math
34import tempfile
35import wave
36from pathlib import Path
37
38import numpy as np
39
40from simvx.core import AudioClip, AudioPlayer, Input, Key, Node, Text2D, Vec2, wait
41from simvx.graphics import App
42
43SAMPLE_RATE = 44100
44CROSSFADE_SECONDS = 2.0
45DUCK_GAIN = 0.25 # a quarter of the amplitude, about -12 dB
46DUCK_FADE_SECONDS = 0.3
47RESTORE_FADE_SECONDS = 0.5
48DIALOGUE_SECONDS = 1.8
49
50# Both loops are 2.4 s and wrap seamlessly: every pluck decays inside its own
51# slot, and each bass drone completes a whole number of cycles over the loop
52# (110 Hz x 2.4 s = 264; 82.5 Hz x 2.4 s = 198), so the join is inaudible.
53TRACK_A = {
54 "notes": [220.00, 261.63, 329.63, 392.00, 440.00, 392.00, 329.63, 261.63],
55 "note_len": 0.3,
56 "bass": 110.0,
57 "harmonics": ((1, 1.0), (2, 0.35)), # warm: fundamental plus one octave
58}
59TRACK_B = {
60 "notes": [329.63, 392.00, 493.88, 587.33, 493.88, 392.00, 329.63, 392.00, 493.88, 659.25, 587.33, 493.88],
61 "note_len": 0.2,
62 "bass": 82.5,
63 "harmonics": ((1, 1.0), (3, 0.33), (5, 0.2)), # reedy: odd harmonics
64}
65
66
67def _pluck(freq: float, duration: float, harmonics) -> np.ndarray:
68 """One exponentially decaying note built from (multiple, amplitude) partials."""
69 t = np.arange(int(SAMPLE_RATE * duration)) / SAMPLE_RATE
70 tone = sum(amp * np.sin(2.0 * np.pi * freq * mult * t) for mult, amp in harmonics)
71 return np.exp(-5.0 * t / duration) * tone
72
73
74def _render_loop(notes, note_len, bass, harmonics) -> np.ndarray:
75 """A normalised mono loop: an arpeggio of plucks over a sine bass drone."""
76 melody = np.concatenate([_pluck(freq, note_len, harmonics) for freq in notes])
77 t = np.arange(len(melody)) / SAMPLE_RATE
78 mix = 0.6 * melody + 0.22 * np.sin(2.0 * np.pi * bass * t)
79 return mix / np.max(np.abs(mix))
80
81
82def _write_wav(path: Path, mono: np.ndarray) -> None:
83 """Write a mono float array as a 16-bit stereo WAV with the stdlib."""
84 samples = (np.clip(mono, -1.0, 1.0) * 0.8 * 32767).astype(np.int16)
85 stereo = np.column_stack((samples, samples))
86 with wave.open(str(path), "wb") as out:
87 out.setnchannels(2)
88 out.setsampwidth(2)
89 out.setframerate(SAMPLE_RATE)
90 out.writeframes(stereo.tobytes())
91
92
93def _write_tracks() -> tuple[Path, Path]:
94 """Render both loops into a fresh temp directory and return their paths.
95
96 Streaming needs real files on disk, so the PCM is written out rather than
97 wrapped in a synthetic clip. mkdtemp keeps the example's own directory
98 clean (it may be a read-only site-packages install); the OS reaps /tmp.
99 """
100 folder = Path(tempfile.mkdtemp(prefix="simvx_music_"))
101 path_a, path_b = folder / "track_a.wav", folder / "track_b.wav"
102 _write_wav(path_a, _render_loop(**TRACK_A))
103 _write_wav(path_b, _render_loop(**TRACK_B))
104 return path_a, path_b
105
106
107class MusicDemo(Node):
108 """Two streaming music players: crossfade between them, duck for dialogue."""
109
110 input_actions = {
111 "quit": [Key.ESCAPE],
112 "crossfade": [Key.SPACE],
113 "dialogue": [Key.D],
114 }
115
116 def on_ready(self):
117 path_a, path_b = _write_tracks()
118 # stream_mode is [next_play], so it is set here, before the first play().
119 # Only a file-backed AudioClip can stream; a synthetic one raises at play.
120 self._a = self.add_child(
121 AudioPlayer(AudioClip(path_a), name="TrackA", stream_mode="streaming", loop=True, bus="Music")
122 )
123 self._b = self.add_child(
124 AudioPlayer(AudioClip(path_b), name="TrackB", stream_mode="streaming", loop=True, bus="Music")
125 )
126 self._active = self._a
127 self._dialogue = ""
128 self._status = self.add_child(Text2D(text="", position=Vec2(40, 40), name="Status"))
129 self._a.play()
130
131 def on_update(self, dt: float):
132 if Input.is_action_just_pressed("quit"):
133 self.app.quit()
134 return
135 if Input.is_action_just_pressed("crossfade"):
136 self._crossfade()
137 if Input.is_action_just_pressed("dialogue"):
138 self._begin_dialogue()
139 self._update_status()
140
141 def _crossfade(self):
142 outgoing = self._active
143 incoming = self._b if outgoing is self._a else self._a
144 # One call arms both halves in the same frame: `incoming` fades in
145 # (starting playback if it was stopped, from the top of its file) while
146 # `outgoing` fades out and stops at the deadline. The default
147 # equal_power curve keeps the summed loudness steady; pressing SPACE
148 # mid-fade simply retargets both ramps.
149 outgoing.crossfade(incoming, CROSSFADE_SECONDS)
150 self._active = incoming
151
152 def _begin_dialogue(self):
153 if self._dialogue:
154 return
155 self.start_coroutine(self._dialogue_moment(self._active))
156
157 def _dialogue_moment(self, music: AudioPlayer):
158 # fade_to's gain is a multiplier on volume_db and it holds its target,
159 # so the duck coexists with any volume the user has set on the player.
160 self._dialogue = '"The music dips while I speak, then swells back."'
161 music.fade_to(DUCK_GAIN, DUCK_FADE_SECONDS)
162 yield from wait(DIALOGUE_SECONDS)
163 music.fade_to(1.0, RESTORE_FADE_SECONDS)
164 self._dialogue = ""
165
166 def _update_status(self):
167 def describe(player: AudioPlayer, label: str) -> str:
168 marker = "->" if player is self._active else " "
169 state = "playing" if player.is_playing() else "stopped"
170 # get_playback_position works for streaming players too: the
171 # backend accounts for the frames it has decoded and mixed. On a
172 # looping stream it keeps counting up rather than wrapping.
173 return f"{marker} {label}: {state} at {player.get_playback_position():.1f}s"
174
175 self._status.text = (
176 "Music Streaming\n\n"
177 f"{describe(self._a, 'Track A (warm arpeggio)')}\n"
178 f"{describe(self._b, 'Track B (bright reed)')}\n\n"
179 f"Dialogue: {self._dialogue or '(none)'}\n\n"
180 "SPACE: crossfade to the other track\n"
181 "D: dialogue line (duck to 25%, then restore)\n"
182 "ESC: quit"
183 )
184
185
186def _selftest() -> bool:
187 """Headless: drive the demo on a SceneTree against a recording backend.
188
189 NullAudioBackend deliberately cannot stream, so the test installs a stub
190 that satisfies the streaming facet (open_stream / feed_audio_chunk) and
191 records every volume the engine pushes. The fades then become observable
192 without a sound card: the crossfade midpoint, the duck floor and the
193 restore all appear in the pushed dB values.
194 """
195 from simvx.core import SceneTree
196
197 class RecordingBackend:
198 """Streaming-capable, device-free backend: pure bookkeeping."""
199
200 def __init__(self):
201 self._next = 1
202 self.ring_frames = 24000
203 self.fed: dict[int, bytearray] = {}
204 self.volumes: dict[int, list[float]] = {}
205
206 def open_stream(self, *, volume_db=0.0, bus="Master", buffer_seconds=0.5, loop=False, stream=None) -> int:
207 cid = self._next
208 self._next += 1
209 self.fed[cid] = bytearray()
210 self.volumes[cid] = [volume_db]
211 return cid
212
213 def feed_audio_chunk(self, channel_id, chunk) -> None:
214 self.fed[channel_id].extend(chunk)
215
216 def stream_format(self) -> tuple[int, int]:
217 return 48000, 2
218
219 def frames_available(self, channel_id) -> int:
220 return max(0, self.ring_frames - len(self.fed.get(channel_id, b"")) // 4)
221
222 def get_playback_position(self, channel_id) -> float:
223 # Pretend everything fed has played: 48 kHz stereo int16 frames.
224 return len(self.fed.get(channel_id, b"")) / (48000 * 4)
225
226 def update_audio_2d(self, channel_id, volume_db, pan) -> None:
227 self.volumes.setdefault(channel_id, []).append(volume_db)
228
229 def stop_audio(self, channel_id) -> None:
230 self.fed.pop(channel_id, None)
231
232 def sync_bus_layout(self, layout) -> None:
233 pass
234
235 def list_capabilities(self):
236 return frozenset()
237
238 def shutdown(self) -> None:
239 pass
240
241 ok = True
242
243 def check(label: str, passed: bool, detail: str) -> None:
244 nonlocal ok
245 ok = ok and passed
246 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
247
248 # The rendered files must be real, distinct, streamable WAVs.
249 path_a, path_b = _write_tracks()
250 with wave.open(str(path_a), "rb") as wav_a, wave.open(str(path_b), "rb") as wav_b:
251 params_ok = all(
252 handle.getnchannels() == 2 and handle.getsampwidth() == 2 and handle.getframerate() == SAMPLE_RATE
253 for handle in (wav_a, wav_b)
254 )
255 seconds_a = wav_a.getnframes() / SAMPLE_RATE
256 distinct = wav_a.readframes(wav_a.getnframes()) != wav_b.readframes(wav_b.getnframes())
257 check("tracks are 16-bit stereo WAVs at the declared rate", params_ok, f"track A runs {seconds_a:.1f}s")
258 check("the two tracks are audibly different files", distinct, "payloads differ")
259
260 tree = SceneTree()
261 backend = RecordingBackend()
262 tree.install_audio_backend(backend)
263 demo = MusicDemo(name="MusicDemo")
264 tree.set_root(demo)
265 dt = 1.0 / 60.0
266
267 for _ in range(12):
268 tree.tick(dt)
269 fed = len(backend.fed.get(1, b""))
270 check(
271 "track A streams: playing, with PCM chunks fed to the backend",
272 demo._a.is_playing() and fed > 0,
273 f"{fed} bytes fed to channel 1",
274 )
275 check("track B waits silently", not demo._b.is_playing(), "not playing")
276
277 # Crossfade: both audible at the midpoint, handover complete at the deadline.
278 demo._crossfade()
279 for _ in range(60): # 1.0 s into a 2.0 s crossfade
280 tree.tick(dt)
281 va, vb = backend.volumes[1][-1], backend.volumes[2][-1]
282 check(
283 "mid-crossfade both tracks are playing",
284 demo._a.is_playing() and demo._b.is_playing(),
285 f"A at {va:.1f} dB, B at {vb:.1f} dB",
286 )
287 check(
288 "the equal-power midpoint holds both near -3 dB",
289 -6.0 < va < -1.0 and -6.0 < vb < -1.0,
290 f"A {va:.2f} dB, B {vb:.2f} dB",
291 )
292 for _ in range(90): # well past the 2.0 s deadline
293 tree.tick(dt)
294 check(
295 "the handover completed: A stopped, B looping on",
296 not demo._a.is_playing() and demo._b.is_playing() and demo._active is demo._b,
297 "A stopped, B playing",
298 )
299
300 # Ducking: fade_to(0.25, ...) lands on -12 dB, the restore returns to 0 dB.
301 duck_db = 20.0 * math.log10(DUCK_GAIN)
302 demo._begin_dialogue()
303 for _ in range(36): # 0.6 s: the 0.3 s duck has landed, the line still runs
304 tree.tick(dt)
305 floor = backend.volumes[2][-1]
306 check("the duck landed on a quarter amplitude", abs(floor - duck_db) < 0.3, f"{floor:.2f} dB vs {duck_db:.2f} dB")
307 for _ in range(int((DIALOGUE_SECONDS + RESTORE_FADE_SECONDS + 0.5) * 60)):
308 tree.tick(dt)
309 restored = backend.volumes[2][-1]
310 check(
311 "the music swelled back to full volume",
312 abs(restored) < 0.3 and demo._b.is_playing() and demo._dialogue == "",
313 f"{restored:.2f} dB, dialogue over",
314 )
315
316 print("SELFTEST:", "PASS" if ok else "FAIL")
317 return ok
318
319
320if __name__ == "__main__":
321 import sys
322
323 if "--test" in sys.argv:
324 sys.exit(0 if _selftest() else 1)
325 App(title="Music Streaming", width=960, height=540).run(MusicDemo())