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.

TypeFile under engine/sound/sopra/legacy/Export
SoundEmitterSoundEmitter.jsnamed
SoundTrackSoundTrack.jsnamed
SoundTrackFlagsSoundTrackFlags.jsnamed
SoundEmitterFlagsSoundEmitterFlags.jsnamed
SoundAttenuationFunctionSoundAttenuationFunction.jsnamed
SoundPanningModelTypeSoundPanningModelType.jsnamed
SoundControllerSoundController.jsdefault (the file also has a named SoundControllerSerializationAdapter)
SoundEmitterSerializationAdapter, SoundEmitterSerializationUpgrader_0_1, SoundEmitterSerializationUpgrader_1_2one file each, same namenamed

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 fieldBecomes
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
loopthe clip’s own loop flag
volumegainDb on the clip
channelbusId

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 a BlendContainerAudioClip with 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 drive emitter.volume.
  • A SoundEmitter with no tracks converts to nothing and is counted in discarded. Most of those are the “empty emitter for a SoundController to write into” pattern, which AudioEventTrigger replaces 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).

  • 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