Music Streaming¶

file-backed loops that crossfade and duck under dialogue.

📄 Docs only

Tags: 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 wave module. Only file-backed clips can stream (synthetic AudioClip.tone/from_pcm cannot), 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 with fade_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())