Audio events & mixing
The authoring surface behind AudioEmitter - clip containers, the mixer bus tree with effects and sends, live parameters, snapshots, ducking, deterministic playback, and converting legacy sound data.
Everything an AudioEmitter or AudioEventTrigger
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 and rules.
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.
populateEngineSerializationRegistry calls
populateSopraSerializationRegistry first, before the component adapters,
precisely because an AudioEmitter cannot be written at all until the object
adapter can name the types in its clip graph. If you build a registry by hand,
keep that order.
Legacy sound components
SoundEmitter, SoundTrack, SoundController and their flag enums live under
engine/sound/sopra/legacy/ and are data only: the classes exist so saves
and scenes that carry them still deserialize, and nothing plays them.
populateEngineSerializationRegistry registers their adapters and both
SoundEmitter upgraders, so the bytes read; no system links the result. There
is no SoundEmitterSystem and no SoundControllerSystem.
| Type | File under engine/sound/sopra/legacy/ | Export |
|---|---|---|
SoundEmitter | SoundEmitter.js | named |
SoundTrack | SoundTrack.js | named |
SoundTrackFlags | SoundTrackFlags.js | named |
SoundEmitterFlags | SoundEmitterFlags.js | named |
SoundAttenuationFunction | SoundAttenuationFunction.js | named |
SoundPanningModelType | SoundPanningModelType.js | named |
SoundController | SoundController.js | default (the file also has a named SoundControllerSerializationAdapter) |
SoundEmitterSerializationAdapter, SoundEmitterSerializationUpgrader_0_1, SoundEmitterSerializationUpgrader_1_2 | one file each, same name | named |
Converting a loaded dataset
convertLegacySoundComponents turns what a load produced into native
components: SoundEmitter into AudioEmitter, SoundController into
AudioEventTrigger. The host calls it, after deserialization. It is
deliberately not run inside the deserializer: a host owns its load pipeline,
and populateEngineSerializationRegistry promises only to know how to read the
bytes.
import { convertLegacySoundComponents } from "@woosh/meep-engine/src/engine/sound/sopra/legacy/convertLegacySoundComponents.js";
import { SopraDefaultBus } from "@woosh/meep-engine/src/engine/sound/sopra/SopraEngine.js";
// after deserialization; mutates the dataset in place
const { emitters, triggers, discarded } = convertLegacySoundComponents(dataset, {
knownBusIds: Object.values(SopraDefaultBus)
});
Pass knownBusIds. A legacy channel is a free string, and sopra’s bus lookup
throws on an id the mixer does not have, so a stale channel name would fail
the emitter’s first play. Given the set, an unrecognised channel is logged and
rewritten to effects. Omit it only when you already know
every channel name is good; on a custom mixer, pass your own bus ids. The
default bus ids (master, effects, music, ambient) are deliberately the
legacy channel names, so most converted data resolves unchanged.
The return value counts what the pass did: emitters and triggers created,
and discarded legacy components removed with no replacement because they had
nothing to play.
Controllers are converted first, on purpose: a rule’s sound plays through the
entity’s SoundEmitter, so the conversion has to read that emitter’s
spatialization before the emitter itself is taken away. A controller on an
entity with no emitter reads as 2D and unattenuated.
How the legacy fields collapse
A legacy SoundController.Rule carries tracks[] / loop / volume / channel.
All four collapse into the EventDescription:
| Legacy field | Becomes |
|---|---|
tracks (several urls, one picked at random per trigger) | a RandomContainerAudioClip over one SampleAudioClip per url - the pick as authored data rather than a decision the runtime remakes. A single url stays a bare SampleAudioClip |
loop | the clip’s own loop flag |
volume | gainDb on the clip |
channel | busId |
An emitter’s routing translates through soundEmitterToEventDescription, a
pure field mapping with no ECS or engine state in it. The legacy component
carried two independent spatial flags and both are honoured: is3D is
Spatialization || Attenuation, so an ambience bed that faded with distance
but never panned around the listener’s head keeps
SopraPanningModel.None. SoundAttenuationFunction (Linear, Logarithmic,
Smith) becomes a sampled distance/gain attenuation curve between
distanceMin and distanceMax, and the event gets maxInstances: 256, so a
converted event is never voice-limited.
Converted emitters come out with autoplay = true, and each track’s own volume
is baked into its clip gain - there is no live track gain left to compose with,
only emitter.volume.
A few shapes to review by hand after a conversion:
- Several
SoundTracks on one emitter played simultaneously, so they become aBlendContainerAudioClipwith no blend curves (a child without one is always fully on) and the pass logs a warning. If those layers were meant to cross-fade live, give each its own emitter instead and driveemitter.volume. - A
SoundEmitterwith no tracks converts to nothing and is counted indiscarded. Most of those are the “empty emitter for aSoundControllerto write into” pattern, whichAudioEventTriggerreplaces outright. - A rule with no
startEvent, or naming no url, is dropped with a warning; a controller left with no usable rule is discarded.
Two fields are dropped on purpose: track.time (a restored track always starts
from zero) and panningModel (the legacy binary adapter never writes it, so a
binary-sourced emitter carries the HRTF default a conversion produces).
Related
- Audio overview - the ECS layer:
AudioEmitter,AudioEventTrigger, play paths and spatial scaling - Scenes - where the deserialization step this conversion follows happens
- Source:
engine/sound/sopra/definition/- every class here is small and documented