Beta — not for production. This package is in early development (v0.x). APIs, behavior, and package structure may change without notice. Do not rely on it in production apps until a stable 1.0 release.
A small, typed Web Audio layer for games and 3D apps. Play sounds by event id, optionally spatialize them with HRTF when a world position is provided, and control loudness through master + category volumes that update live on playing sounds. The core engine is framework-agnostic; React and React Three Fiber helpers are included for typical setups.
npm install @webgamedevkit/audio-engine zustandFor React and R3F integrations, also install the optional peers:
npm install react @react-three/fiber threeIt provides you the volume values to control the categories you assigned plus the master volume value and mute toggle:
// .../stores/volumeStore.ts
import {
createAudioVolumeStore,
localStoragePersist,
} from "@webgamedevkit/audio-engine/stores";
// List of categories you want to have a separate volume value for
export const AUDIO_CATEGORIES = ["sfx", "music"] as const;
export const volumeStore = createAudioVolumeStore({
categories: AUDIO_CATEGORIES,
// Saves values to localStorage
persist: localStoragePersist("my-app-audio-settings"),
defaultCategoryVolumes: { sfx: 50, music: 50 },
});Declare audio assets directly on each sound config. Each sound corresponds to game event you provided so you won't leave any of your events w/o a sound:
// .../constants/audio.ts
import { defineSoundConfigs } from "@webgamedevkit/audio-engine";
import { AUDIO_CATEGORIES } from "../stores/volumeStore"
// Provide your in-game events
type GameEvent = "explosion" | "ui_click" | "game_start";
export const SOUND_CONFIGS = defineSoundConfigs<GameEvent>(
AUDIO_CATEGORIES,
{
explosion: {
// Fully typed categories --> "sfx" | "music"
category: "sfx",
src: "assets/explosion.wav",
},
ui_click: {
category: "sfx",
// Configure if this sound should be spatial
spatial: false,
src: "assets/click.wav",
},
game_start: {
category: "music",
spatial: false,
// You can specify multiple sources
srces: {
episode_1: "assets/main_theme.wav",
episode_2: "assets/main_theme_alt.wav",
},
}
},
);For React you can use a helper hook called useSpatialAudioEngine. It provides a simple type-safe API for you to play sounds:
import { useSpatialAudioEngine } from "@webgamedevkit/audio-engine/react";
import { volumeStore } from "../stores/volumeStore"
import { SOUND_CONFIGS } from "../constants/audio";
const { play, isReady } = useSpatialAudioEngine({
volumeStore: volumeStore,
soundConfigs: SOUND_CONFIGS,
// A common callback for handling errors
onLoadError: console.warn,
});
// ... Somewhere later ...
await play("ui_click");
await play(
"explosion",
// Provide position for spatial sound
{ worldPosition: { x: 10, y: 0, z: 5 } }
);The hook creates an AudioContext, resumes it on the first user click / keydown / touch, and reapplies volumes whenever the store changes. isReady is true once the context is activated.
In order for you to play spatial sounds in react-three-fiber, you'll have to synchronize it with the engine and the camera you have. Use <AudioListenerSync> as convenience component inside your <Canvas> so panners hear from the camera's point of view:
import { AudioListenerSync } from "@webgamedevkit/audio-engine/r3f";
<Canvas>
<AudioListenerSync />
{/* scene … */}
</Canvas>;For sounds with multiple samples, use srces and pass srcKey at play time. Omit srcKey to pick a random variant.
export const SOUND_CONFIGS = defineSoundConfigs(AUDIO_CATEGORIES, {
tower_shot: {
category: "sfx",
srces: {
cannon: "assets/cannon.wav",
laser: "assets/laser.wav",
},
},
});
await play("tower_shot", {
srcKey: "cannon", // typed as "cannon" | "laser"
worldPosition: { x: 0, y: 0, z: 0 },
});Optional patterns for less common setups.
Use normalize on file-backed configs to level inconsistent one-shot SFX without re-encoding or duplicating buffers. Skip it for music and long ambience loops — normalize those offline instead.
export const SOUND_CONFIGS = defineSoundConfigs(AUDIO_CATEGORIES, {
footstep: {
category: "sfx",
src: "assets/footstep.wav",
normalize: true, // default target: −1 dBFS
},
ui_tick: {
category: "sfx",
spatial: false,
src: "assets/tick.wav",
normalize: { targetPeak: 0.5 }, // custom linear peak in (0, 1]
},
});On first use, the engine scans the decoded AudioBuffer for the absolute peak across all channels and caches it. For finite, nonzero peaks, playback gain is multiplied by targetPeak / measuredPeak (quiet clips boosted, hot clips attenuated). Silent or invalid decoded buffers, where the measured peak is zero or non-finite, use unity gain instead. The decoded buffer is shared; only the gain differs per config/play.
Effective gain at play time: (master / 100) × (category / 100) × normalizationGain. preload() warms the peak cache when any config for that URL opts in, so the first gameplay play does not hitch.
| Config A | Config B | Peak scan | Playback gain |
|---|---|---|---|
normalize: true |
omitted | 1× (if A preloads/plays) | A scaled, B unity |
normalize: true |
{ targetPeak: 0.5 } |
1× | different gains, same peak |
| both omitted | — | 0× | both unity |
Limitations:
- Peak ≠ loudness — two sounds at the same peak can still feel very different; prefer offline LUFS for authored packs.
- Boost side effects — quiet assets get amplified (noise floor, hiss); overlapping boosted one-shots can clip; this is not a master limiter.
- Main-thread cost — peak scan is O(samples × channels) and synchronous; fine for short SFX, avoid on long loops.
- Scope — file-backed
src/srcesonly; ignored forresolveBuffer/ procedural sounds. - Not a substitute for per-sound mix gain or bus compression/limiting.
For synthetic or runtime-generated buffers, provide an optional resolveBuffer override. The engine uses it only for events without src / srces.
const PROCEDURAL_SOUND_CONFIGS = defineSoundConfigs(AUDIO_CATEGORIES, {
explosion: { category: "sfx", src: "assets/explosion.wav" },
ui_click: { category: "sfx", spatial: false, src: "assets/click.wav" },
synth_click: { category: "sfx", spatial: false },
});
const { play } = useSpatialAudioEngine({
soundConfigs: PROCEDURAL_SOUND_CONFIGS,
volumeStore: volumeStore,
resolveBuffer: async (ctx, event) => {
if (event !== "synth_click") return null;
const duration = 0.05;
const buffer = ctx.createBuffer(1, ctx.sampleRate * duration, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
const t = i / ctx.sampleRate;
data[i] = Math.sin(2 * Math.PI * 800 * t) * Math.exp(-t * 20) * 0.1;
}
return buffer;
},
});Persistence is optional. Omit persist for an in-memory store, or supply your own load/save logic.
import {
createAudioVolumeStore,
localStoragePersist,
sessionStoragePersist,
indexdbPersist,
} from "@webgamedevkit/audio-engine/stores";
createAudioVolumeStore({
categories: AUDIO_CATEGORIES,
persist: localStoragePersist("my-app-audio-settings"),
// persist: sessionStoragePersist("my-app-audio-settings"),
// persist: indexdbPersist("my-app-audio-settings"),
});Your callbacks own where data lives. The store auto-saves on every volume/mute change:
import {
createAudioVolumeStore,
type VolumePersistedState,
} from "@webgamedevkit/audio-engine/stores";
type AudioCategory = (typeof AUDIO_CATEGORIES)[number];
let saveData = { audio: null as VolumePersistedState<AudioCategory> | null };
export const useAudioStore = createAudioVolumeStore({
categories: AUDIO_CATEGORIES,
persist: {
key: "game-audio",
load: () => saveData.audio,
save: (state) => {
saveData.audio = state;
},
},
});
// On slot load:
saveData.audio = loadedSave.audio;
await useAudioStore.persist.rehydrate();When you only want to read/write volumes during explicit save/load:
import {
createAudioVolumeStore,
getVolumePersistedState,
hydrateVolumeStore,
} from "@webgamedevkit/audio-engine/stores";
export const useAudioStore = createAudioVolumeStore({
categories: AUDIO_CATEGORIES,
});
// On save:
gameSave.audio = getVolumePersistedState(useAudioStore);
// On load:
hydrateVolumeStore(
useAudioStore,
gameSave.audio,
useAudioStore.categories,
useAudioStore.initialCategoryVolumes
);| Import path | Contents |
|---|---|
@webgamedevkit/audio-engine |
Core engine, types |
@webgamedevkit/audio-engine/stores |
Volume store, persistence helpers |
@webgamedevkit/audio-engine/react |
useSpatialAudioEngine hook |
@webgamedevkit/audio-engine/r3f |
AudioListenerSync component |
- Internal loading — File-backed sounds load from
src/srceson the config. Buffers are cached and deduplicated across concurrent requests. - Spatial vs non-spatial — A sound is spatial when
config.spatial !== falseanddata.worldPositionis present. Otherwise it plays flat (a warning is logged if spatial was expected but position is missing). - Live volume — Changing master, category, or mute in the store updates gain on all currently playing sounds. Store values use a
0–100scale; effective gain is(master / 100) × (category / 100), so defaults of50/50produce0.25linear gain and100/100produces unity (1.0). - Pitch variation — One-shots get a random
playbackRateunlesspitchVariation: falseorloop: true. - Peak normalization — Opt-in
normalizeon file-backed configs scales playback gain from a cached peak scan (default −1 dBFS). This is not loudness matching or a limiter; best for short SFX, not long music loops. - Without React — Instantiate
SpatialAudioEnginedirectly, callsetActivated(true)after a user gesture, and manage theAudioContextyourself.
npm install
npm run build
npm run typecheck
npm testUse npm run dev to rebuild on file changes.