Audio events & mixing
The authoring surface behind AudioEmitter — clip containers, the mixer bus tree with effects and sends, live parameters, snapshots, ducking, and deterministic playback.
Everything an AudioEmitter plays is described by an
EventDescription: a root clip (what sounds), routing (which bus), 3D
settings, and voice limits. Descriptions are plain, immutable, serializable
data — author them in code, hold them in module constants or components,
share one across any number of emitters.
import { EventDescription } from "@woosh/meep-engine/src/engine/sound/sopra/definition/EventDescription.js";
import { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
const roar = EventDescription.from("monster.roar", SampleAudioClip.from("roar.ogg"), {
busId: "effects",
is3D: true,
distanceMin: 2,
distanceMax: 60,
maxInstances: 8, // concurrency cap for this event
});
The clip graph
rootClip is a tree. Leaves make sound; containers arrange, select, or layer
their children. Gains (dB) and pitch (cents) accumulate down the tree and are
resolved once at trigger time.
SampleAudioClip — one audio asset, the only buffer-referencing leaf.
Supports looping with a loop region, and per-trigger gain/pitch
randomization:
SampleAudioClip.from("explosion.ogg", { gain: -3, pitchRandom: 50, gainRandom: 2 });
SilenceAudioClip.from(0.5) — half a second of dead air, e.g. between
sequence steps.
SequenceContainerAudioClip — children in order. A looping child is
terminal: the sequence can’t advance past it, which is exactly how you author
“intro, then loop forever”.
RandomContainerAudioClip — one child per trigger, weighted, with
avoid-repeat history:
RandomContainerAudioClip.from([step1, step2, step3], {
avoidRepeatingLast: 1,
weights: [3, 1, 1],
});
SwitchContainerAudioClip — one child chosen by a parameter (discrete).
The classic footstep-surface switch:
SwitchContainerAudioClip.from([grass, stone, wood], { parameter: "surface" });
BlendContainerAudioClip — several children at once, each scaled by a
per-child curve over a parameter (continuous) — layered ambience cross-faded
by an “intensity” value. Note: the blend is sampled at trigger time; it
does not re-blend live as the parameter sweeps afterwards (live re-blend is a
planned follow-up). For mixes that must move every frame — engine loops
cross-faded by speed, say — use one emitter per layer and drive
emitter.volume, the way the
jet-propulsion-alliance example does.
Buses, effects, and sends
The mixer is a tree of BusDefinitions — each bus is an effect chain and a
gain, routing into its parent. The default tree (master → effects,
music, ambient) works out of the box; replace it when you need more:
import { BusDefinition } from "@woosh/meep-engine/src/engine/sound/sopra/definition/BusDefinition.js";
import { EqEffect, EqFilterType } from "@woosh/meep-engine/src/engine/sound/sopra/definition/effect/EqEffect.js";
import { CompressorEffect } from "@woosh/meep-engine/src/engine/sound/sopra/definition/effect/CompressorEffect.js";
import { ReverbEffect } from "@woosh/meep-engine/src/engine/sound/sopra/definition/effect/ReverbEffect.js";
sopra.setBuses([
BusDefinition.from("master", { gainDb: 0 }),
BusDefinition.from("music", { parentId: "master", gainDb: -6,
effects: [CompressorEffect.from({ threshold: -18, ratio: 4 })] }),
BusDefinition.from("sfx", { parentId: "master",
sends: [{ targetBusId: "reverb", levelDb: -9 }] }), // post-fader aux send
BusDefinition.from("reverb", { parentId: "master",
effects: [ReverbEffect.from({ decaySeconds: 2.0 })] }),
]);
A send routes a post-fader copy of one bus into another — the standard way to
share a single reverb across many sources. ReverbEffect generates its
impulse response procedurally, so there is no IR asset to load. Available
inserts: EqEffect (biquad), CompressorEffect, ReverbEffect.
The renderer instance is owned by the engine’s SoundEngine; systems and
tooling reach it as engine.sound.sopra once an audio system has created it.
Parameters
Named floats that drive Switch/Blend selection and bus automation —
real-time parameter control, if you’re used to the middleware term:
sopra.defineParameter("surface", 0);
sopra.setParameter("surface", 1); // next footstep trigger picks child 1
// live bus automation through a curve
sopra.bindParameterToBusVolume("tension", "music", AnimationCurve.linear(0, 0.2, 1, 1));
sopra.setParameter("tension", 0.8); // music volume follows immediately
Timing rule: bus-volume bindings update live; clip selection is sampled at trigger time, so a parameter change affects the next play, not voices already sounding.
Snapshots and ducking
A MixerSnapshot is a named set of per-bus target gains — shift the whole
mix between game states with a click-safe ramp:
sopra.applySnapshot(combatMix, { duration: 1.5 });
const restore = sopra.captureSnapshot("pre-combat", ["music", "ambient"]);
Ducking attenuates a target bus while anything plays on a trigger bus — dialogue over music, impacts over ambience:
sopra.addDucker(DuckingRule.from({
triggerBusId: "effects", targetBusId: "music",
duckDb: -8, attack: 0.1, release: 0.4,
}));
Determinism
Random selection and per-trigger randomization run on a seeded RNG. Seed from a replicated source — entity id, fixed-step tick — and every networked client resolves the same picks, which keeps audio consistent under lockstep netcode:
sopra.playEvent(footsteps, { seed: entityId * 31 + tick });
Serialization
Every definition round-trips through both of meep’s serialization paths:
toJSON()/fromJSON() with type-tagged dispatch for the polymorphic clip
and effect trees, and binary adapters registered via
populateSopraSerializationRegistry. Event data ships in
scenes and save files like any other component data —
see the engine/sound/sopra/serialization/ source.
Related
- Audio overview — the ECS layer:
AudioEmitter, play paths, spatial scaling, and the SoundEmitter migration table - Source:
engine/sound/sopra/definition/— every class here is small and documented