Audio Buses

per-bus volume, mute/solo, and bus effects

▶ Run in browser

Tags: 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 bus property (set before play()).

  • AudioBusLayout.get_default() and live volume_db / mute / solo.

  • effective_volume: the dB chain walked through send_to up to Master.

  • send_to as 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’s cutoff_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())