WakaDSound

WakaDSound is an audio plugin for wakaPAC modelled after DirectSound. It manages static buffers for sound effects and streaming buffers for music and ambience. One playback slot per handle - play() is a no-op if the handle is already playing.

explanation

Getting Started

Include the script after wakaPAC and register the plugin using the instance:

<script src="https://cdn.jsdelivr.net/gh/quellabs/wakapac@main/wakapac.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/quellabs/wakapac@main/plugins/wakadsound.min.js"></script>

<script>
    wakaPAC.use(wakaDSound); // must be called before any wakaPAC() calls
</script>

Once registered, WakaDSound (constructor) and wakaDSound (singleton instance) are available globally. All API calls go through wakaDSound.

Basic Usage

Load buffers and streams during component initialisation. Store handles at the abstraction root so WakaDSound can route per-handle messages to the owning component:

wakaPAC('myComponent', {
    snd:   null,
    music: null,

    async init() {
        this.snd   = await wakaDSound.loadBuffer('/sfx/click.wav');
        this.music = await wakaDSound.loadStream('/music/theme.ogg', { loop: true });
    },

    msgProc(event) {
        switch (event.message) {
            case wakaPAC.MSG_LCLICK:
                wakaDSound.play(this.snd);
                break;

            case WakaDSound.MSG_BUFFER_STARTED:
                console.log('click sound started');
                break;

            case WakaDSound.MSG_STREAM_STOPPED:
                console.log('music track ended');
                break;
        }
    }
});

Usage

Handles

Every loaded buffer or stream returns an opaque handle. The handle is the single playback slot for that sound - calling play() on an already-playing handle is a no-op. Handles remain valid across multiple play/stop cycles until free() is called.

All generic playback functions - play(), stop(), setVolume(), isPlaying(), free() - accept both buffer and stream handles.

Autoplay

Browsers require a user gesture before audio can play. WakaDSound handles this automatically - calling play() from within a user interaction is sufficient. No manual setup is needed. To suspend audio when the page is hidden and resume when it becomes visible again, react to browserVisible - a reactive property injected into every component's abstraction by wakaPAC:

wakaPAC('myComponent', {
    watch: {
        browserVisible(isVisible) {
            if (isVisible) {
                wakaDSound.resume();
            } else {
                wakaDSound.suspend();
            }
        }
    }
});

Static Buffers

Static buffers are loaded once and held in memory. They are suited to short sound effects that need low latency and may be played repeatedly. loadBuffer() is asynchronous - it fetches and prepares the file, then returns a handle. Once loaded, play() is immediate.

// Default options
const snd = await wakaDSound.loadBuffer('/sfx/shoot.wav');

// With initial volume and pan
const snd = await wakaDSound.loadBuffer('/sfx/shoot.wav', { volume: 80, pan: -0.5 });

// With 3D positional audio
const snd = await wakaDSound.loadBuffer('/sfx/explosion.wav', { positional: true });

Streaming Buffers

Streaming buffers play audio progressively without loading it into memory. They are suited to long-form audio - music, ambience, narration. loadStream() is asynchronous - it prepares the source, then returns a handle.

// Default options
const music = await wakaDSound.loadStream('/music/theme.ogg');

// With initial volume and looping
const music = await wakaDSound.loadStream('/music/theme.ogg', { volume: 60, loop: true });

3D Positional Audio

Positional audio is opt-in per buffer via the positional: true load option. Sound sources and the listener are positioned in 3D space - WakaDSound calculates panning and attenuation automatically. The listener defaults to the origin facing -Z.

const snd = await wakaDSound.loadBuffer('/sfx/engine.wav', { positional: true });
wakaDSound.play(snd);

// Move the sound source
wakaDSound.setPosition(snd, 5, 0, -3);

// Move the listener
wakaDSound.setListenerPosition(0, 0, 0);
wakaDSound.setListenerOrientation(0, 0, -1);  // forward vector

setPan() is a no-op for positional buffers. Use setPosition() instead. Positional audio is not available for streams.

Analyser

The analyser delivers waveform data to a callback on every frame. It captures all playing sounds simultaneously - buffers, streams, and any combination thereof.

const analyser = wakaDSound.createAnalyser(data => {
    // data is a Uint8Array of time-domain samples (128 values by default)
    // called on every animation frame while the analyser is active
    drawOscilloscope(data);
});

// When done
wakaDSound.destroyAnalyser(analyser);

The fftSize option controls the resolution. It must be a power of 2 between 32 and 32768. The callback receives fftSize / 2 samples per frame. The default of 2048 is sufficient for a smooth oscilloscope display.

const analyser = wakaDSound.createAnalyser(data => {
    drawOscilloscope(data);
}, { fftSize: 1024 });

The callback receives the same buffer on every frame. Copy the data if you need it to persist beyond the callback:

const analyser = wakaDSound.createAnalyser(data => {
    const snapshot = new Uint8Array(data); // copy if needed beyond this call
});

The callback pauses automatically when audio is suspended and resumes when it runs again. Call destroyAnalyser() when the visualiser is no longer needed.

Message Routing

WakaDSound sends two categories of messages. Global messages are broadcast to all components. Per-handle messages are sent only to the component that owns the handle. Ownership is determined automatically - WakaDSound scans each component's abstraction root for a matching handle reference. For this to work, handles must be stored as top-level properties of the abstraction, not nested inside sub-objects.

// Correct - handle at abstraction root
wakaPAC('myComponent', {
    snd: null,
    async init() { this.snd = await wakaDSound.loadBuffer('/sfx/hit.wav'); }
});

// Won't receive per-handle messages - handle is nested
wakaPAC('myComponent', {
    audio: { snd: null },
    async init() { this.audio.snd = await wakaDSound.loadBuffer('/sfx/hit.wav'); }
});

API

Device

wakaDSound.masterVolume(volume)

Sets the master volume for all sounds and streams. Takes effect immediately.

ParameterTypeDescription
volumenumberMaster volume, clamped to 0–100
Returns void

wakaDSound.suspend()

Suspends audio output. All playing sounds go silent. No-op if already suspended.

ParameterTypeDescription
Returns Promise<void>

wakaDSound.resume()

Resumes audio output. Must be called from within a user gesture if audio was suspended by the browser's autoplay policy.

ParameterTypeDescription
Returns Promise<void>

Loading

wakaDSound.loadBuffer(url, options?)

Loads an audio file and returns a buffer handle. The audio is held in memory for the lifetime of the handle. Broadcasts MSG_BUFFER_LOADED on success, MSG_BUFFER_FAILED on failure.

ParameterTypeDescription
urlstringURL of the audio file to load
options.volumenumberInitial volume, 0–100. Default: 1
options.pannumberInitial stereo pan, -1–1. Default: 0. Ignored for positional buffers.
options.positionalbooleanEnable 3D positional audio. Default: false
Returns Promise<Object | null>Buffer handle, or null on failure

wakaDSound.loadStream(url, options?)

Prepares a stream handle for long-form audio. Broadcasts MSG_STREAM_LOADED on success, MSG_STREAM_FAILED on failure. The handle remains valid across multiple play/stop cycles — call free() to release it when no longer needed.

ParameterTypeDescription
urlstringURL of the audio file to stream
options.volumenumberInitial volume, 0–100. Default: 1
options.loopbooleanWhether the stream loops. Default: false
Returns Promise<Object | null>Stream handle, or null on failure

Generic Playback

These functions accept both buffer and stream handles.

wakaDSound.play(handle)

Starts playback. No-op if the handle is already playing. Resumes audio automatically if suspended by the browser.

ParameterTypeDescription
handleObjectBuffer or stream handle
Returns void

wakaDSound.stop(handle)

Stops playback. No-op if not playing. Resets playback to the beginning. The handle remains valid and can be played again. To release all resources, call free() instead.

ParameterTypeDescription
handleObjectBuffer or stream handle
Returns void

wakaDSound.setVolume(handle, volume)

Sets the volume for a handle. Takes effect immediately, whether playing or not.

ParameterTypeDescription
handleObjectBuffer or stream handle
volumenumberVolume, clamped to 0–100
Returns void

wakaDSound.isPlaying(handle)

Returns true if the handle is currently playing.

ParameterTypeDescription
handleObjectBuffer or stream handle
Returns boolean

wakaDSound.free(handle)

Stops playback and releases all Web Audio nodes. The handle must not be used after this call.

ParameterTypeDescription
handleObjectBuffer or stream handle
Returns void

Buffer-only

wakaDSound.setPan(handle, pan)

Sets the stereo pan for a non-positional buffer. No-op for positional buffers and streams.

ParameterTypeDescription
handleObjectBuffer handle (non-positional)
pannumberPan value, clamped to -1 (left) to +1 (right). 0 = center.
Returns void

Stream-only

wakaDSound.loop(handle, loop)

Enables or disables looping for a stream. Takes effect immediately, whether playing or not. No-op for buffer handles.

ParameterTypeDescription
handleObjectStream handle
loopbooleanWhether the stream should loop
Returns void

wakaDSound.seek(handle, seconds)

Seeks to a position in a stream. No-op for buffer handles.

ParameterTypeDescription
handleObjectStream handle
secondsnumberPlayback position in seconds
Returns void

Analyser

wakaDSound.createAnalyser(callback, options?)

Starts delivering waveform data to a callback on every frame, capturing all currently playing sounds. Returns an analyser handle that must be passed to destroyAnalyser() when done.

ParameterTypeDescription
callbackFunctionCalled each frame with (data: Uint8Array). The same buffer is reused across frames.
options.fftSizenumberFFT size. Must be a power of 2 between 32 and 32768. Default: 2048
Returns Object | nullAnalyser handle, or null if the context is unavailable

wakaDSound.destroyAnalyser(handle)

Stops the analyser. Audio playback continues unaffected. The handle must not be used after this call.

ParameterTypeDescription
handleObjectAnalyser handle from createAnalyser()
Returns void

3D Positional Audio

wakaDSound.setPosition(handle, x, y, z)

Sets the 3D position of a positional buffer's sound source. No-op for non-positional buffers and streams.

ParameterTypeDescription
handleObjectBuffer handle loaded with { positional: true }
x, y, znumberPosition in 3D space
Returns void

wakaDSound.setListenerPosition(x, y, z)

Sets the listener position in 3D space. Affects all positional buffers simultaneously.

ParameterTypeDescription
x, y, znumberListener position in 3D space
Returns void

wakaDSound.setListenerOrientation(x, y, z)

Sets the listener's forward orientation vector. The up vector is fixed at (0, 1, 0).

ParameterTypeDescription
x, y, znumberForward direction vector
Returns void

Messages

WakaDSound sends eleven message types. Global messages are broadcast to all components. Per-handle messages are sent only to the component whose abstraction root contains the handle - the handle must be a top-level property of the abstraction for routing to work.

Buffer Loaded (MSG_BUFFER_LOADED)

Broadcast to all components when a buffer finishes loading successfully.

case WakaDSound.MSG_BUFFER_LOADED: {
    // Buffer is ready - event.detail.buffer is the handle
    this.snd = event.detail.buffer;
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ url, buffer } - the source URL and the buffer handle

Buffer Failed (MSG_BUFFER_FAILED)

Broadcast to all components when a buffer fails to load. The returned handle is null.

case WakaDSound.MSG_BUFFER_FAILED: {
    console.warn('Buffer failed to load:', event.detail.url);
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ url, error } - the source URL and the error

Stream Loaded (MSG_STREAM_LOADED)

Broadcast to all components when a stream is ready to play.

case WakaDSound.MSG_STREAM_LOADED: {
    // Stream is ready - event.detail.stream is the handle
    this.music = event.detail.stream;
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ url, stream } - the source URL and the stream handle

Stream Failed (MSG_STREAM_FAILED)

Broadcast to all components when a stream fails to load or encounters a mid-playback error such as a network abort. The handle is null for load failures.

case WakaDSound.MSG_STREAM_FAILED: {
    console.warn('Stream failed to load:', event.detail.url);
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ url, error } - the source URL and the error

Buffer Started (MSG_BUFFER_STARTED)

Sent to the owning component when a buffer begins playing.

case WakaDSound.MSG_BUFFER_STARTED: {
    // event.detail.handle is the buffer that started
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the buffer handle that started playing

Buffer Stopped (MSG_BUFFER_STOPPED)

Sent to the owning component when a buffer stops - either via stop() or by reaching the end of the audio data naturally.

case WakaDSound.MSG_BUFFER_STOPPED: {
    // event.detail.handle is the buffer that stopped
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the buffer handle that stopped

Stream Started (MSG_STREAM_STARTED)

Sent to the owning component when a stream begins playing.

case WakaDSound.MSG_STREAM_STARTED: {
    // event.detail.handle is the stream that started
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the stream handle that started playing

Stream Stopped (MSG_STREAM_STOPPED)

Sent to the owning component when a stream stops - either via stop() or by reaching the end of the audio naturally.

case WakaDSound.MSG_STREAM_STOPPED: {
    // event.detail.handle is the stream that was stopped
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the stream handle that was stopped

Stream Ready (MSG_STREAM_READY)

Sent to the owning component when data resumes flowing after a stall. Only sent if MSG_STREAM_BUFFERING was previously sent.

case WakaDSound.MSG_STREAM_READY: {
    // Hide buffering indicator
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the stream handle that resumed

Stream Error (MSG_STREAM_ERROR)

Sent to the owning component when a stream encounters an error during playback - for example a network failure or media decode error. Distinct from MSG_STREAM_FAILED which signals a load failure. The stream handle remains valid after this event but playback has stopped.

case WakaDSound.MSG_STREAM_ERROR: {
    console.warn('Stream error:', event.detail.error);
    // Optionally attempt recovery or notify the user
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle, error } - the stream handle and the media error

Stream Buffering (MSG_STREAM_BUFFERING)

Sent to the owning component when the browser cannot fetch stream data. This is transient and often recovers on its own - the component may choose to show a buffering indicator or wait. If data resumes flowing, MSG_STREAM_READY follows. If the stream does not recover, MSG_STREAM_ERROR will follow.

case WakaDSound.MSG_STREAM_BUFFERING: {
    // Show buffering indicator
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle } - the stream handle that stalled

Volume Changed (MSG_VOLUME_CHANGED)

Sent to the owning component when setVolume() is called on a handle.

case WakaDSound.MSG_VOLUME_CHANGED: {
    console.log('Volume:', event.detail.volume);
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle, volume } - the handle and the new volume value (0–1)

Pan Changed (MSG_PAN_CHANGED)

Sent to the owning component when setPan() is called on a buffer handle.

case WakaDSound.MSG_PAN_CHANGED: {
    console.log('Pan:', event.detail.pan);
    break;
}

Message Parameters

ParameterTypeDescription
wParamnumberAlways 0
lParamnumberAlways 0
event.detailobject{ handle, pan } - the handle and the new pan value (-1–1)

Notes

  • masterVolume() affects all buffers and streams uniformly - it is a global level control, not per-handle.
  • Calling stop() on a buffer resets it to the beginning — the handle remains valid and can be played again. Calling stop() on a stream pauses it; the next play() call reloads the stream from the beginning automatically.
  • For 2D games and spatial UI, use setListenerOrientation(0, 0, -1) with the listener at the origin and sound sources positioned on the XZ plane.

Implementation Notes

  • Static buffers are decoded entirely into memory on load via the Web Audio API. This gives them very low playback latency - play() is synchronous once the buffer is loaded. AudioBufferSourceNode is single-use in the Web Audio API and cannot be restarted once it ends. WakaDSound hides this by creating a fresh source node on every play() call while keeping the gain and pan nodes alive across plays.
  • Streaming buffers use an HTMLAudioElement fed into the audio graph via a MediaElementSourceNode. On each play() call the underlying element is replaced with a fresh one loaded from the same URL. This avoids the decoder-resume click that browsers produce when resuming a paused MediaElementAudioSourceNode. The gain node persists across reloads so volume control and audio graph routing are unaffected. This approach avoids the complexity of AudioWorklet while still routing the stream through the shared audio graph for master volume control.
  • Positional buffers use a PannerNode with HRTF panning and an inverse distance model. The up vector is fixed at (0, 1, 0) - sufficient for the common case of 2D-plane positional audio.
  • The analyser inserts an AnalyserNode between the master gain and the audio destination, capturing all output. It drives its own requestAnimationFrame loop and calls getByteTimeDomainData() into a pre-allocated Uint8Array each frame, reusing the same buffer to avoid garbage collection pressure.
  • Per-handle message routing works by scanning each registered component's abstraction root for a handle that matches by reference. Handles stored in the abstraction are wrapped in a reactive proxy by wakaPAC - WakaDSound unwraps them via proxy.unwrap() before comparing.

Best Practices

  • Register before creating components - call wakaPAC.use(wakaDSound) before any wakaPAC() calls. Note: pass the instance wakaDSound, not the constructor WakaDSound.
  • Store handles at the abstraction root - per-handle messages are only routed to the owning component if the handle is a direct property of the abstraction, not nested inside a sub-object.
  • Load once, play many times - loadBuffer() and loadStream() are async operations. Call them during init() or at application startup, not on every user interaction.
  • Free handles when done - call free() when a component is destroyed or a sound is no longer needed.
  • Use streams for music, buffers for effects - buffers decode the entire file into memory and are suited for short, frequently triggered sounds. Streams are suited for long audio that should not occupy memory.
  • Destroy analysers when done - call destroyAnalyser() when the visualiser is no longer visible.
  • Handle suspend/resume for tab visibility - use a browserVisible watcher to call wakaDSound.suspend() and wakaDSound.resume() so audio stops when the user switches tabs.