Audio overview
Meep's audio engine is event-driven — authorable sound events, a mixer bus tree, live parameters, and spatial 3D — and you use it through ECS, with one component and one system.
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.
The two-minute version
An entity makes sound by carrying an AudioEmitter. The component owns an
EventDescription — what to play, how to route it, how it behaves in 3D —
and the system drives every emitter through one shared audio renderer.
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 { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
// once, at startup
em.addSystem(new AudioEmitterSystem(engine.assetManager, engine.sound));
ecd.registerComponentType(AudioEmitter);
// per sound source
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 Transform()).add(emitter).build(ecd);
That’s a positional, looping torch. The entity’s Transform feeds the
source’s position every frame; the SoundListener (wired to the camera by
EngineHarness.buildBasics) provides the ears. Move either and the
attenuation and panning follow.
One component, one system
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.
AudioEmitterSystem(assetManager, soundEngine) does the rest: it creates the
shared audio renderer off the engine’s SoundEngine (idempotently — multiple
audio systems can coexist), registers the sound asset loader, forwards the
listener pose, and ticks playback once per frame.
How an emitter plays
The system picks one of three paths per emitter, once, at link time:
| Path | When | Behaviour |
|---|---|---|
| Spatially managed | autoplay + is3D + looping 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; finite one-shots self-release when done. 2D music and ambience beds live here. |
| Inert | autoplay === false | Neither played nor registered; waiting for an explicit trigger. |
The managed path is what lets meep scenes carry on the order of 100,000 registered positional emitters. Each frame the system culls by listener position 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. A demoted emitter fades out click-free; a re-promoted looping source resumes at the phase it would have reached. The per-frame cost tracks the budget, not the registered count.
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.
One-shots from gameplay code
For fire-and-forget positional sounds — impacts, jumps, a goal horn — spawn a short-lived entity with a non-looping autoplay emitter. The direct path plays it on link and the instance self-releases when the sample ends:
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 Transform();
t.position.set(x, y, z);
new Entity().add(t).add(e).build(ecd); // despawn the entity when convenient
}
The jet-propulsion-alliance example
uses exactly this pattern 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.
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 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.
Migrating from SoundEmitter (deprecated)
The legacy SoundEmitter / SoundTrack / SoundEmitterSystem API
(engine/sound/ecs/emitter/) is deprecated. It still works — a
translator drives old components through the new renderer during the
migration window — but new code should use AudioEmitter, and existing code
should move over; the legacy layer is scheduled for removal.
The mapping is direct:
| Legacy (deprecated) | Replacement |
|---|---|
SoundEmitterSystem(assetManager, soundEngine) | AudioEmitterSystem(assetManager, soundEngine) |
SoundEmitter component | AudioEmitter component |
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) |
One behavioural difference worth knowing: per-track live volume had no
equivalent concurrency control; the new system adds per-event polyphony
(maxInstances, voice stealing) and the managed/dormant lifecycle described
above. Sounds that used to silently pile up now obey a budget.
Related
- Audio events & mixing — the authoring surface: clip containers, buses, parameters, snapshots, ducking, determinism
- Assets — how sound files load through the
AssetManager - Source:
engine/sound/ecs/audio/(the ECS layer),engine/sound/sopra/(the renderer)