Module: Audio Engine
Package path:
packages/babylon-lite/src/audio/
Status: Implemented. This is a faithful behavioral port of the Babylon.js AudioV2 engine (
packages/dev/core/src/AudioV2/) re-architected to Babylon Lite idioms: pure-state interfaces + standalone functions, zero module-level side effects, single Web Audio backend (no abstract/concrete split), and opt-in feature modules. The Web Audio API node graph, parameter-ramp math, and spatial panner math are preserved 1:1 — only the code shape changes, never the audible behavior.The shipped module differs from the original one-shot design below in a few places (most notably: feature sub-nodes are enabled by explicit
enable*/set*functions rather than presence of an option field, and the file layout is flat, not nested). The authoritative API is the exported TSDoc inpackages/babylon-lite/src/audio/and the barrelaudio/index.ts; the "As-Built API & Divergences" section below records the deltas from this design doc.
Purpose
The Audio module wraps the browser Web Audio API to provide sound playback,
mixing/routing (buses), 3D spatial audio, stereo panning, FFT analysis,
microphone capture, and an optional unmute UI. It is fully decoupled from the
WebGPU render stack: it has no GPU, scene-graph, or render-loop dependency.
Spatial audio optionally reads a Lite Mesh/transform's world position so a
sound can follow an object, but audio never holds a reference to the scene
(Pillar 4b — one-way data ownership).
The module is 100% opt-in and tree-shakable. A scene that imports nothing
from audio/ pays zero bytes. Spatial, streaming, stereo, analyzer, microphone,
and unmute-UI are each separable feature modules pulled in only when their
factory/option is used.
Design Mapping: AudioV2 → Lite
| AudioV2 (Babylon.js) | Babylon Lite |
|---|---|
abstract AudioEngineV2 + _WebAudioEngine | one AudioEngine state interface + functions |
abstract AbstractSound + _WebAudioStaticSound | one StaticSound state interface + functions |
29 abstract classes + 21 _WebAudio* concretes | flat set of pure-state interfaces; no inheritance |
Class methods (sound.play()) | standalone functions (playSound(sound)) |
new Observable() event fields | lightweight callback-set (AudioSignal<T>) on the state |
Module-level Instances[], OnCreatedObservable | removed — caller owns the AudioEngine handle |
Module-level new RegExp, cached ExpCurve | lazy-init inside functions (no module side effects) |
WebRequest.FetchAsync + custom headers | plain fetch() (matches every other Lite loader) |
Vector3/Quaternion from core Maths | Lite math/ vec3/quat |
Logger.Warn | console.warn |
EngineStore.LastCreatedEngine (unmute UI) | explicit parentElement option |
PrecisionDate.Now | performance.now() |
Node / AbstractMesh (spatial attach) | Lite Mesh / { worldMatrix } transform |
Why collapse the abstract layer
AudioV2's abstract/concrete split exists so Babylon can swap audio backends. Babylon Lite targets one backend (Web Audio) that will ever exist, so the indirection is pure overhead and conflicts with Pillars 2 and 4b′. We keep a single implementation. The Web Audio graph construction is copied faithfully; the class scaffolding is not.
Public API Surface
Original design sketch. The block below is the pre-implementation API design. It is kept for context; where it disagrees with the shipped code, the shipped code wins. See "As-Built API & Divergences" immediately after for the corrections.
All public types are pure state (no methods). Behavior is in functions.
// ─── Engine ──────────────────────────────────────────────────────────export interface AudioEngineOptions { /** Provide an existing context. Pass an `OfflineAudioContext` for deterministic, * headless, faster-than-real-time rendering to PCM (Tier-2/3 tests, parity). * Default: new AudioContext(). */ audioContext?: BaseAudioContext; /** Ramp smoothing for all parameter changes, in seconds. Default 0.01 (10 ms). */ parameterRampDuration?: number; /** Initial master output volume. Default 1. */ volume?: number; /** Resume the context on the first user gesture (click). Default true. */ resumeOnInteraction?: boolean; /** Periodically retry resume() while suspended/interrupted. Default true. */ resumeOnPause?: boolean; /** Retry period for resumeOnPause, in ms. Default 1000. */ resumeOnPauseRetryInterval?: number; /** Spatial listener defaults (position/orientation). */ listener?: Partial<SpatialListenerOptions>;}
export type AudioEngineState = "closed" | "interrupted" | "running" | "suspended";
/** Handle to the audio engine. Pure state. GPU-style internals are @internal. */export interface AudioEngine { readonly state: AudioEngineState; /** Fires on every state transition. */ readonly onStateChanged: AudioSignal<AudioEngineState>; /** @internal */ readonly _ctx: BaseAudioContext; /** @internal */ readonly _mainOut: GainNode; /** @internal */ readonly _mainBus: MainBus; /** @internal */ readonly _listener: SpatialListener | null; /** @internal */ _rampDuration: number; /** @internal */ readonly _disposers: Array<() => void>;}
export function createAudioEngineAsync(options?: AudioEngineOptions): Promise<AudioEngine>;export function disposeAudioEngine(engine: AudioEngine): void;/** Resume a suspended/interrupted context (e.g. from a user-gesture handler). */export function unlockAudioEngineAsync(engine: AudioEngine): Promise<void>;export function setMasterVolume(engine: AudioEngine, value: number, options?: RampOptions): void;
// ─── Sounds (static) ─────────────────────────────────────────────────export type SoundSource = string | string[] | ArrayBuffer | AudioBuffer | SoundBuffer;
export interface StaticSoundOptions { autoplay?: boolean; // default false loop?: boolean; // default false startOffset?: number; // seconds, default 0 maxInstances?: number; // default Infinity volume?: number; // default 1 outBus?: AudioBus | MainBus; // default engine main bus // feature opt-ins (presence triggers the sub-node): spatial?: Partial<SpatialSoundOptions>; stereo?: { pan: number }; analyzer?: { fftSize: number };}
export interface StaticSound { readonly name: string; readonly state: SoundState; loop: boolean; startOffset: number; maxInstances: number; readonly onEnded: AudioSignal<StaticSound>; /** @internal */ readonly _engine: AudioEngine; /** @internal */ readonly _buffer: AudioBuffer; /** @internal */ readonly _graph: SoundSubGraph; /** @internal */ readonly _instances: Set<StaticSoundInstance>; /** @internal */ readonly _options: StaticSoundOptions;}
export function createSoundAsync(engine: AudioEngine, source: SoundSource, options?: StaticSoundOptions): Promise<StaticSound>;export function createSoundBufferAsync(engine: AudioEngine, source: SoundSource): Promise<SoundBuffer>;export function playSound(sound: StaticSound, options?: PlaySoundOptions): void;export function pauseSound(sound: StaticSound): void;export function resumeSound(sound: StaticSound, options?: PlaySoundOptions): void;export function stopSound(sound: StaticSound): void;export function disposeSound(sound: StaticSound): void;
// ─── Streaming sounds (HTMLAudioElement path) ───────────────────────export interface StreamingSoundOptions extends Omit<StaticSoundOptions, "maxInstances"> { preloadCount?: number; // default 1}export interface StreamingSound { /* mirrors StaticSound, no loopStart/pitch/playbackRate */}export function createStreamingSoundAsync(engine: AudioEngine, source: string | string[] | HTMLAudioElement, options?: StreamingSoundOptions): Promise<StreamingSound>;export function preloadStreamingInstanceAsync(sound: StreamingSound): Promise<void>;// play/pause/resume/stop/dispose share the StaticSound functions via a common SoundLike union.
// ─── Buses (routing / mixing) ───────────────────────────────────────export interface AudioBusOptions { volume?: number; outBus?: AudioBus | MainBus; spatial?: Partial<SpatialSoundOptions>; stereo?: { pan: number };}export interface AudioBus { readonly name: string; /** @internal */ _graph: SoundSubGraph; /** @internal */ _in: GainNode;}export interface MainBus { readonly name: string; /** @internal */ _gain: GainNode;}export function createAudioBusAsync(engine: AudioEngine, name: string, options?: AudioBusOptions): Promise<AudioBus>;export function createMainBusAsync(engine: AudioEngine, name: string, options?: { volume?: number }): Promise<MainBus>;export function setBusVolume(bus: AudioBus | MainBus, value: number, options?: RampOptions): void;
// ─── Spatial (3D) ───────────────────────────────────────────────────export interface SpatialSoundOptions { position: Vec3; orientation: Vec3; rotationQuaternion?: Quat; panningEnabled: boolean; // default true panningModel: "equalpower" | "HRTF"; // default "equalpower" distanceModel: "linear" | "inverse" | "exponential"; // default "inverse" minDistance: number; maxDistance: number; rolloffFactor: number; coneInnerAngle: number; coneOuterAngle: number; coneOuterVolume: number; /** Follow a Lite mesh/transform's world position (and optionally rotation). */ attachedTo?: SpatialTarget; attachmentType?: "position" | "rotation" | "positionAndRotation"; useBoundingBox?: boolean;}export interface SpatialListenerOptions { position: Vec3; orientation: Vec3; rotationQuaternion?: Quat; attachedTo?: SpatialTarget;}/** Anything exposing a world transform — typically a Lite Mesh or camera. */export interface SpatialTarget { readonly worldMatrix: Mat4; readonly onDispose?: AudioSignal<unknown>;}export function setSpatialPosition(sound: StaticSound | AudioBus, p: Vec3): void;export function attachSpatialTarget(soundOrListener: StaticSound | AudioBus | AudioEngine, target: SpatialTarget, type?: SpatialSoundOptions["attachmentType"]): void;export function detachSpatialTarget(soundOrListener: StaticSound | AudioBus | AudioEngine): void;/** Pump per-frame spatial updates. Call from your render loop OR enable auto-RAF. */export function updateSpatialAudio(engine: AudioEngine): void;export function setSpatialAutoUpdate(engine: AudioEngine, enabled: boolean, minUpdateMs?: number): void;
// ─── Analyzer ───────────────────────────────────────────────────────export interface AudioAnalyzer { /** @internal */ _node: AnalyserNode;}export function getByteFrequencyData(sound: StaticSound | AudioBus, out: Uint8Array): void;export function getFloatFrequencyData(sound: StaticSound | AudioBus, out: Float32Array): void;
// ─── Microphone source ──────────────────────────────────────────────export interface MicrophoneSound { readonly name: string; /** @internal */ _stream: MediaStream;}export function createMicrophoneSoundSourceAsync(engine: AudioEngine, name: string, options?: { outBus?: AudioBus | MainBus }): Promise<MicrophoneSound>;
// ─── Unmute UI ──────────────────────────────────────────────────────export interface UnmuteUiOptions { parentElement?: HTMLElement;}export function createUnmuteUi(engine: AudioEngine, options?: UnmuteUiOptions): { dispose(): void };
// ─── Parameter ramps ────────────────────────────────────────────────export type AudioRampShape = "none" | "linear" | "exponential" | "logarithmic";export interface RampOptions { shape?: AudioRampShape; duration?: number;}
// ─── Shared signal (replaces core Observable) ───────────────────────export interface AudioSignal<T> { add(cb: (v: T) => void): () => void; addOnce(cb: (v: T) => void): () => void;}
// ─── Enums (re-exported, identical semantics) ───────────────────────export const enum SoundState { Stopped, Starting, Started, Stopping, Paused, FailedToStart,}As-Built API & Divergences
The shipped surface is the barrel audio/index.ts. It matches the design above
in spirit; the concrete differences are:
Feature enablement is explicit, not option-presence. There are no
spatial?/stereo?/analyzer? fields on StaticSoundOptions,
StreamingSoundOptions, or AudioBusOptions. Instead each feature is turned on
by its own function on a host (AudioGraphHost = StaticSound | StreamingSound | AudioBus | AudioInputSource), which lazily builds/rebuilds the sub-graph:
// Stereoexport function enableStereo(host: AudioGraphHost, options?: StereoSoundOptions): void;export function setStereoPan(host: AudioGraphHost, pan: number, options?: RampOptions): void;
// Spatial (3D)export function enableSpatial(host: AudioGraphHost, options?: SpatialSoundOptions): void;export function setSpatialPosition(host: AudioGraphHost, position: Vec3): void;export function setSpatialOrientation(host: AudioGraphHost, orientation: Vec3): void;export function attachSpatialTarget(target: AudioGraphHost | AudioEngine, worldTarget: SpatialTarget, type?: SpatialAttachmentType): void;export function detachSpatialTarget(target: AudioGraphHost | AudioEngine): void;export function setSpatialListener(engine: AudioEngine, options?: SpatialListenerOptions): void;export function setSpatialListenerPosition(engine: AudioEngine, position: Vec3): void;export function updateSpatialAudio(engine: AudioEngine): void;export function setSpatialAutoUpdate(engine: AudioEngine, enabled: boolean, minUpdateMs?: number): void;
// Analyzer — frequency AND time-domain readbackexport function enableAnalyzer(host: AudioGraphHost, options?: AudioAnalyzerOptions): void;export function getByteFrequencyData(host: AudioGraphHost, out: Uint8Array): void;export function getFloatFrequencyData(host: AudioGraphHost, out: Float32Array): void;export function getByteTimeDomainData(host: AudioGraphHost, out: Uint8Array): void;export function getFloatTimeDomainData(host: AudioGraphHost, out: Float32Array): void;Other deltas:
-
Per-sound volume helper.
setSoundVolume(sound, value, options?)andsetStreamingSoundVolume(...)exist (the design only listed master/bus volume). -
Buses.
MainBusis created and owned by the engine; there is no publiccreateMainBusAsync.disposeAudioBus(bus)is public.PrimaryAudioBusis the public union (AudioBus | MainBus) used byoutBus/spatial/stereo hosts. -
Sound sources & microphone. Generic input sources are exposed via
createSoundSourceAsync(engine, node, options?),createMicrophoneSoundSourceAsync(engine, options?),setSoundSourceVolume(...), anddisposeSoundSource(...), returning anAudioInputSourcehandle (the design'sMicrophoneSoundwas folded into this). -
Unmute UI.
createUnmuteUI(engine, { parentElement? })plussetUnmuteUIEnabled(ui, enabled)anddisposeUnmuteUI(ui)(capitalizedUI, and a richer handle than{ dispose() }). -
Media-stream output (Lite-only). The final post-master mix can be mirrored into browser media pipelines without affecting speaker output:
export interface AudioEngineMediaStream {readonly stream: MediaStream;/** @internal */ readonly _engine: AudioEngine;/** @internal */ readonly _source: GainNode;/** @internal */ readonly _destination: MediaStreamAudioDestinationNode;/** @internal */ readonly _engineDisposer: () => void;/** @internal */ _registered: boolean;/** @internal */ _disposed: boolean;/** @internal */ _dispose(): void;}export function createAudioEngineMediaStream(engine: AudioEngine): AudioEngineMediaStream;export function disposeAudioEngineMediaStream(output: AudioEngineMediaStream): void;Creation requires a real-time
AudioContext, connects aMediaStreamAudioDestinationNodein parallel with the audible destination, and registers idempotent engine-owned cleanup. Disposal disconnects only this tap, stops every track in its stream, and unregisters the engine cleanup closure so repeated recording sessions do not accumulate retained handles. -
Visualizer (Lite-only, no AudioV2 counterpart). A small canvas-2D waveform/bars helper for the demo and manual use:
createAudioVisualizer(host, canvas, options?),renderAudioVisualizerFrame(viz),startAudioVisualizer(viz),stopAudioVisualizer(viz),disposeAudioVisualizer(viz). Documented as an intentional adaptation (the runtime visualizer is presentation glue, not a port of audible behavior). -
Spatial defaults match AudioV2
_SpatialAudioDefaults— note thedistanceModeldefault is"linear"(the prose in the design sketch said "inverse"). -
Streaming exposes both
preloadStreamingInstanceAsyncandpreloadStreamingInstancesAsync, and dedicatedplay/pause/resume/stop/disposeStreamingSoundfunctions (not shared with the static functions). -
createSoundBufferAsync(engine, source, options?)and theSoundBuffer/SoundSource/SoundBufferOptionstypes are public.
Everything else (engine lifecycle, ramps, AudioSignal, SoundState, pure-state
interfaces, one-way spatial data ownership) is as designed.
Sound sub-graph (signal flow)
Faithful reproduction of _WebAudioBusAndSoundSubGraph. Each sound/bus owns a
SoundSubGraph — a lazily-built chain of Web Audio nodes. Nodes exist only when
their feature is requested:
source/instance ─▶ [root Gain (only if spatial+stereo both present)] ├─▶ Spatial (PannerNode [+ attenuation GainNode]) └─▶ Stereo (StereoPannerNode) ▼ Volume (GainNode) ← always present ▼ [Analyzer (AnalyserNode)] ← only if fftSize given ▼ out → outBus.in → … → mainBus → mainOut → ctx.destinationSoundSubGraph is plain state:
interface SoundSubGraph { readonly _ctx: BaseAudioContext; _volume: GainNode; // always _stereo: StereoPannerNode | null; _spatial: SpatialSubNode | null; _analyzer: AnalyserNode | null; _root: GainNode | null; // only when spatial+stereo coexist _in: AudioNode; // current head of chain (where instances connect) _out: AudioNode; // tail (volume or analyzer)}rebuildSubGraph(graph) re-wires connections when a feature node is added or
removed (mirrors _onSubNodesChanged). Adding a feature is the only way to
grow the graph — there is no hardcoded if (spatial) in core sound code; each
feature module exposes an ensureXSubNode(graph, opts) that the option parser
calls (Pillar 4c′ extension pattern, applied to audio sub-nodes).
Parameter ramp component
audio-param.ts — pure, side-effect-free. applyRamp(param, ctx, value, shape, duration, rampDuration):
"none":param.value = value."linear":param.setValueCurveAtTime([from, to], ctx.currentTime, duration)."exponential"/"logarithmic": build a 100-point curve viagetRampCurve(shape, from, to)andparam.setValueCurveAtTime(curve, …). BelowMIN_RAMP_DURATION = 1e-6s, fall back tosetValueAtTime.- Always
param.cancelScheduledValues(0)before scheduling.
The exp/log normalized curves are cached via lazy-init (let expCurve: Float32Array | null = null; function getExpCurve() {…}) — never at module scope. Math copied verbatim from audioUtils.ts (Math.exp(-11.512925464970227 * (1 - x)), 1 + Math.log10(x)/Math.log10(100)).
Instances
A StaticSound spawns a StaticSoundInstance per playSound call, each wrapping
a fresh AudioBufferSourceNode (start(when, offset) / stop(when)), respecting
loop, loopStart/loopEnd, startOffset, pitch (cents → detune), and
playbackRate. maxInstances trims oldest started instances. Pause stores the
elapsed time and stops the source; resume creates a new source at the stored
offset. Streaming instances wrap an HTMLAudioElement +
MediaElementAudioSourceNode and support preloadCount look-ahead instances.
State Machine / Lifecycle
Engine
createAudioEngineAsync → new AudioContext (or use provided) → build mainOut GainNode → ctx.destination → build default MainBus → mainOut → build SpatialListener (lazy; only if spatial used) → wire "statechange" listener → onStateChanged signal → if resumeOnInteraction: document.addEventListener("click", resumeOnce) [disposer registered] → if resumeOnPause: setInterval(retryResume, interval) while !running [disposer registered]disposeAudioEngine → run every fn in engine._disposers (removes listeners, clears interval/RAF) → close ctx unless it was provided/offline
createAudioEngineMediaStream → require a real-time AudioContext → create MediaStreamAudioDestinationNode → connect mainOut GainNode → media-stream destination (speaker path remains connected) → register idempotent tap disposal in engine._disposersdisposeAudioEngineMediaStream → disconnect only the media-stream destination → stop every MediaStreamTrack owned by the tapAll global hooks (document click listener, setInterval, spatial requestAnimationFrame) are registered into engine._disposers so disposal is leak-free. Nothing is registered at module load — the engine handle owns it all.
Sound
Stopped → Starting → Started → (Stopping) → Stopped, with Paused and
FailedToStart branches — identical to soundState.ts. onEnded fires when the
last instance ends (non-looping full play, or explicit stop).
Babylon.js Equivalence Map
| Lite function/type | AudioV2 origin |
|---|---|
createAudioEngineAsync | CreateAudioEngineAsync + _WebAudioEngine ctor/init |
AudioEngine.state | AudioEngineV2.state (maps ctx.state) |
unlockAudioEngineAsync | _WebAudioEngine.unlockAsync / resumeAsync |
createSoundAsync | CreateSoundAsync + _WebAudioStaticSound._initAsync |
createSoundBufferAsync | CreateSoundBufferAsync (decodeAudioData) |
playSound/stopSound/… | AbstractSound.play/stop/pause/resume |
SoundSubGraph + rebuild | _WebAudioBusAndSoundSubGraph + _onSubNodesChanged |
applyRamp / getRampCurve | _WebAudioParameterComponent + _GetAudioParamCurveValues |
SpatialSubNode / panner | _SpatialWebAudioSubNode (PannerNode config) |
SpatialListener | _SpatialWebAudioListener(+Fallback) |
attachSpatialTarget | _SpatialAudioAttacherComponent.attach |
updateSpatialAudio / RAF | _SpatialWebAudioUpdaterComponent |
createStreamingSoundAsync | CreateStreamingSoundAsync + _WebAudioStreamingSound |
createMicrophoneSoundSourceAsync | _WebAudioSoundSource (getUserMedia) |
createUnmuteUi | _WebAudioUnmuteUI (parentElement injected, no EngineStore) |
createAudioEngineMediaStream | Lite-only MediaStreamAudioDestinationNode post-master tap |
Behavior (node types, parameter mappings, ramp curves, panner/listener math) is identical. Only ownership, side-effect timing, and call syntax change.
Dependencies
- Web Audio API (
AudioContext,GainNode,PannerNode,StereoPannerNode,AnalyserNode,AudioBufferSourceNode,MediaElementAudioSourceNode,MediaStreamAudioSourceNode,MediaStreamAudioDestinationNode). - Lite
math/—Vec3,Quat,Mat4(spatial only; static/streaming/bus pull no math). fetch()— same plain pattern as every other Lite loader (no WebRequest).- No GPU, no scene, no render-loop dependency.
Tree-Shaking / Bundle Strategy
audio/index.tsre-exports only side-effect-free modules; nothing runs at import.- Feature modules (
spatial/,streaming/,analyzer/,microphone/,media-stream-output/,unmute-ui/) are reachable only through their own factory functions, so an app using justcreateSoundAsync+playSounddrops every other module. - The sub-node
ensureXSubNoderegistration is wired through the option parser, not a global registry — unused sub-nodes are eliminated. - Lazy-init for all caches (ramp curves, file-extension regex). No
module-level
newofMap/Set/Observable/RegExp.
Test Specification
Audio produces no pixels, so the Playwright screenshot/MAD parity harness
does not apply directly. Instead, audio is tested in four tiers. Tier 1
(mocked Web Audio) is the always-on deterministic CI gate; Tiers 2–3 render
through a real OfflineAudioContext and are opt-in (see below); Tier 4
is a manual showcase. The cornerstone is OfflineAudioContext: the entire
Web Audio graph can be rendered faster-than-real-time, with no user gesture
and no speakers, into a reproducible AudioBuffer. This lets us assert on the
actual rendered PCM — a stronger guarantee than any screenshot — and,
optionally, draw that PCM to a canvas for a deterministic visual gate.
As-built (Tier 2–3 backend). There is no in-browser run for these; they run headless in vitest against the native
node-web-audio-apipackage, a dev-only dependency that supplies a realOfflineAudioContextand the Web Audio node constructors. The harness installs those classes as globals for the duration of a render and passes the offline context tocreateAudioEngineAsync({ audioContext }). Because the native prebuilt binary may be unavailable on some platforms, every Tier-2/3 spec self-skips viadescribe.skipIf(!realWebAudioAvailable), so install/CI never hard-fail. The tiers live in their own opt-in vitest projectaudio-offline(pnpm test:audio-offline), separate from theunitproject.The
createAudioEngineAsyncaudioContextoption accepts aBaseAudioContext, so passing anOfflineAudioContextis the supported entry point for offline rendering. The engine detects offline contexts and skipsclose()/gesture-unlock paths on them (mirrors AudioV2's_isUsingOfflineAudioContext).
Tier 1 — Unit / behavioral (mocked Web Audio API), deterministic — always-on CI gate
As-built: tests/lite/unit/audio/*.test.ts (mocked Web Audio, runs in the
unit vitest project; no native dependency).
- Graph wiring — assert the correct
connect()topology for every feature combination (volume only; +stereo; +spatial; +spatial+stereo with root gain; +analyzer). Verify rebuild on add/remove. - Ramps — assert
setValueCurveAtTimeis called with the exact 2-point linear array and the 100-point exp/log curves (snapshot the curve values against the verbatim BJS math); assertcancelScheduledValues(0)precedes. - Lifecycle — state transitions;
onEndedfires once on last-instance end;maxInstancestrims oldest; pause/resume offset correctness. - Spatial wiring — panner
positionX/Y/Z,orientationX/Y/Z, distance/cone params set from a fakeSpatialTarget.worldMatrix; listener 9-param path + legacysetPosition/setOrientationfallback;updateSpatialAudiothrottling. - No side effects — import every
audio/module and assert no global mutation, nodocument/setInterval/new AudioContextat import time (guards the zero-side-effect pillar). - Disposal —
disposeAudioEngineremoves the click listener, clears the resume interval/RAF, stops registered media-stream output tracks, and closes only contexts it created.disposeAudioEngineMediaStreamdisconnects only its tap, stops its tracks, and is idempotent.
Tier 2 — Output correctness (OfflineAudioContext → PCM), opt-in (native) ★ primary correctness tier
Render real sounds through a real (offline) Web Audio graph and assert on the
returned samples. This validates audible behavior, not just node wiring.
As-built: tests/lite/audio/offline/*.test.ts (15 tests).
- Playback — render a known buffer (a 440 Hz sine) and assert peak/RMS,
per-window silence, and total duration. Looping renders the expected repeats;
startOffsetshifts the first non-zero sample. - Volume / ramps — render a sound-volume ramp and assert the PCM envelope follows the linear fade in/out (monotonic across windows).
- Stereo — assert left/right channel energy split matches the pan value (measured after the default pan ramp settles).
- Spatial — render with the source panned left/right and assert the inter-channel level split, plus linear-distance-model attenuation (a distant source is measurably quieter than a near one).
- Bus routing — attenuation through a bus chain matches the expected ratio of the direct output.
Tier 3 — Visual parity (offline PCM → waveform canvas), opt-in (native)
- Draw the Tier-2 rendered PCM to an image (a deterministic waveform raster)
and diff against a committed golden. Because offline rendering is
reproducible, the image is deterministic — this is the "show the waves" gate.
As-built:
tests/lite/audio/visual/waveform-golden.test.tsrenders via a small dependency-light pngjs rasterizer (_shared/waveform-png.ts) — used instead of a DOM canvas, which Node lacks without an extra native dependency. The waveform band is drawn thick and the diff is position-tolerant (a pixel matches if any golden pixel within a 2 px radius is within tolerance), so cosmetic sub-pixel envelope shifts across platforms do not flake the gate; a companiongolden-robustness.test.tsasserts a 2 px shift passes while a 12 px shift is still caught. Goldens live underreference/lite/audio/<case>.pngand are regenerated only on explicit request (UPDATE_AUDIO_GOLDENS=1).
Tier 4 — Live demo visualizer, NOT a gate
- The interactive demo uses a real-time
AnalyserNodeto draw a waveform + frequency-bar visualizer. It requires a user gesture to unlock the context and is non-deterministic, so it is a manual showcase only — never a CI check.
Behavioral parity against Babylon.js (optional, deterministic)
Render the same sound through BJS AudioV2 and through Lite, both on an
OfflineAudioContext, then diff the PCM. True behavioral parity, no ears
required. Useful for validating the spatial/ramp math during the port; not a
standing CI gate.
File Manifest
As-built layout is flat (no per-feature subdirectories):
packages/babylon-lite/src/audio/ index.ts # pure barrel audio-engine.ts # createAudioEngineAsync, dispose, unlock, master volume, offline detect audio-signal.ts # AudioSignal<T> (Observable replacement) audio-param.ts # ramp shapes + curve math (lazy-init caches) audio-fetch.ts # decode helpers over plain fetch() sound-buffer.ts # createSoundBufferAsync (decodeAudioData) + SoundBuffer static-sound.ts # StaticSound + instance lifecycle streaming-sound.ts # StreamingSound (HTMLAudioElement path) sound-sub-graph.ts # SoundSubGraph build/rebuild (core chain) bus.ts # MainBus (engine-owned) audio-bus.ts # AudioBus (createAudioBusAsync / dispose) spatial.ts # PannerNode + listener (+ legacy fallback) + attach/RAF updater stereo.ts # StereoPannerNode sub-node analyzer.ts # AnalyserNode (frequency + time-domain readback) sound-source.ts # createSoundSourceAsync / microphone (getUserMedia) media-stream-output.ts # post-master MediaStream tap (recording / WebRTC) unmute-ui.ts # DOM button (parentElement injected) visualizer.ts # runtime canvas waveform/bars (demo + manual; Lite-only) host-types.ts # AudioGraphHost union + shared sub-graph host plumbingdocs/lite/architecture/41-audio-engine.md # this doctests/lite/unit/audio/*.test.ts # Tier-1 vitest (mocked Web Audio; unit project)tests/lite/audio/offline/*.test.ts # Tier-2 vitest (real OfflineAudioContext → PCM; audio-offline project)tests/lite/audio/visual/*.test.ts # Tier-3 (offline PCM → pngjs waveform → golden diff)tests/lite/audio/_shared/real-web-audio.ts # node-web-audio-api harness (globals + renderOffline)tests/lite/audio/_shared/waveform-png.ts # pngjs rasterizer + position-tolerant golden comparereference/lite/audio/<case>.png # Tier-3 golden waveform images (flat)lab/lite/... # Tier-4 interactive showcase (real-time visualizer)The Tier-3 rasterizer (
tests/lite/audio/_shared/waveform-png.ts) is a test-only pngjs stand-in for the runtime canvasvisualizer.ts; it exists because Node has no DOM canvas. Tier-2/3 run headless in vitest against the nativenode-web-audio-apidev dependency (realOfflineAudioContext) and self-skip when its prebuilt binary is unavailable; Tier-4 uses the on-screen runtime visualizer.