Audio

Audio events & mixing

The authoring surface behind AudioEmitter — clip containers, the mixer bus tree with effects and sends, live parameters, snapshots, ducking, and deterministic playback.

Everything an AudioEmitter 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.

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 (mastereffects, 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.

  • Audio overview — the ECS layer: AudioEmitter, play paths, spatial scaling, and the SoundEmitter migration table
  • Source: engine/sound/sopra/definition/ — every class here is small and documented