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:

ComponentSounds becauseSystem
AudioEmitterthe component linked to an entity (autoplay)AudioEmitterSystem
AudioEventTriggerthe entity received a named entity eventAudioEventTriggerSystem

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.

AudioEmitterAudioEmitterSystemAudioEventTriggerAudioEventTriggerSystemSopraEngineWeb Audio

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:

PathWhenBehaviour
Spatially managedautoplay + is3D + looping root clipRegistered in a spatial index and left dormant - no instance, no audio nodes - until proximity promotes it.
Directany other autoplay eventPlayed 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).
Inertautoplay === falseNeither 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:

  • rules is 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 mirrors AudioEmitter.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.

LegacyNative equivalent
SoundEmitterSystemnone of that name - AudioEmitterSystem
SoundControllerSystemnone of that name - AudioEventTriggerSystem
SoundEmitterChannels, loadSoundTrackAsset, SoundEmitterComponentContextnone
SoundEmitter componentAudioEmitter
SoundController componentAudioEventTrigger
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)
SoundTrack.on.endedemitter.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.

  • Audio events & mixing - the authoring surface: clip containers, buses, parameters, snapshots, ducking, determinism, and converting legacy sound data
  • Entities and Components - entity events, which AudioEventTrigger listens to
  • Assets - how sound files load through the AssetManager
  • Source: engine/sound/ecs/audio/ and engine/sound/ecs/trigger/ (the ECS layer), engine/sound/sopra/ (the renderer), engine/sound/simulation/ (acoustic simulation)