Audio Buses.

Buses are the mixing desk every sound passes through. AudioBusLayout.get_default() holds five of them: Master, with Music, SFX, Voice and UI sending into it. A player chooses its bus with the bus property, which accepts only those five names:

▶ Run in browser

Tags: audio buses effects mixing ui

AudioPlayer(clip, bus="Music", loop=True, autoplay=True)

Here a synthesised arpeggio loops on the Music bus while a Timer fires a blip on SFX every 0.9 seconds; press B or the Blip button for another. Each mixer strip drives one bus live: the slider sets volume_db, the M box mute and the S box solo. Solo silences every bus off the soloed bus’s routing path, so a group bus keeps what is routed into it audible; Master has no S box because it is the whole mix already.

The status line reads effective_volume, the dB sum along the send_to chain up to Master, which drops to the -80 dB floor when a bus on that chain is muted or solo leaves it out. A bus’s output runs through its target’s gain and effects, so an effect on Master processes every bus under it.

The two check boxes put effects on the Music bus with add_effect and remove_effect, and keep each effect object so its settings survive an off and on. While the LowPassFilter is on, the scene sweeps its cutoff_hz between 300 and 8000 Hz each frame; an effect’s properties are read live, so assigning one is all it takes. The ReverbEffect is only ever added and removed whole. Effects run on the native audio backend; the pure-Python fallback mixer plays the buses without them.

Source

  1# /// simvx
  2# tags = ["audio", "buses", "effects", "mixing", "ui"]
  3# ///
  4"""Audio Buses.
  5
  6Buses are the mixing desk every sound passes through. `AudioBusLayout.get_default()`
  7holds five of them: Master, with Music, SFX, Voice and UI sending into it. A player
  8chooses its bus with the `bus` property, which accepts only those five names:
  9
 10```python
 11AudioPlayer(clip, bus="Music", loop=True, autoplay=True)
 12```
 13
 14Here a synthesised arpeggio loops on the Music bus while a `Timer` fires a blip on
 15SFX every 0.9 seconds; press B or the Blip button for another. Each mixer strip drives
 16one bus live: the slider sets `volume_db`, the M box `mute` and the S box `solo`.
 17Solo silences every bus off the soloed bus's routing path, so a group bus keeps what
 18is routed into it audible; Master has no S box because it is the whole mix already.
 19
 20The status line reads `effective_volume`, the dB sum along the `send_to` chain up to
 21Master, which drops to the -80 dB floor when a bus on that chain is muted or solo
 22leaves it out. A bus's output runs through its target's gain and effects, so an
 23effect on Master processes every bus under it.
 24
 25The two check boxes put effects on the Music bus with `add_effect` and
 26`remove_effect`, and keep each effect object so its settings survive an off and on.
 27While the `LowPassFilter` is on, the scene sweeps its `cutoff_hz` between 300 and
 288000 Hz each frame; an effect's properties are read live, so assigning one is all it
 29takes. The `ReverbEffect` is only ever added and removed whole. Effects run on the
 30native audio backend; the pure-Python fallback mixer plays the buses without them.
 31"""
 32
 33import itertools
 34
 35import numpy as np
 36
 37from simvx.core import (
 38    Anchor,
 39    AudioBus,
 40    AudioBusLayout,
 41    AudioClip,
 42    AudioPlayer,
 43    Colour,
 44    HAlign,
 45    Key,
 46    Label,
 47    LowPassFilter,
 48    Node,
 49    Panel,
 50    ReverbEffect,
 51    Timer,
 52    on_input,
 53)
 54from simvx.core.ui import Button, CheckBox, HBoxContainer, Slider, VBoxContainer
 55from simvx.graphics import App
 56
 57SAMPLE_RATE = 44100
 58SWEEP_LOW_HZ, SWEEP_HIGH_HZ, SWEEP_PERIOD_S = 300.0, 8000.0, 6.0
 59
 60
 61def arpeggio_loop() -> AudioClip:
 62    """A two-second Cmaj7 arpeggio of plucked notes that loops without a click."""
 63    t = np.arange(int(SAMPLE_RATE * 0.25), dtype=np.float32) / SAMPLE_RATE
 64    # Fast attack, then a decay that is near silent by the end of each step.
 65    envelope = np.minimum(t / 0.01, 1.0) * np.exp(-4.0 * t)
 66    notes = (261.63, 329.63, 392.00, 493.88, 523.25, 493.88, 392.00, 329.63)
 67    pcm = np.concatenate([(np.sin(2 * np.pi * f * t) + 0.35 * np.sin(4 * np.pi * f * t)) * envelope for f in notes])
 68    return AudioClip.from_pcm((pcm * 0.22).astype(np.float32), sample_rate=SAMPLE_RATE, channels=1)
 69
 70
 71def blip(freq_hz: float) -> AudioClip:
 72    """A short percussive sine blip."""
 73    t = np.arange(int(SAMPLE_RATE * 0.12), dtype=np.float32) / SAMPLE_RATE
 74    pcm = np.sin(2 * np.pi * freq_hz * t) * np.exp(-30.0 * t) * 0.4
 75    return AudioClip.from_pcm(pcm.astype(np.float32), sample_rate=SAMPLE_RATE, channels=1)
 76
 77
 78class BusStrip(HBoxContainer):
 79    """One mixer strip: the bus name, a volume_db slider, and mute and solo boxes."""
 80
 81    def __init__(self, bus: AudioBus, **kwargs):
 82        super().__init__(name=f"{bus.name}Strip", separation=12, **kwargs)
 83        self.bus = bus
 84        slider = Slider(-40, 12, value=bus.volume_db, size=(260, 22), name="Volume")
 85        slider.value_changed.connect(self.set_volume)
 86        self.add_child(Label(bus.name, min_size_x=90))
 87        self.add_child(slider)
 88        self.add_child(CheckBox("M", on_toggle=self.set_mute, name="Mute"))
 89        if bus.name != "Master":
 90            self.add_child(CheckBox("S", on_toggle=self.set_solo, name="Solo"))
 91
 92    def set_volume(self, db: float):
 93        self.bus.volume_db = db
 94
 95    def set_mute(self, on: bool):
 96        self.bus.mute = on
 97
 98    def set_solo(self, on: bool):
 99        self.bus.solo = on
100
101
102class BusMixer(Node):
103    """Music and SFX players on their own buses, a strip per bus, and Music-bus effects."""
104
105    input_context = {"blip": [Key.B]}
106
107    def on_ready(self):
108        layout = AudioBusLayout.get_default()
109        # Master last: its strip scales the whole mix.
110        self.buses = [layout.get_bus(name) for name in ("Music", "SFX", "Master")]
111        self.music_bus = self.buses[0]
112        self.lowpass = LowPassFilter(cutoff_hz=SWEEP_HIGH_HZ)
113        self.reverb = ReverbEffect(room_size=0.8, wet=0.45, dry=0.7)
114        self.elapsed = 0.0
115
116        self.add_child(AudioPlayer(arpeggio_loop(), bus="Music", loop=True, autoplay=True, name="Music"))
117        self.sfx = self.add_child(AudioPlayer(bus="SFX", name="Blip"))
118        self.blips = itertools.cycle([blip(f) for f in (660.0, 880.0, 1320.0)])
119        timer = self.add_child(Timer(duration=0.9, one_shot=False, autostart=True))
120        timer.timeout.connect(self.play_blip)
121
122        self.status = Label(font_size=13, name="Status")
123        self.status.text_colour = Colour.LIGHT_GRAY
124        effects = HBoxContainer(
125            separation=18,
126            children=[
127                Label("Music FX:", min_size_x=90),
128                CheckBox("Low-pass sweep", on_toggle=lambda on: self.set_effect(self.lowpass, on), name="LowPass"),
129                CheckBox("Reverb", on_toggle=lambda on: self.set_effect(self.reverb, on), name="Reverb"),
130                Button("Blip", on_press=self.play_blip, size=(80, 26), name="BlipButton"),
131            ],
132        )
133        panel = self.add_child(Panel(anchor=Anchor.CENTER, size=(620, 400)))
134        panel.add_child(
135            VBoxContainer(
136                anchor=Anchor.FULL_RECT,
137                inset=(24, 18),
138                separation=14,
139                children=[
140                    Label("Audio Buses", font_size=22, alignment=HAlign.CENTRE),
141                    *(BusStrip(bus) for bus in self.buses),
142                    effects,
143                    self.status,
144                ],
145            )
146        )
147
148        # Example help overlay: not part of the technique shown.
149        hint = Label("Sliders: volume_db   M: mute   S: solo   B: blip", anchor=Anchor.BOTTOM_LEFT, inset=12)
150        hint.text_colour = Colour.GRAY
151        self.add_child(hint)
152
153    @on_input("blip")
154    def play_blip(self, _event=None):
155        self.sfx.stop()  # restart rather than resume
156        self.sfx.stream = next(self.blips)
157        self.sfx.play()
158
159    def set_effect(self, effect, enabled: bool):
160        if enabled:
161            self.music_bus.add_effect(effect)
162        else:
163            self.music_bus.remove_effect(effect)
164
165    def on_update(self, dt: float):
166        self.elapsed += dt
167        sweeping = self.lowpass in self.music_bus.effects
168        if sweeping:
169            # An exponential glide between the bounds sounds even across octaves.
170            phase = 0.5 - 0.5 * np.cos(2 * np.pi * self.elapsed / SWEEP_PERIOD_S)
171            self.lowpass.cutoff_hz = SWEEP_LOW_HZ * (SWEEP_HIGH_HZ / SWEEP_LOW_HZ) ** phase
172
173        levels = "   ".join(f"{bus.name} {bus.effective_volume:+.0f} dB" for bus in self.buses)
174        self.status.text = f"Effective:  {levels}" + (f"   cutoff {self.lowpass.cutoff_hz:.0f} Hz" if sweeping else "")
175
176
177if __name__ == "__main__":
178    App(title="Audio Buses", width=960, height=540).run(BusMixer())