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:
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())