web-audio

Web Audio Browser Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "web-audio" with this command: npx skills add matthewharwood/fantasy-phonics/matthewharwood-fantasy-phonics-web-audio

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 patterns

  • web-components : Integrating audio with custom elements

  • ux-feedback-patterns : Audio as part of user feedback

  • ux-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-motion respected

  • Web Audio nodes created once, reused

  • TTS preference persisted to localStorage

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

ux-spacing-layout

No summary provided by upstream source.

Repository SourceNeeds Review
General

animejs-v4

No summary provided by upstream source.

Repository SourceNeeds Review
General

audio-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

web-components-architecture

No summary provided by upstream source.

Repository SourceNeeds Review