Audio

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:

PathWhenBehaviour
Spatially managedautoplay + is3D + looping clipRegistered in a spatial index and left dormant — no instance, no audio nodes — until proximity promotes it.
Directany other autoplay eventPlayed immediately on link; finite one-shots self-release when done. 2D music and ambience beds live here.
Inertautoplay === falseNeither 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 componentAudioEmitter component
SoundTrack with url + Loop flagSampleAudioClip.from(url, { loop: true }) as the event’s rootClip
several SoundTracks on one emitterone emitter per layer (e.g. on child EntityNodes), or a container clip — see the clip graph
SoundEmitterFlags.Spatialization / .Attenuationevent.is3D = true (+ distanceMin / distanceMax / attenuation)
emitter.channel = "effects"event.busId = "effects"
per-track .volume writesemitter.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.

  • 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)