Audio overview
Meep's audio is event-driven and ECS-native - an AudioEmitter sounds because it linked, an AudioEventTrigger sounds because the entity received an event, and one shared renderer plays both.
Meep ships a full audio engine. Not a thin <audio> wrapper - an event-driven
renderer over Web Audio with spatialization, a mixer bus tree with effects and
sends, live parameters, snapshots, ducking and deterministic playback. If
you’ve worked with industry audio middleware, the concepts - events, buses,
parameters - will feel familiar; the difference is that here they are plain
meep data, living in the same ECS as everything else, serialized by the same
machinery as every other component.
Two components, two systems
There are exactly two ways an entity makes sound, and they differ only in what starts it:
| Component | Sounds because | System |
|---|---|---|
AudioEmitter | the component linked to an entity (autoplay) | AudioEmitterSystem |
AudioEventTrigger | the entity received a named entity event | AudioEventTriggerSystem |
Both carry authored EventDescription data directly, both take their position
from the entity’s Transform64, and both play through the one SopraEngine
owned by the engine’s SoundEngine (engine.sound) - created on demand,
idempotently, by whichever system starts first.
AudioEmitterSystem is the sole owner of the per-frame sopra tick and of the
listener pose. AudioEventTriggerSystem deliberately has no update - a
second ticker would advance the same instances twice a frame - so register it
alongside the emitter system, never instead of it.
import { AudioEmitter } from "@woosh/meep-engine/src/engine/sound/ecs/audio/AudioEmitter.js";
import { AudioEmitterSystem } from "@woosh/meep-engine/src/engine/sound/ecs/audio/AudioEmitterSystem.js";
import { AudioEventTrigger } from "@woosh/meep-engine/src/engine/sound/ecs/trigger/AudioEventTrigger.js";
import { AudioEventTriggerSystem } from "@woosh/meep-engine/src/engine/sound/ecs/trigger/AudioEventTriggerSystem.js";
const em = engine.entityManager;
const ecd = em.dataset;
em.addSystem(new AudioEmitterSystem(engine.assetManager, engine.sound));
em.addSystem(new AudioEventTriggerSystem(engine.assetManager, engine.sound));
ecd.registerComponentType(AudioEmitter);
ecd.registerComponentType(AudioEventTrigger);
Each system registers the Sound asset loader if nothing has yet, and both
guard that, so adding both in either order is safe. The ears come from a
SoundListener: EngineHarness.buildCamera (and buildBasics, which calls
it) adds SoundListenerSystem and puts a SoundListener on the camera entity
for you.
AudioEmitter
An AudioEmitter owns a full EventDescription as first-class ECS data - the
clip graph, the routing, the 3D and voice configuration. There is no global
sound bank to register against and no id indirection: the
EntityComponentDataset is the data store, and event definitions serialize
with your scene like any other component.
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
const emitter = new AudioEmitter();
emitter.event.label = "torch.loop";
emitter.event.is3D = true;
emitter.event.distanceMin = 1;
emitter.event.distanceMax = 20;
emitter.event.rootClip = SampleAudioClip.from("./sounds/torch.ogg", { loop: true });
emitter.volume.set(0.8); // live multiplier, change it any frame
new Entity().add(new Transform64()).add(emitter).build(ecd);
That’s a positional, looping torch. The entity’s Transform64 feeds the source’s
position every frame; move either it or the listener and the attenuation and
panning follow.
volume is the one live handle. Everything else on the emitter is authored
data, read once at link time: autoplay, is3D and the event’s routing
decide the shape of the instance’s audio graph and the polyphony bucket it is
filed under, so editing them on a linked emitter does nothing useful. Re-link
the component (remove it and add it back) to apply a change cleanly.
How an emitter plays
AudioEmitterSystem picks one of three paths per emitter, once, at link time:
| Path | When | Behaviour |
|---|---|---|
| Spatially managed | autoplay + is3D + looping root clip | Registered in a spatial index and left dormant - no instance, no audio nodes - until proximity promotes it. |
| Direct | any other autoplay event | Played immediately on link, stopped on unlink. 2D music and ambience beds live here, as do finite 3D one-shots (which still spatialize, and self-release when the sample ends). |
| Inert | autoplay === false | Neither played nor registered; waiting for an explicit trigger. |
Only looping events are managed: the live set models a persistent source whose phase is reconstructed on re-promotion, which is meaningless for a finite one-shot - and a dead one-shot would otherwise keep re-promoting and occupying a budget slot.
Scaling the managed path
Managed emitters update their spatial-index bounds on TRANSFORM64_EVENT_CHANGE.
After writing a source’s transform, call t64_announce_change(ecd, entity);
the system does not scan every dormant source for movement each frame.
The managed path is what lets a scene carry far more positional emitters than
can ever sound at once - the 100,000-bird case. Each frame the system culls by
listener position through a BVH and promotes only the nearest in-range emitters
up to a global voice budget (default 64); everything else costs one BVH leaf
and nothing more. Stealing is by distance, with a little stickiness so
emitters near the cutoff don’t flicker: an emitter culled out of range is cut
(it was already inaudible past distanceMax), while one that loses its slot to
a closer source fades out click-free. The per-frame cost tracks the budget, not
the registered count.
Tune it through the system’s third constructor argument:
em.addSystem(new AudioEmitterSystem(engine.assetManager, engine.sound, {
budget: 96, // default 64 - max simultaneously live managed emitters
liveStickiness: 0.8, // default 0.8 - rank hysteresis, keeps the live set stable
fadeOutSeconds: 0.15, // default 0.15 - contention fade
}));
The gotcha worth knowing up front: the budget and per-event polyphony are
independent, composed caps. budget bounds the global live count; the event’s
own maxInstances bounds concurrent instances of one content-equal event,
and a crowd of identical ambience emitters all share one bucket. If that
event’s maxInstances is below the number of copies you want audible, sopra
gates the budget rather than the other way around - with stealMode None the
promotion is simply denied and the emitter stays dormant. Give a spatial
ambience event a maxInstances at least as large as the budget.
Within the live set there’s a second tier of thrift: an instance whose
post-attenuation gain falls below its event’s virtualThresholdDb - or whose
distance exceeds distanceMax - goes virtual: its voices stop while the
playhead keeps advancing, and it revives at the correct offset when audible
again.
Knowing when a sound ended
emitter.on.ended is a Signal that fires with the emitter when a direct
instance finishes on its own. It is the component-level end that lets an entity
builder destroy a fire-and-forget sound entity the moment it stops sounding,
without reaching into the system for its instance:
function playAt(url, x, y, z, volume = 1) {
const e = new AudioEmitter();
e.event.label = url;
e.event.is3D = true;
e.event.distanceMin = 6;
e.event.distanceMax = 110;
e.event.rootClip = SampleAudioClip.from(url); // no loop -> finite -> direct path
e.volume.set(volume);
const t = new Transform64();
t.setTranslation(x, y, z);
const builder = new Entity().add(t).add(e);
e.on.ended.add(() => ecd.removeEntity(builder.id));
builder.build(ecd);
}
Three constraints the signal is explicit about:
- It does not fire on unlink. Stop is not the same as ended, and the system detaches its handler before tearing an instance down, so destroying a sound that was still playing never reports an end that did not happen.
- It never fires for a spatially managed emitter. That route carries looping events only, so there is no end to report, and its instances come and go with promotion and demotion.
- A one-shot whose buffers are already decoded can finish inside the link, so
the signal may fire before
build()returns. Subscribe before you link, as above, and never assume a later frame.
on is an observation surface, deliberately excluded from toJSON, equals
and hash: two emitters that differ only in who is listening are the same
emitter.
For a fade rather than a hard end, tween the component’s volume - a live
volume write reaches the instance gain on both play routes, so it works for a
managed and a direct emitter alike. That is what
stopAudioEmitterAndNotifyOnceFinished(entity, ecd)
(engine/animation/AnimatedActions.js) does.
The jet-propulsion-alliance example uses
both shapes: transient entities with non-looping autoplay emitters for its
one-shots, and a set of looping emitters on child EntityNodes - one per
engine/tyre layer, volumes cross-faded by speed every frame - for the car
audio.
AudioEventTrigger
Where an AudioEmitter sounds because it was linked, an AudioEventTrigger
sounds because the entity received an event: “unit took damage” gives a hurt
sound. It holds a list of AudioEventTriggerRules, each pairing an entity
event name with an authored event to play, and optionally a second event name
that stops it again.
import { AudioEventTrigger, AudioEventTriggerRule } from "@woosh/meep-engine/src/engine/sound/ecs/trigger/AudioEventTrigger.js";
import { EventDescription } from "@woosh/meep-engine/src/engine/sound/sopra/definition/EventDescription.js";
import { RandomContainerAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/RandomContainerAudioClip.js";
import { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
const hurt = EventDescription.from("unit.hurt", RandomContainerAudioClip.from([
SampleAudioClip.from("hurt_a.ogg"),
SampleAudioClip.from("hurt_b.ogg"),
]), { busId: "effects", is3D: true, distanceMin: 2, distanceMax: 40 });
const engineLoop = EventDescription.from("unit.engine",
SampleAudioClip.from("engine.ogg", { loop: true }),
{ busId: "effects", is3D: true });
const trigger = AudioEventTrigger.from([
AudioEventTriggerRule.from("damaged", hurt), // fire and forget
AudioEventTriggerRule.from("engaged", engineLoop, "disengaged") // start / stop pair
]);
new Entity().add(new Transform64()).add(trigger).build(ecd);
// anywhere in gameplay code
ecd.sendEvent(entity, "damaged", { amount: 12 });
Rules are evaluated independently - several may listen to the same event, and
each owns the instances it started. A rule whose event loops plays until its
stopEvent arrives or the component unlinks; a finite one self-releases.
Unlinking removes every listener and stops everything still sounding, so a
destroyed entity is silent and leaves nothing subscribed.
Two details to keep in mind:
rulesis read once, when the component links. The system registers one entity-event listener per rule at that moment, so mutating the array on a linked component does nothing until you remove and re-add it. This mirrorsAudioEmitter.event.- There is no component-level handle on what a trigger started - it is
fire-and-forget by design. If you need to reach a sounding instance, ask the
system:
triggerSystem.instancesFor(entity, rule)returns them in start order.
Definitions vs runtime
The engine splits cleanly in two: definitions (the EventDescription, its
clip graph, bus and parameter definitions - immutable, shared, serializable)
and runtime (the transient instances and voices spawned when something
plays - pooled, never serialized). A definition is a recipe; playing it never
mutates it, and one definition can back any number of simultaneous instances.
This is the rule that makes event data safe to share between emitters and
triggers, and safe to ship in save files.
The full authoring surface - clip containers, the bus tree and effects, live parameters, snapshots and ducking, deterministic randomization - is covered in Audio events & mixing.
The legacy sound components
SoundEmitter, SoundTrack, SoundController and their flags, under
engine/sound/sopra/legacy/, are data-only: they deserialize, so saves and
scenes that carry them load, and no system links them.
| Legacy | Native equivalent |
|---|---|
SoundEmitterSystem | none of that name - AudioEmitterSystem |
SoundControllerSystem | none of that name - AudioEventTriggerSystem |
SoundEmitterChannels, loadSoundTrackAsset, SoundEmitterComponentContext | none |
SoundEmitter component | AudioEmitter |
SoundController component | AudioEventTrigger |
SoundTrack with url + Loop flag | SampleAudioClip.from(url, { loop: true }) as the event’s rootClip |
several SoundTracks on one emitter | one emitter per layer (e.g. on child EntityNodes), or a container clip - see the clip graph |
SoundEmitterFlags.Spatialization / .Attenuation | event.is3D = true (+ distanceMin / distanceMax / attenuation) |
emitter.channel = "effects" | event.busId = "effects" |
per-track .volume writes | emitter.volume.set(v) (live, per-emitter) |
SoundTrack.on.ended | emitter.on.ended (the legacy SoundTrack carries no signal) |
Legacy data does not convert itself. Deserialization gives you legacy
components; turning them into AudioEmitters and AudioEventTriggers is a
call your load pipeline makes, with
convertLegacySoundComponents.
Two things a converted scene meets that legacy playback has no notion of:
per-event polyphony (maxInstances, voice stealing) and the managed/dormant
lifecycle above, so sounds that would pile up obey a budget. And
AudioEmitter.fromJSON defaults an absent sourceRadius to 0.05, the
field’s own default - write sourceRadius explicitly on any emitter you opt
into the acoustic simulation (emitter.acoustic = true), because the radius
sets how gradually a source is occluded.
Related
- Audio events & mixing - the authoring surface: clip containers, buses, parameters, snapshots, ducking, determinism, and converting legacy sound data
- Entities and Components - entity events, which
AudioEventTriggerlistens to - Assets - how sound files load through the
AssetManager - Source:
engine/sound/ecs/audio/andengine/sound/ecs/trigger/(the ECS layer),engine/sound/sopra/(the renderer),engine/sound/simulation/(acoustic simulation)