Audio Buses¶
per-bus volume, mute/solo, and bus effects
▶ Run in browserTags: audio buses effects mixing ui
A synthesised arpeggio loop plays on the “Music” bus while periodic blips
fire on “SFX”. Sliders set each bus’s volume_db live, M/S check boxes drive
mute and solo (solo silences every bus off the soloed bus’s routing path,
so a group bus keeps whatever is routed into it audible), and two
toggles put effects on the Music bus: a LowPassFilter whose cutoff sweeps
continuously, and a ReverbEffect switched on and off whole. Only the five
built-in buses (Master, Music, SFX, Voice, UI) are routable; a player’s
bus property rejects any other name. Bus effects are silently inert on
the pure-Python fallback audio backend.
What it demonstrates¶
Routing players to buses via the
busproperty (set before play()).AudioBusLayout.get_default()and livevolume_db/mute/solo.effective_volume: the dB chain walked throughsend_toup to Master.send_toas signal routing: a bus’s output runs through its target’s gain and effects, so an effect on Master processes every bus under it.Bus effects:
add_effect/remove_effect, sweeping a biquad’scutoff_hz(clean), toggling a ReverbEffect whole (its parameters are never swept: changing them rebuilds the native chain audibly).
Controls: Sliders - per-bus volume_db M / S - mute / solo a bus (solo is ignored on Master) Toggles - low-pass cutoff sweep and reverb on the Music bus B - fire a blip on the SFX bus (or click the Blip button) ESC - Quit
Run: uv run python examples/features/audio/buses.py Headless self-check: uv run python examples/features/audio/buses.py –test
Source¶
1"""Audio Buses: per-bus volume, mute/solo, and bus effects
2
3A synthesised arpeggio loop plays on the "Music" bus while periodic blips
4fire on "SFX". Sliders set each bus's volume_db live, M/S check boxes drive
5mute and solo (solo silences every bus off the soloed bus's routing path,
6so a group bus keeps whatever is routed into it audible), and two
7toggles put effects on the Music bus: a LowPassFilter whose cutoff sweeps
8continuously, and a ReverbEffect switched on and off whole. Only the five
9built-in buses (Master, Music, SFX, Voice, UI) are routable; a player's
10`bus` property rejects any other name. Bus effects are silently inert on
11the pure-Python fallback audio backend.
12
13# /// simvx
14# tags = ["audio", "buses", "effects", "mixing", "ui"]
15# ///
16
17## What it demonstrates
18- Routing players to buses via the `bus` property (set before play()).
19- `AudioBusLayout.get_default()` and live `volume_db` / `mute` / `solo`.
20- `effective_volume`: the dB chain walked through `send_to` up to Master.
21- `send_to` as signal routing: a bus's output runs through its target's
22 gain and effects, so an effect on Master processes every bus under it.
23- Bus effects: `add_effect` / `remove_effect`, sweeping a biquad's
24 `cutoff_hz` (clean), toggling a ReverbEffect whole (its parameters are
25 never swept: changing them rebuilds the native chain audibly).
26
27Controls:
28 Sliders - per-bus volume_db
29 M / S - mute / solo a bus (solo is ignored on Master)
30 Toggles - low-pass cutoff sweep and reverb on the Music bus
31 B - fire a blip on the SFX bus (or click the Blip button)
32 ESC - Quit
33
34Run: uv run python examples/features/audio/buses.py
35Headless self-check: uv run python examples/features/audio/buses.py --test
36"""
37
38import numpy as np
39
40from simvx.core import (
41 AnchorPreset,
42 AudioBusLayout,
43 AudioClip,
44 AudioPlayer,
45 Colour,
46 Input,
47 Key,
48 Label,
49 LowPassFilter,
50 Node,
51 Panel,
52 ReverbEffect,
53 Timer,
54 Vec2,
55)
56from simvx.core.ui import Button, CheckBox, HBoxContainer, Slider, VBoxContainer
57from simvx.graphics import App
58
59WIDTH, HEIGHT = 960, 540
60SAMPLE_RATE = 44100
61
62# Slider range: a usable mixing window inside the bus's full [-80, 24] dB span.
63SLIDER_MIN_DB, SLIDER_MAX_DB = -40.0, 12.0
64
65# Low-pass sweep: cutoff_hz glides between these bounds, exponentially so the
66# motion sounds even across octaves. Biquad parameter sweeps are clean; only
67# Delay/Reverb rebuild their processing chain on a parameter change.
68SWEEP_LOW_HZ, SWEEP_HIGH_HZ = 300.0, 8000.0
69SWEEP_PERIOD_S = 6.0
70
71BLIP_INTERVAL_S = 0.9
72BLIP_FREQS = (660.0, 880.0, 1320.0)
73
74
75def make_music_loop() -> AudioClip:
76 """Render a two-second Cmaj7 arpeggio loop as a stereo PCM clip."""
77 step_s = 0.25
78 notes = (261.63, 329.63, 392.00, 493.88, 523.25, 493.88, 392.00, 329.63)
79 n = int(SAMPLE_RATE * step_s)
80 t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
81 # Fast attack, exponential decay: reads as a plucked note, and the decay
82 # reaching near-zero by the step boundary keeps the loop seam silent.
83 envelope = np.minimum(t / 0.01, 1.0) * np.exp(-4.0 * t)
84 parts = []
85 for freq in notes:
86 wave = np.sin(2 * np.pi * freq * t) + 0.35 * np.sin(2 * np.pi * 2 * freq * t)
87 parts.append((wave * envelope * 0.22).astype(np.float32))
88 mono = np.concatenate(parts)
89 stereo = np.repeat(mono, 2) # interleave L/R with the same signal
90 return AudioClip.from_pcm(stereo, sample_rate=SAMPLE_RATE, channels=2, name="arp_loop")
91
92
93def make_blip(freq_hz: float) -> AudioClip:
94 """Render a short percussive blip as a mono PCM clip."""
95 n = int(SAMPLE_RATE * 0.12)
96 t = np.arange(n, dtype=np.float32) / SAMPLE_RATE
97 mono = (np.sin(2 * np.pi * freq_hz * t) * np.exp(-30.0 * t) * 0.4).astype(np.float32)
98 return AudioClip.from_pcm(mono, sample_rate=SAMPLE_RATE, channels=1, name=f"blip_{int(freq_hz)}")
99
100
101class BusMixerDemo(Node):
102 """A three-strip mixer over the default bus layout, with Music-bus effects."""
103
104 input_actions = {"quit": [Key.ESCAPE], "blip": [Key.B]}
105
106 def on_ready(self):
107 self._layout = AudioBusLayout.get_default()
108 self._elapsed = 0.0
109 self._blip_index = 0
110
111 # Effect instances are kept and reused: toggling re-adds the same
112 # object, so its parameters survive an off/on cycle.
113 self._lowpass = LowPassFilter(cutoff_hz=SWEEP_HIGH_HZ)
114 self._reverb = ReverbEffect(room_size=0.8, wet=0.45, dry=0.7)
115 self._sweeping = False
116
117 # Music: a looping synthesised arpeggio. `bus` and `loop` are
118 # next-play properties, so both are set before the first play().
119 self._music = self.add_child(AudioPlayer(name="Music"))
120 self._music.bus = "Music"
121 self._music.loop = True
122 self._music.stream = make_music_loop()
123 self._music.play()
124
125 # SFX: short blips, re-triggered by a repeating Timer and by the B key.
126 self._blips = [make_blip(freq) for freq in BLIP_FREQS]
127 self._sfx = self.add_child(AudioPlayer(name="Blip"))
128 self._sfx.bus = "SFX"
129 timer = self.add_child(Timer(duration=BLIP_INTERVAL_S, one_shot=False, autostart=True))
130 timer.timeout.connect(self._play_blip)
131
132 self._build_ui()
133
134 # --- UI -----------------------------------------------------------------
135
136 def _build_ui(self):
137 panel = Panel(name="MixerPanel")
138 panel.set_anchor_preset(AnchorPreset.CENTER)
139 panel.margin_left = -310
140 panel.margin_right = 310
141 panel.margin_top = -215
142 panel.margin_bottom = 215
143 panel.bg_colour = Colour.hex("#16182A")
144 self.add_child(panel)
145
146 column = VBoxContainer(name="Column")
147 column.separation = 14.0
148 column.set_anchor_preset(AnchorPreset.FULL_RECT)
149 column.margin_left = 24
150 column.margin_right = 24
151 column.margin_top = 18
152 column.margin_bottom = 18
153 panel.add_child(column)
154
155 title = Label("Audio Buses")
156 title.font_size = 22.0
157 title.alignment = "center"
158 column.add_child(title)
159
160 self._sliders: dict[str, Slider] = {}
161 # Master last: its strip scales the whole mix, including both children.
162 for bus_name in ("Music", "SFX", "Master"):
163 column.add_child(self._make_strip(bus_name))
164
165 # Effects on the Music bus. The low-pass toggle starts a continuous
166 # cutoff sweep; the reverb toggle adds/removes the whole effect and its
167 # parameters are deliberately left alone while it runs.
168 fx_row = HBoxContainer(name="Effects")
169 fx_row.separation = 18.0
170 fx_label = Label("Music FX:")
171 fx_label.min_size_x = 90
172 fx_row.add_child(fx_label)
173 self._sweep_toggle = CheckBox("Low-pass sweep", on_toggle=self._on_sweep_toggled)
174 fx_row.add_child(self._sweep_toggle)
175 self._reverb_toggle = CheckBox("Reverb", on_toggle=self._on_reverb_toggled)
176 fx_row.add_child(self._reverb_toggle)
177 blip_button = Button("Blip", on_press=self._play_blip)
178 blip_button.size = Vec2(80, 26)
179 fx_row.add_child(blip_button)
180 column.add_child(fx_row)
181
182 self._status = Label("")
183 self._status.font_size = 13.0
184 self._status.text_colour = Colour.LIGHT_GRAY
185 column.add_child(self._status)
186
187 hint = Label("Sliders: volume_db M: mute S: solo B: blip ESC: quit")
188 hint.font_size = 12.0
189 hint.text_colour = Colour.GRAY
190 column.add_child(hint)
191
192 def _make_strip(self, bus_name: str) -> HBoxContainer:
193 """One mixer strip: name, volume slider, mute box, solo box."""
194 bus = self._layout.get_bus(bus_name)
195 row = HBoxContainer(name=f"{bus_name}Strip")
196 row.separation = 12.0
197
198 label = Label(bus_name)
199 label.min_size_x = 90
200 row.add_child(label)
201
202 slider = Slider(SLIDER_MIN_DB, SLIDER_MAX_DB, value=bus.volume_db)
203 slider.size = Vec2(260, 22)
204 slider.value_changed.connect(lambda db, b=bus: setattr(b, "volume_db", db))
205 self._sliders[bus_name] = slider
206 row.add_child(slider)
207
208 mute = CheckBox("M", checked=bus.mute, on_toggle=lambda on, b=bus: setattr(b, "mute", on))
209 row.add_child(mute)
210 if bus_name != "Master":
211 # Soloing Master is a no-op (it is already the root mix), so the
212 # engine ignores its solo flag and the strip omits the box.
213 solo = CheckBox("S", checked=bus.solo, on_toggle=lambda on, b=bus: setattr(b, "solo", on))
214 row.add_child(solo)
215 return row
216
217 # --- Behaviour ----------------------------------------------------------
218
219 def _play_blip(self):
220 # A fresh clip each trigger; stop() first so play() restarts rather
221 # than resuming. The player was routed to "SFX" once, at ready.
222 self._sfx.stop()
223 self._sfx.stream = self._blips[self._blip_index]
224 self._blip_index = (self._blip_index + 1) % len(self._blips)
225 self._sfx.play()
226
227 def _on_sweep_toggled(self, enabled: bool):
228 self._sweeping = enabled
229 music = self._layout.get_bus("Music")
230 if enabled:
231 music.add_effect(self._lowpass)
232 else:
233 music.remove_effect(self._lowpass)
234
235 def _on_reverb_toggled(self, enabled: bool):
236 music = self._layout.get_bus("Music")
237 if enabled:
238 music.add_effect(self._reverb)
239 else:
240 music.remove_effect(self._reverb)
241
242 def on_update(self, dt: float):
243 if Input.is_action_just_pressed("quit"):
244 self.app.quit()
245 return
246 if Input.is_action_just_pressed("blip"):
247 self._play_blip()
248
249 self._elapsed += dt
250 if self._sweeping:
251 # Exponential glide between the bounds: linear in octaves.
252 phase = 0.5 - 0.5 * np.cos(2 * np.pi * self._elapsed / SWEEP_PERIOD_S)
253 self._lowpass.cutoff_hz = SWEEP_LOW_HZ * (SWEEP_HIGH_HZ / SWEEP_LOW_HZ) ** phase
254
255 self._refresh_status()
256
257 def _refresh_status(self):
258 # effective_volume walks the send_to chain, so it shows mute and solo
259 # gating as -80 dB even when the strip's own slider is untouched.
260 parts = []
261 for name in ("Music", "SFX", "Master"):
262 parts.append(f"{name} {self._layout.get_bus(name).effective_volume:+.0f} dB")
263 line = "Effective: " + " ".join(parts)
264 if self._sweeping:
265 line += f" | cutoff {self._lowpass.cutoff_hz:.0f} Hz"
266 self._status.text = line
267
268
269def _selftest() -> bool:
270 """Headless: check the bus maths on a fresh layout, then the demo's wiring."""
271 ok = True
272
273 def check(label: str, passed: bool, detail: str = "") -> None:
274 nonlocal ok
275 ok = ok and passed
276 print(f"{'ok ' if passed else 'FAIL'} {label}" + (f": {detail}" if detail else ""))
277
278 # --- Pure bus logic, on a private layout so nothing global is touched ---
279 layout = AudioBusLayout.create_default()
280 check(
281 "the default layout holds exactly the five routable buses",
282 set(layout.bus_names) == {"Master", "Music", "SFX", "Voice", "UI"},
283 str(layout.bus_names),
284 )
285
286 player = AudioPlayer(name="Probe")
287 try:
288 player.bus = "Ambience"
289 routable = False
290 except ValueError:
291 routable = True
292 check("a player's bus property rejects non-built-in names", routable)
293
294 music, sfx, master = layout.get_bus("Music"), layout.get_bus("SFX"), layout.get_bus("Master")
295 music.volume_db = -6.0
296 master.volume_db = -6.0
297 check("effective_volume sums the send_to chain", music.effective_volume == -12.0, f"{music.effective_volume}")
298 music.volume_db = -200.0 # Property clamps to the [-80, 24] range
299 check(
300 "volume_db clamps to the floor and goes silent",
301 music.volume_db == -80.0 and music.effective_linear_volume == 0.0,
302 f"{music.volume_db} dB, linear {music.effective_linear_volume}",
303 )
304 music.volume_db = 0.0
305 master.mute = True
306 check("muting Master silences a child bus", music.effective_volume == -80.0, f"{music.effective_volume}")
307 master.mute = False
308 sfx.solo = True
309 check(
310 "solo on SFX gates Music but leaves SFX and Master audible",
311 music.effective_volume == -80.0 and sfx.effective_volume > -80.0 and master.effective_volume > -80.0,
312 f"music {music.effective_volume}, sfx {sfx.effective_volume}, master {master.effective_volume}",
313 )
314 footsteps = layout.add_bus("Footsteps", send_to="SFX")
315 check(
316 "solo on a group bus keeps what is routed into it audible",
317 footsteps.effective_volume > -80.0,
318 f"footsteps {footsteps.effective_volume}",
319 )
320 layout.remove_bus("SFX")
321 check(
322 "removing a bus re-routes what fed it rather than orphaning it",
323 footsteps.send_to == "Master",
324 f"footsteps -> {footsteps.send_to!r}",
325 )
326 layout = AudioBusLayout.create_default()
327 music, sfx, master = layout.get_bus("Music"), layout.get_bus("SFX"), layout.get_bus("Master")
328
329 lowpass = LowPassFilter(cutoff_hz=800.0)
330 music.add_effect(lowpass)
331 check("add_effect places the effect on the bus chain", lowpass in music.effects)
332 lowpass.cutoff_hz = 5.0
333 check("cutoff_hz clamps to its declared range", lowpass.cutoff_hz == 20.0, f"{lowpass.cutoff_hz}")
334 music.remove_effect(lowpass)
335 check("remove_effect takes it off again", lowpass not in music.effects)
336
337 loop_clip = make_music_loop()
338 check(
339 "the synthesised loop is baked PCM the backend can play",
340 loop_clip.backend_data is not None and loop_clip.backend_data.dtype == np.float32,
341 )
342
343 # --- The demo itself, mounted headless ---
344 app = App(title="Audio Buses", width=WIDTH, height=HEIGHT, visible=False)
345 scene = BusMixerDemo(name="BusMixerDemo")
346 default = AudioBusLayout.get_default()
347 cutoffs: list[float] = []
348
349 def on_frame(idx: int, _t: float) -> bool:
350 if idx == 5:
351 # Drive the Music slider the way its signal contract promises.
352 scene._sliders["Music"].value = -12.0
353 scene._sliders["Music"].value_changed.emit(-12.0)
354 scene._sweep_toggle.activate() # toggles + emits, like a click
355 scene._reverb_toggle.activate()
356 if idx == 8:
357 scene._play_blip()
358 if idx >= 10:
359 cutoffs.append(float(scene._lowpass.cutoff_hz))
360 return True
361
362 app.run_headless(scene, frames=60, on_frame=on_frame)
363
364 check("the music loop is playing on the Music bus", scene._music.is_playing() and scene._music.bus == "Music")
365 check("the blip player is routed to SFX", scene._sfx.bus == "SFX")
366 check(
367 "the slider write landed on the bus",
368 default.get_bus("Music").volume_db == -12.0,
369 f"{default.get_bus('Music').volume_db}",
370 )
371 check(
372 "the toggles installed both effects on the Music bus",
373 scene._lowpass in default.get_bus("Music").effects and scene._reverb in default.get_bus("Music").effects,
374 )
375 swept = len(cutoffs) >= 2 and cutoffs[0] != cutoffs[-1]
376 in_range = all(20.0 <= c <= 20000.0 for c in cutoffs)
377 check(
378 "the low-pass cutoff sweeps within its legal range",
379 swept and in_range,
380 f"{cutoffs[0]:.0f} Hz -> {cutoffs[-1]:.0f} Hz over {len(cutoffs)} frames" if cutoffs else "no samples",
381 )
382
383 print("SELFTEST:", "PASS" if ok else "FAIL")
384 return ok
385
386
387if __name__ == "__main__":
388 import sys
389
390 if "--test" in sys.argv:
391 sys.exit(0 if _selftest() else 1)
392 App(title="Audio Buses", width=WIDTH, height=HEIGHT).run(BusMixerDemo())