Concept

Mixer

The named bus graph that routes every voice from source to speakers.

Every project ships a volume slider. Most ship three. The interesting part is never the slider; it is having somewhere for it to point that survives the twentieth sound you add.

The mixer is the declarative routing model. It is a set of named buses, each with a level, optional FX inserts, an optional voice cap, and an optional sidechain key. You name them at construction and the engine builds the audio graph. Bus outputs sum at the master, which applies headroom and sends to the destination.

Why declare buses up front?

Because the alternative is deciding routing at every call site, and call sites multiply. With named buses, "duck the music while dialogue plays" is one rule written once, and it applies to dialogue lines that don't exist yet.

bus graph
SOURCES music.mp3 crowd.webm coin.webm click.webm voice.m4a BUSES music level 0.8 · concurrency 4 fx: reverb sfx level 1.0 · concurrency 32 steal: oldest voice level 1.0 · ducks music fx: ducker(from: voice) MASTER Master headroom -3 dB 🔊 dest sidechain (voice → music)
Three buses, FX inserts, sidechain key (voice → music). Master sums and applies headroom.

API surface

Declare buses up front ts
const engine = createEngine({
  buses: {
    music: { level: 0.8, concurrency: { max: 4 } },
    sfx:   { level: 1.0, concurrency: { max: 32, steal: 'oldest' } },
    voice: { level: 1.0, sidechain: { from: 'music', amount: 0.5, attack: 0.08, release: 0.4 } },
  },
  master: {
    headroom: -3,
    // Fast-attack soft limiter on master out — best-effort peak control for
    // when FX stack on busy mixes. Disable by omitting the field.
    limiter: { threshold: -1, ratio: 20, attack: 0.001, release: 0.05 },
  },
});
Talk to a bus by name ts
engine.bus('music').level = 0.5;
engine.bus('sfx').fadeTo(0, 0.8);
engine.bus('voice').muted = true;

Live demo

Three buses, six samples, a live voice counter. Sliders and mute drive the running engine.

engine.state = cold
0 voices

Recipes

Insert an FX chain on a bus

ts ts
import { Compressor, Reverb } from '@schmooky/zvuk';

const comp = new Compressor(engine.context, { threshold: -18, ratio: 4 });
const reverb = new Reverb(engine.context, { wet: 0.3, decay: { seconds: 1.4 } });

engine.bus('music').addFx(reverb);
engine.bus('sfx').addFx(comp);   // FX run between bus.input and bus.output

Master limiter

Headroom is a static gain offset. The optional limiter is a fast-attack DynamicsCompressor at ratio 20 with a 1 ms attack, used as a soft limiter on the master output. It catches most of the transients headroom alone can't tame. It is not a brick wall: with a finite attack and no lookahead, peaks above the threshold still get through, so don't treat it as a guaranteed 0 dBFS ceiling.

ts ts
// The master limiter is configured at construction (see above). There is
// no public runtime accessor yet — to change or disable it, recreate the
// engine with a different master.limiter (or omit the field).
createEngine({ master: { limiter: { threshold: -0.5, ratio: 20 } } });

Pitfalls

Don't add buses dynamically after createEngine.
Bus topology is declared once. If you need a temporary effect, route into an existing bus and bypass when not in use, or add a fresh FX insert.
Don't write directly to ctx.destination.
Going around the master skips headroom and breaks snapshots. Always route through a declared bus.

Related

  • Bus covers a single bus and its FX chain.
  • Snapshot captures and crossfades the whole mix.
  • Building your mix is the practical version of this page.