| name | web-audio |
| description | Production-tested patterns for fault-tolerant browser audio with zero-lag rapid-fire support. Use when implementing sound effects, background music, voice feedback, or any audio playback in web applications. Covers AudioContext singleton, preloading, cloneNode for rapid-fire, autoplay handling, and Web Audio API effects. |
| allowed-tools | Read, Write, Edit, Glob, Grep |
Web Audio Browser Patterns
Production-tested patterns for fault-tolerant browser audio with zero-lag rapid-fire support.
Related Skills
javascript: Async patterns, cleanup in disconnectedCallback, singleton patternsweb-components: Integrating audio with custom elementsux-feedback-patterns: Audio as part of user feedbackux-accessibility: Respecting prefers-reduced-motion for audio
Rule 1: AudioContext is Expensive — Create Once
AudioContext creation is expensive and browsers limit the number of contexts. Create one per page, reuse forever.
// ✅ Singleton AudioContext
class AudioService {
static #audioContext = null;
static getContext() {
if (!this.#audioContext) {
this.#audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return this.#audioContext;
}
// Resume context on user interaction (required by browsers)
static async ensureResumed() {
const ctx = this.getContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
return ctx;
}
}
// ❌ Never create context per sound
function badPlaySound() {
const ctx = new AudioContext(); // Creates new context every time!
// ...
}
Why: Browsers limit AudioContext instances. Creating contexts is slow and triggers garbage collection.
Rule 2: Preload All Sounds at Startup
Load audio files once at startup, play instantly during gameplay. Never load audio on hot paths.
// ✅ Preload into cache
class AudioService {
static #sounds = new Map();
static #loaded = false;
static async preload() {
if (this.#loaded) return;
const soundFiles = {
sparkle: '/audio/sfx/sparkle.mp3',
success: '/audio/sfx/success.mp3',
phaseComplete: '/audio/sfx/phase-complete.mp3',
wordComplete: '/audio/sfx/word-complete.mp3',
milestone: '/audio/sfx/milestone.mp3',
click: '/audio/sfx/click.mp3',
error: '/audio/sfx/error.mp3'
};
const loadPromises = Object.entries(soundFiles).map(async ([name, path]) => {
try {
const audio = new Audio(path);
audio.preload = 'auto';
// Wait for audio to be loaded enough to play
await new Promise((resolve, reject) => {
audio.addEventListener('canplaythrough', resolve, { once: true });
audio.addEventListener('error', reject, { once: true });
audio.load();
});
this.#sounds.set(name, audio);
} catch (error) {
console.warn(`Failed to load sound: ${name}`, error);
// Don't throw - audio is non-critical
}
});
await Promise.allSettled(loadPromises);
this.#loaded = true;
}
static getSound(name) {
return this.#sounds.get(name);
}
}
Why: Network requests during gameplay cause lag. Preloading ensures instant playback.
Rule 3: cloneNode() for Rapid-Fire Sounds
For sounds that trigger rapidly (hover, clicks, typing), use cloneNode() to create instant playable copies without network requests.
// ✅ Clone for overlapping/rapid plays
static playHoverSound() {
const cached = this.#sounds.get('hover');
if (!cached) return;
// Cancel currently playing instance
if (this.#currentHover && !this.#currentHover.paused) {
this.#currentHover.pause();
this.#currentHover.currentTime = 0;
}
// Clone creates instant playable copy (no network request)
this.#currentHover = cached.cloneNode();
this.#currentHover.volume = 0.3;
return this.#currentHover.play().catch(() => {});
}
// ✅ Generic rapid-fire pattern
static playRapidFire(name, volume = 0.5) {
const cached = this.#sounds.get(name);
if (!cached) return Promise.resolve();
const clone = cached.cloneNode();
clone.volume = volume;
return clone.play().catch(() => {});
}
// ❌ Never create new Audio() on hot paths
function badRapidFire(path) {
const audio = new Audio(path); // Network request every time!
return audio.play();
}
Why: new Audio(path) triggers a network request. cloneNode() creates an instant copy from the cached audio buffer.
Rule 4: Cancel Before Play — Prevent Audio Pile-Up
For sounds that shouldn't overlap (UI feedback, voice), cancel the previous instance before starting a new one.
// ✅ Track and cancel current instance
class AudioService {
static #currentVoice = null;
static playVoiceFeedback(text) {
// Cancel any currently playing voice
if (this.#currentVoice && !this.#currentVoice.paused) {
this.#currentVoice.pause();
this.#currentVoice.currentTime = 0;
}
this.#currentVoice = this.#sounds.get('voice')?.cloneNode();
if (!this.#currentVoice) return;
this.#currentVoice.volume = 0.5;
return this.#currentVoice.play().catch(() => {});
}
}
// ✅ For overlapping sounds (celebrations), don't cancel - let them layer
static playCelebrationSound() {
const cached = this.#sounds.get('celebration');
if (!cached) return;
// Clone without canceling previous - sounds can overlap
const clone = cached.cloneNode();
clone.volume = 0.7;
return clone.play().catch(() => {});
}
Why: Without cancellation, rapid triggers create audio pile-up where dozens of sounds play simultaneously.
Rule 5: Silent .catch() on Every .play()
Browser autoplay policies block audio until user interaction. Always catch and ignore these errors silently.
// ✅ Silent catch - ALWAYS
audio.play().catch(() => {});
// ✅ With optional logging for development
audio.play().catch(e => {
if (e.name !== 'NotAllowedError') {
console.warn('Audio playback failed:', e);
}
});
// ❌ Never leave .play() uncaught
audio.play(); // Throws on autoplay block!
// ❌ Don't let autoplay errors bubble up
async function badPlaySound() {
await audio.play(); // Throws to caller on autoplay block
}
Why: Browsers block autoplay until user interaction. Uncaught promise rejections crash the application or flood the console.
Rule 6: Window Globals for Hot-Reload Survival
For background music or persistent audio state, store singletons on window to survive module hot-reload during development.
// ✅ Singleton survives hot-reload
if (typeof window.__AudioServiceClass === 'undefined') {
window.__AudioServiceClass = class AudioService {
#enabled = true;
#volume = 0.5;
#sounds = new Map();
constructor() {
// Restore state from previous instance
this.#enabled = window.__audioEnabled ?? true;
this.#volume = window.__audioVolume ?? 0.5;
}
setEnabled(enabled) {
this.#enabled = enabled;
window.__audioEnabled = enabled; // Persist across hot-reload
}
setVolume(volume) {
this.#volume = volume;
window.__audioVolume = volume;
}
};
}
if (!window.__audioServiceInstance) {
window.__audioServiceInstance = new window.__AudioServiceClass();
}
export default window.__audioServiceInstance;
Why: During development, module hot-reload destroys and recreates module scope. Window globals persist, preventing audio restart.
Rule 7: Volume Hierarchy by Sound Type
Different sound types serve different purposes and need different volume levels to feel balanced.
| Sound Type | Volume Range | Rationale |
|---|---|---|
| Hover/Click | 0.2–0.3 | Subtle, frequent — shouldn't fatigue |
| Typing feedback | 0.2–0.3 | Very frequent — whisper quiet |
| Success/Error | 0.3–0.5 | Clear feedback, moderate frequency |
| Phase Complete | 0.4–0.6 | Meaningful milestone |
| Word Complete | 0.5–0.7 | Significant achievement |
| Milestone/Rank-Up | 0.7–0.8 | Big celebration moments |
| Background Music | 0.15–0.25 | Never dominate, support atmosphere |
| Warning/Alert | 0.4–0.5 | Attention-getting but not startling |
// ✅ Volume constants
const VOLUMES = {
MICRO: 0.25, // sparkle, click, hover
FEEDBACK: 0.4, // success, error
CELEBRATION: 0.6, // phase complete
MAJOR: 0.75, // word complete, milestone
MUSIC: 0.2 // background
};
static playSparkle() {
return this.playRapidFire('sparkle', VOLUMES.MICRO);
}
static playSuccess() {
return this.play('success', VOLUMES.FEEDBACK);
}
static playMilestone() {
return this.play('milestone', VOLUMES.MAJOR);
}
Why: Balanced audio creates professional feel. Loud frequent sounds cause fatigue; quiet celebrations feel anticlimactic.
Rule 8: Respect prefers-reduced-motion for Audio
Users who prefer reduced motion often want reduced audio stimulation too. Check the preference and adjust.
// ✅ Check preference and adjust
class AudioService {
static #enabled = true;
static {
// Disable by default if user prefers reduced motion
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
this.#enabled = !mediaQuery.matches;
// Listen for changes
mediaQuery.addEventListener('change', (e) => {
this.#enabled = !e.matches;
});
}
static play(name, volume = 0.5) {
if (!this.#enabled) return Promise.resolve();
const sound = this.#sounds.get(name);
if (!sound) return Promise.resolve();
const clone = sound.cloneNode();
clone.volume = volume;
return clone.play().catch(() => {});
}
static setEnabled(enabled) {
this.#enabled = enabled;
}
static get enabled() {
return this.#enabled;
}
}
Why: Accessibility includes audio. Users with vestibular disorders or sensory sensitivities benefit from reduced audio.
Rule 9: Web Audio API for Effects (Reverb, Ducking)
Use Web Audio API for advanced effects like reverb, ducking, and filtering. Create nodes once, reuse forever.
Sidechain Ducking (Lower Music When SFX Play)
// ✅ Duck background music when sound effects play
class AudioService {
static #musicGain = null;
static #audioContext = null;
static initMusicWithDucking(musicElement) {
this.#audioContext = this.getContext();
// Create source from music element
const source = this.#audioContext.createMediaElementSource(musicElement);
// Create gain node for ducking
this.#musicGain = this.#audioContext.createGain();
this.#musicGain.gain.value = 1.0;
// Connect: source → gain → destination
source.connect(this.#musicGain);
this.#musicGain.connect(this.#audioContext.destination);
}
static duckMusicFor(durationMs = 1500) {
if (!this.#musicGain || !this.#audioContext) return;
const now = this.#audioContext.currentTime;
// Quick duck down
this.#musicGain.gain.setValueAtTime(this.#musicGain.gain.value, now);
this.#musicGain.gain.linearRampToValueAtTime(0.1, now + 0.1);
// Slow release back up
setTimeout(() => {
const releaseTime = this.#audioContext.currentTime;
this.#musicGain.gain.linearRampToValueAtTime(1.0, releaseTime + 0.5);
}, durationMs);
}
// Call duck when playing important sounds
static playMilestone() {
this.duckMusicFor(2000);
return this.play('milestone', VOLUMES.MAJOR);
}
}
Hall Reverb Effect
// ✅ Apply reverb effect
static applyHallReverb(duration = 2.0) {
const ctx = this.getContext();
// Create convolver for reverb (create once, reuse)
if (!this.#convolver) {
this.#convolver = ctx.createConvolver();
this.#wetGain = ctx.createGain();
this.#dryGain = ctx.createGain();
// Generate impulse response
const sampleRate = ctx.sampleRate;
const length = sampleRate * duration;
const impulse = ctx.createBuffer(2, length, sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
// Exponential decay with noise
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
}
}
this.#convolver.buffer = impulse;
// Initial dry/wet mix
this.#dryGain.gain.value = 1.0;
this.#wetGain.gain.value = 0.0;
}
return { convolver: this.#convolver, wetGain: this.#wetGain, dryGain: this.#dryGain };
}
Why: Web Audio API enables professional audio effects without external libraries. Create nodes once to avoid performance issues.
Rule 10: Text-to-Speech Integration
Use Web Speech API for reading text aloud, with user preference persistence.
// ✅ TTS with preference check
class TTSService {
static #enabled = false;
static {
this.#enabled = localStorage.getItem('ttsEnabled') === 'true';
}
static speak(text, options = {}) {
if (!this.#enabled) return;
if (!('speechSynthesis' in window)) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = options.rate ?? 0.9; // Slightly slower for kids
utterance.pitch = options.pitch ?? 1.0;
utterance.volume = options.volume ?? 0.8;
// Cancel any current speech
speechSynthesis.cancel();
speechSynthesis.speak(utterance);
}
static stop() {
speechSynthesis.cancel();
}
static setEnabled(enabled) {
this.#enabled = enabled;
localStorage.setItem('ttsEnabled', String(enabled));
}
static get enabled() {
return this.#enabled;
}
static get supported() {
return 'speechSynthesis' in window;
}
}
Why: TTS helps emergent readers and improves accessibility. User preference should persist across sessions.
Web Audio Node Routing Reference
Simple Playback:
Source ──────────────────────────────► Destination
With Volume Control:
Source ──► GainNode ─────────────────► Destination
Wet/Dry Effects (Reverb):
Source ─┬─► DryGain ─────────────────┬► Destination
└─► Effect ──► WetGain ──────┘
Sidechain Ducking:
Music ──► MusicGain ─────────────────► Destination
SFX ────► SFXGain ───────────────────► Destination
(MusicGain.gain reduced when SFX plays)
Common Web Audio Nodes
| Node | Purpose |
|---|---|
GainNode |
Volume control |
ConvolverNode |
Reverb, room simulation |
DelayNode |
Echo, delay effects |
BiquadFilterNode |
EQ, low/high pass |
DynamicsCompressorNode |
Limiting, compression |
AnalyserNode |
Visualization data |
Complete AudioService Implementation
/**
* AudioService - Centralized audio management
*
* Skills applied:
* - web-audio: All 10 rules
* - javascript: Singleton, cleanup, error handling
* - ux-accessibility: prefers-reduced-motion
*/
class AudioService {
static #sounds = new Map();
static #enabled = true;
static #volume = 0.5;
static #currentByCategory = new Map();
static #audioContext = null;
static #loaded = false;
// Volume hierarchy
static VOLUMES = {
MICRO: 0.25,
FEEDBACK: 0.4,
CELEBRATION: 0.6,
MAJOR: 0.75,
MUSIC: 0.2
};
static {
// Respect reduced motion preference
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
this.#enabled = !mq.matches;
mq.addEventListener('change', (e) => { this.#enabled = !e.matches; });
}
static async preload() {
if (this.#loaded) return;
const files = {
sparkle: '/audio/sfx/sparkle.mp3',
success: '/audio/sfx/success.mp3',
phaseComplete: '/audio/sfx/phase-complete.mp3',
wordComplete: '/audio/sfx/word-complete.mp3',
milestone: '/audio/sfx/milestone.mp3',
click: '/audio/sfx/click.mp3',
error: '/audio/sfx/error.mp3'
};
await Promise.allSettled(
Object.entries(files).map(async ([name, path]) => {
try {
const audio = new Audio(path);
audio.preload = 'auto';
this.#sounds.set(name, audio);
} catch (e) {
console.warn(`Audio load failed: ${name}`, e);
}
})
);
this.#loaded = true;
}
static play(name, volume = 0.5, category = null) {
if (!this.#enabled) return Promise.resolve();
const cached = this.#sounds.get(name);
if (!cached) return Promise.resolve();
// Cancel previous in same category
if (category) {
const prev = this.#currentByCategory.get(category);
if (prev && !prev.paused) {
prev.pause();
prev.currentTime = 0;
}
}
const clone = cached.cloneNode();
clone.volume = Math.min(1, volume * this.#volume);
if (category) {
this.#currentByCategory.set(category, clone);
}
return clone.play().catch(() => {});
}
// Convenience methods
static playSparkle() { return this.play('sparkle', this.VOLUMES.MICRO); }
static playSuccess() { return this.play('success', this.VOLUMES.FEEDBACK, 'feedback'); }
static playError() { return this.play('error', this.VOLUMES.FEEDBACK, 'feedback'); }
static playPhaseComplete() { return this.play('phaseComplete', this.VOLUMES.CELEBRATION); }
static playWordComplete() { return this.play('wordComplete', this.VOLUMES.MAJOR); }
static playMilestone() { return this.play('milestone', this.VOLUMES.MAJOR); }
static playClick() { return this.play('click', this.VOLUMES.MICRO, 'ui'); }
static setEnabled(enabled) { this.#enabled = enabled; }
static setVolume(v) { this.#volume = Math.max(0, Math.min(1, v)); }
static get enabled() { return this.#enabled; }
static get volume() { return this.#volume; }
}
export { AudioService };
export const audioService = AudioService;
Checklist
- AudioContext created once per page
- All sounds preloaded at startup
-
cloneNode()used for rapid-fire sounds - Previous sound canceled before playing (where appropriate)
- Silent
.catch(() => {})on every.play() - Window globals for hot-reload survival (if needed)
- Volume hierarchy applied by sound type
-
prefers-reduced-motionrespected - Web Audio nodes created once, reused
- TTS preference persisted to localStorage