audio_bus source

← audio_bus · API reference · version 0.1.3

2 file(s), 420 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
Bus struct36—61 78 91
Voice struct49—62 132
Mixer struct60—76 77 87 100 118 129 … (20 total)
MASTER const66—78 87 15 16 23 26 … (9 total)
MAX_DEPTH const73—103 261
mixer fn76—14 46 105 115 125 132 … (7 total)
add fn87—15 16 17 36 37 133 … (7 total)
gain fn100—121 150 159 23 24 25 … (10 total)
level fn118—133 23 28 43 45 46 … (14 total)
attach fn129—152 42 47 81 99 100 … (9 total)
detach fn138—114
play fn147—147 150
set_volume fn168—26 35 44 55 69 91
volume fn177—87 91 129 132 147 150 … (18 total)
duck fn190—58 70 71 72 75 83 … (9 total)
unduck fn204—60 73 76 85
ducked fn215—58 59 64
pan fn223—148 150 122 123
stop fn229—116 124 126
stop_bus fn239—104 116
stop_all fn250—107 108 127 149
under fn259—241 24 102 103 137

src/audio_bus.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// audio_bus — the mixer a game reaches for when one slider has to move many
// sounds at once.
//
// ── ⚠⚠ THE INVARIANT ────────────────────────────────────────────────
//
// > **A voice is heard at its own level TIMES the gain of every bus above it,
// > and a duck that is lifted restores the level EXACTLY.**
//
// Both halves are the whole package. The first says why a mixer exists at all:
// `graphics::audio_set_volume` moves one sink, so "turn the music down" without a
// bus means walking a list a game had to keep itself, and "turn everything down"
// means walking all of them. The second says why ducking is not a multiplication:
// a game ducks the music under speech a thousand times an hour, and restoring by
// dividing the gain back out accumulates a drift that ends with the music quietly
// several percent wrong and nothing in the code saying where it went.
//
// So a duck REMEMBERS the level it replaced and puts that number back. It cannot
// drift, because nothing is computed on the way home.
//
// ── WHAT THIS IS NOT ────────────────────────────────────────────────
//
// Not a DSP graph. There is no filtering, no send/return, no compressor — a bus
// here is a number that multiplies, arranged in a tree. That is what a 2-D game
// wants from a mixer, and everything past it belongs to a sound engine.
//
// Not an owner of playback either: `graphics` plays the sounds and this decides
// how loud. A sink a caller played itself is welcome — `attach` puts an existing
// handle on a bus — which is also what lets every rule here be tested on a
// machine with no audio device at all.
use graphics;

/// One level in the tree: a name, a level, and what it hangs under.
pub struct Bus {
  bs_name: text,
  /// The bus above this one. `""` is the top — the level everything passes
  /// through, so a master volume is `set_volume(m, MASTER, x)`.
  pub bs_parent: text = "",
  /// This bus's own level, 0.0 to 1.0.
  bs_volume: float = 1.0,
  /// What `bs_volume` was before the current duck, or `-1.0` when nothing is
  /// ducking it. The duck's cure is REMEMBERING, not dividing back out.
  bs_before: float = -1.0,
}

/// One playing sound, and the bus it answers to.
pub struct Voice {
  /// The handle `graphics::audio_play` answered — the key, because it is the
  /// one thing a caller already holds.
  vc_sink: integer,
  vc_bus: text = "",
  /// The sound's OWN level, before any bus touches it. Kept, because the level
  /// a sink is set to is derived and a derived value cannot be un-multiplied.
  vc_volume: float = 1.0,
}

/// The mixer: the bus tree, and every voice currently on it.
pub struct Mixer {
  pub buses: hash<Bus[bs_name]>,
  voices: hash<Voice[vc_sink]>,
}

/// The name of the bus everything hangs under.
pub const MASTER = "master";

/// How far up a parent chain to walk before deciding it does not end.
///
/// A cycle is a caller's mistake (`add(m, "a", "b")` plus `add(m, "b", "a")`),
/// and the answer to one has to be a number rather than a hang. Ten is deeper
/// than any real mixer: master, music, ambience, sfx, ui is five.
pub const MAX_DEPTH = 10;

/// A mixer with nothing on it but the master bus.
pub fn mixer() -> Mixer {
  mx = Mixer { buses: [], voices: [] };
  mx.buses += [Bus { bs_name: MASTER, bs_parent: "", bs_volume: 1.0, bs_before: -1.0 }];
  mx
}

/// Add a bus under `parent`, at `volume`.
///
/// Answers false — changing nothing — when the name is already taken, when the
/// parent does not exist, or when the name is empty. A mixer that silently
/// accepts a second `"music"` has two of them and obeys neither.
pub fn add(m: Mixer, name: text, parent: text = MASTER, volume: float = 1.0) -> boolean {
  if name == "" { return false }
  if m.buses[name] != null { return false }
  if parent != "" && m.buses[parent] == null { return false }
  m.buses += [Bus { bs_name: name, bs_parent: parent, bs_volume: volume, bs_before: -1.0 }];
  true
}

/// The gain a sound on this bus is multiplied by: the bus's own level times
/// every level above it.
///
/// A name no bus has answers 0.0 rather than 1.0. Silence is the answer a caller
/// can hear; a missing bus that plays at full volume is one they cannot.
pub fn gain(m: const Mixer, name: text) -> float {
  gn_at = name;
  gn_g = 1.0;
  for _ in 0..MAX_DEPTH {
    if gn_at == "" { return gn_g }
    gn_b = m.buses[gn_at];
    if gn_b == null { return 0.0 }
    // `gn_b` is present past the check above; `?` says so to the type.
    gn_g *= gn_b.bs_volume?;
    gn_at = gn_b.bs_parent?;
  }
  // A parent chain that will not end. Answer silence rather than looping: the
  // caller built a cycle, and a mixer that hangs says less about it than a bus
  // that goes quiet.
  0.0
}

/// What a voice's sink should be set to: the sound's own level times its bus.
pub fn level(m: const Mixer, sink: integer) -> float {
  lv_v = m.voices[sink];
  if lv_v == null { return 0.0 }
  lv_v.vc_volume * gain(m, lv_v.vc_bus)
}

/// Put an already-playing sink on a bus at its own level.
///
/// The seam this package is built around: `play` is `graphics::audio_play`
/// followed by this, and a caller who plays a sound their own way — a procedural
/// effect, a clip started before the mixer existed — gets the same bus.
pub fn attach(m: Mixer, sink: integer, bus: text, volume: float = 1.0) -> boolean {
  if sink < 0 || m.buses[bus] == null { return false }
  if m.voices[sink] != null { m.voices[sink] = null; }
  m.voices += [Voice { vc_sink: sink, vc_bus: bus, vc_volume: volume }];
  graphics::audio_set_volume(sink, level(m, sink));
  true
}

/// Forget a sink without stopping it — it keeps whatever level it has.
pub fn detach(m: Mixer, sink: integer) {
  if m.voices[sink] != null { m.voices[sink] = null; }
}

/// Play a clip on a bus. The arguments past `volume` are `graphics::audio_play`'s
/// and mean exactly what they mean there.
///
/// Answers the sink handle, or -1 when the sound did not start — in which case
/// nothing is attached, so a mixer never carries a voice that is not playing.
pub fn play(m: Mixer, bus: text, clip: integer, volume: float = 1.0,
            looping: boolean = false, pan: float = 0.0, start: float = 0.0) -> integer {
  if m.buses[bus] == null { return -1 }
  py_sink = graphics::audio_play(clip, volume * gain(m, bus), looping, pan, start);
  if py_sink < 0 { return -1 }
  _ = attach(m, py_sink, bus, volume);
  py_sink
}

/// Re-apply every voice's level — after a bus moved, or after a whole tree did.
fn reapply(m: Mixer) {
  for v in m.voices {
    graphics::audio_set_volume(v.vc_sink, v.vc_volume * gain(m, v.vc_bus));
  }
}

/// Set a bus's level, and move every sound under it.
///
/// Answers false for a bus that does not exist. A level below 0 is clamped: a
/// negative gain inverts a waveform rather than quietening it, which is not what
/// any caller of a volume control means.
pub fn set_volume(m: Mixer, name: text, volume: float) -> boolean {
  sv_b = m.buses[name];
  if sv_b == null { return false }
  sv_b.bs_volume = if volume < 0.0 { 0.0 } else { volume };
  reapply(m);
  true
}

/// This bus's own level — not counting the buses above it.
pub fn volume(m: const Mixer, name: text) -> float {
  vo_b = m.buses[name];
  if vo_b == null { return 0.0 }
  vo_b.bs_volume
}

/// Duck a bus to `to` — speech over music, a menu over a battle.
///
/// The level it replaces is REMEMBERED, so `unduck` restores it exactly. Ducking
/// an already-ducked bus moves it again and keeps the ORIGINAL level to come home
/// to: a duck inside a duck must not make the first one's restore a guess.
///
/// Answers false for a bus that does not exist.
pub fn duck(m: Mixer, name: text, to: float) -> boolean {
  dk_b = m.buses[name];
  if dk_b == null { return false }
  if dk_b.bs_before < 0.0 { dk_b.bs_before = dk_b.bs_volume; }
  dk_b.bs_volume = if to < 0.0 { 0.0 } else { to };
  reapply(m);
  true
}

/// Lift a duck, putting back the exact level it replaced.
///
/// Answers false for a bus that does not exist or is not ducked — the second is
/// worth an answer rather than a shrug, because an unduck that finds nothing to
/// lift means a duck and its lift have come apart somewhere.
pub fn unduck(m: Mixer, name: text) -> boolean {
  ud_b = m.buses[name];
  if ud_b == null { return false }
  if ud_b.bs_before < 0.0 { return false }
  ud_b.bs_volume = ud_b.bs_before;
  ud_b.bs_before = -1.0;
  reapply(m);
  true
}

/// Is this bus ducked right now?
pub fn ducked(m: const Mixer, name: text) -> boolean {
  dd_b = m.buses[name];
  if dd_b == null { return false }
  dd_b.bs_before >= 0.0
}

/// Move one playing sound across the stereo field. The mixer keeps the voice
/// list, so this is where a caller already is.
pub fn pan(m: const Mixer, sink: integer, position: float) {
  if m.voices[sink] == null { return }
  graphics::audio_set_pan(sink, position);
}

/// Stop one sound and forget it.
pub fn stop(m: Mixer, sink: integer) {
  if m.voices[sink] == null { return }
  graphics::audio_stop(sink);
  m.voices[sink] = null;
}

/// Stop every sound on a bus — and on the buses under it.
///
/// Answers how many it stopped, because "the scene is over, silence its sounds"
/// is a claim a caller may want to check rather than assume.
pub fn stop_bus(m: Mixer, name: text) -> integer {
  sb_gone: vector<integer> = [];
  for v in m.voices if under(m, v.vc_bus, name) { sb_gone += [v.vc_sink]; }
  for s in sb_gone {
    graphics::audio_stop(s);
    m.voices[s] = null;
  }
  len(sb_gone)
}

/// Stop everything, on every bus.
pub fn stop_all(m: Mixer) -> integer {
  sa_gone: vector<integer> = [];
  for v in m.voices { sa_gone += [v.vc_sink]; }
  graphics::audio_stop_all();
  for s in sa_gone { m.voices[s] = null; }
  len(sa_gone)
}

/// Is `name` at or below `ancestor` in the tree?
pub fn under(m: const Mixer, name: text, ancestor: text) -> boolean {
  un_at = name;
  for _ in 0..MAX_DEPTH {
    if un_at == ancestor { return true }
    if un_at == "" { return false }
    un_b = m.buses[un_at];
    if un_b == null { return false }
    un_at = un_b.bs_parent?;
  }
  false
}

tests/bus.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// What a bus multiplies, and what a duck puts back.
//
// Every number below is hand-computed from the tree, not read off a run. And
// nothing here plays a sound: CI has no audio device, so a test that needed one
// would gate nothing — `attach` puts a handle a caller already has on a bus, and
// that is the seam this whole suite runs through.
use audio_bus;
use graphics;

fn a_mixer() -> audio_bus::Mixer {
  m = audio_bus::mixer();
  _ = audio_bus::add(m, "music", audio_bus::MASTER, 0.5);
  _ = audio_bus::add(m, "sfx", audio_bus::MASTER, 0.8);
  _ = audio_bus::add(m, "steps", "sfx", 0.5);
  m
}

fn test_a_gain_is_the_product_of_the_chain() {
  m = a_mixer();
  assert(audio_bus::gain(m, audio_bus::MASTER) == 1.0, "the master is its own level");
  assert(audio_bus::gain(m, "music") == 0.5, "0.5 under a master of 1.0");
  assert(audio_bus::gain(m, "steps") == 0.4, "0.5 * 0.8 = 0.4, got {audio_bus::gain(m, \"steps\")}");
  _ = audio_bus::set_volume(m, audio_bus::MASTER, 0.5);
  assert(audio_bus::gain(m, "steps") == 0.2, "and the master moves the whole tree");
  assert(audio_bus::volume(m, "steps") == 0.5, "…without touching the bus's OWN level");
}

fn test_a_bus_nobody_declared_is_silence() {
  m = a_mixer();
  assert(audio_bus::gain(m, "voices") == 0.0,
         "a missing bus answers silence — an answer a caller can hear");
  assert(!audio_bus::set_volume(m, "voices", 0.5), "and cannot be turned up");
  assert(!audio_bus::add(m, "steps", "sfx", 1.0), "a name is not handed out twice");
  assert(!audio_bus::add(m, "reverb", "nowhere", 1.0), "nor is a bus hung off nothing");
}

fn test_a_voice_is_its_own_level_times_its_bus() {
  m = a_mixer();
  assert(audio_bus::attach(m, 7, "steps", 0.5), "a handle a caller already has");
  assert(audio_bus::level(m, 7) == 0.2, "0.5 * 0.5 * 0.8 = 0.2, got {audio_bus::level(m, 7)}");
  _ = audio_bus::set_volume(m, "sfx", 0.4);
  assert(audio_bus::level(m, 7) == 0.1, "…and follows a bus two levels up");
  assert(audio_bus::level(m, 99) == 0.0, "a sink nobody attached is not on the mixer");
  assert(!audio_bus::attach(m, 3, "voices", 1.0), "and a bus nobody declared takes none");
}

fn test_a_duck_restores_exactly() {
  // The one thing this package exists to get right.  0.35 is chosen because
  // dividing the gain back out is EXACTLY where the drift would be visible:
  // 0.35 has no exact binary form, so a restore that computes cannot land.
  m = a_mixer();
  _ = audio_bus::set_volume(m, "music", 0.35);
  before = audio_bus::volume(m, "music");
  for _ in 0..1000 {
    assert(audio_bus::duck(m, "music", 0.1), "ducked");
    assert(audio_bus::ducked(m, "music"), "…and says so");
    assert(audio_bus::unduck(m, "music"), "lifted");
  }
  assert(audio_bus::volume(m, "music") == before,
         "a thousand ducks later it is the SAME number: {audio_bus::volume(m, \"music\")} vs {before}");
  assert(!audio_bus::ducked(m, "music"), "and nothing is ducking it");
}

fn test_a_duck_inside_a_duck_comes_home_to_the_original() {
  m = a_mixer();
  _ = audio_bus::set_volume(m, "music", 0.9);
  _ = audio_bus::duck(m, "music", 0.3);
  _ = audio_bus::duck(m, "music", 0.1);      // a second speaker starts
  assert(audio_bus::volume(m, "music") == 0.1, "the deeper duck wins while it lasts");
  assert(audio_bus::unduck(m, "music"), "and one lift is enough");
  assert(audio_bus::volume(m, "music") == 0.9,
         "back to the level before ANY duck, got {audio_bus::volume(m, \"music\")}");
  assert(!audio_bus::unduck(m, "music"), "a second lift finds nothing to lift");
}

fn test_ducking_a_parent_moves_the_children() {
  m = a_mixer();
  _ = audio_bus::attach(m, 11, "steps", 1.0);
  assert(audio_bus::level(m, 11) == 0.4, "0.8 * 0.5");
  _ = audio_bus::duck(m, "sfx", 0.5);
  assert(audio_bus::level(m, 11) == 0.25, "a duck two levels up reaches the voice");
  _ = audio_bus::unduck(m, "sfx");
  assert(audio_bus::level(m, 11) == 0.4, "and lifting it puts the voice back exactly");
}

fn test_a_negative_level_is_silence_not_an_inversion() {
  m = a_mixer();
  _ = audio_bus::set_volume(m, "music", -1.0);
  assert(audio_bus::volume(m, "music") == 0.0, "a negative gain inverts a waveform");
  _ = audio_bus::duck(m, "sfx", -0.5);
  assert(audio_bus::volume(m, "sfx") == 0.0, "…and a duck is no different");
}

fn test_stopping_a_bus_takes_its_children_with_it() {
  m = a_mixer();
  _ = audio_bus::attach(m, 1, "music", 1.0);
  _ = audio_bus::attach(m, 2, "sfx", 1.0);
  _ = audio_bus::attach(m, 3, "steps", 1.0);
  assert(audio_bus::under(m, "steps", "sfx"), "steps hangs under sfx");
  assert(!audio_bus::under(m, "sfx", "steps"), "and not the other way round");
  assert(audio_bus::stop_bus(m, "sfx") == 2, "sfx and steps, not music");
  assert(audio_bus::level(m, 3) == 0.0, "a stopped voice is off the mixer");
  assert(audio_bus::level(m, 1) == 0.5, "and music is untouched");
  assert(audio_bus::stop_all(m) == 1, "one left, and now none");
  assert(audio_bus::stop_all(m) == 0, "stopping nothing is not an error");
}

fn test_detaching_leaves_the_sound_alone() {
  m = a_mixer();
  _ = audio_bus::attach(m, 5, "music", 1.0);
  audio_bus::detach(m, 5);
  assert(audio_bus::level(m, 5) == 0.0, "the mixer has forgotten it");
  assert(audio_bus::stop_bus(m, audio_bus::MASTER) == 0, "so a stop no longer reaches it");
}

fn test_stopping_and_panning_one_voice() {
  m = a_mixer();
  _ = audio_bus::attach(m, 4, "music", 1.0);
  audio_bus::pan(m, 4, -1.0);          // reaches graphics; nothing here to read back
  audio_bus::pan(m, 404, -1.0);        // …and a sink nobody attached is a no-op
  audio_bus::stop(m, 4);
  assert(audio_bus::level(m, 4) == 0.0, "a stopped voice is off the mixer");
  audio_bus::stop(m, 4);               // stopping it twice is not an error
  assert(audio_bus::stop_all(m) == 0, "and it is not counted again");
}

fn test_a_cycle_answers_silence_rather_than_hanging() {
  // A caller can build one, so the answer has to be a number.
  m = audio_bus::mixer();
  _ = audio_bus::add(m, "a", audio_bus::MASTER, 1.0);
  _ = audio_bus::add(m, "b", "a", 1.0);
  m.buses["a"].bs_parent = "b";
  assert(audio_bus::gain(m, "a") == 0.0, "a chain that will not end is silence");
  assert(!audio_bus::under(m, "a", audio_bus::MASTER), "and reaches no ancestor");
}

fn test_playing_without_a_device_attaches_nothing() {
  // `audio_load` needs an output stream, and CI has none — so this is the path
  // every machine that gates this package takes.  The claim is that a sound
  // which never started leaves no voice behind: a mixer whose list fills up with
  // handles that are not playing reports a scene as noisy when it is silent.
  m = a_mixer();
  clip = graphics::audio_load("no-such-file.wav");
  sink = audio_bus::play(m, "music", clip, 1.0);
  assert(sink < 0, "no clip, no sound");
  assert(audio_bus::stop_all(m) == 0, "and no voice on the mixer");
  assert(audio_bus::play(m, "voices", clip, 1.0) < 0, "a bus nobody declared plays nothing");
}