From 69ae2cd7e02085966a7eb5c680daaca04261af04 Mon Sep 17 00:00:00 2001 From: AI+idvorkin Date: Mon, 15 Dec 2025 01:52:04 +0000 Subject: [PATCH 1/4] fix: destroy AudioContext on resume timeout for iOS recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari can get stuck in a state where resume() times out but the context stays suspended. Previously we only destroyed the context on specific error messages - now we destroy on ANY resume failure. Changes: - Extract attemptResume() DRY helper for all resume paths - Always destroyContext() on resume failure (not just specific errors) - Add statechange listener (from Howler.js PR #1770) - Reduce code by 66 lines through refactoring - Fix test mock to include addEventListener, createBuffer - Add global canvas mock to suppress jsdom warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/services/audioService.test.ts | 21 +- src/services/audioService.ts | 310 +++++++++++------------------- src/test/setup.ts | 3 + 3 files changed, 134 insertions(+), 200 deletions(-) diff --git a/src/services/audioService.test.ts b/src/services/audioService.test.ts index 4e2a005..1dad968 100644 --- a/src/services/audioService.test.ts +++ b/src/services/audioService.test.ts @@ -11,6 +11,7 @@ class MockAudioContext { state: "suspended" | "running" | "closed" = "suspended"; currentTime = 0; destination = {}; + private listeners: Map = new Map(); resume = vi.fn().mockImplementation(() => { this.state = "running"; @@ -22,6 +23,13 @@ class MockAudioContext { return Promise.resolve(); }); + addEventListener = vi.fn().mockImplementation((event: string, handler: Function) => { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(handler); + }); + createOscillator = vi.fn().mockReturnValue({ connect: vi.fn(), frequency: { value: 0 }, @@ -37,6 +45,14 @@ class MockAudioContext { exponentialRampToValueAtTime: vi.fn(), }, }); + + // For silent buffer warmup + createBuffer = vi.fn().mockReturnValue({}); + createBufferSource = vi.fn().mockReturnValue({ + buffer: null, + connect: vi.fn(), + start: vi.fn(), + }); } // Store original @@ -183,7 +199,7 @@ describe("AudioService", () => { }); describe("testSound timeout", () => { - it("should timeout and return error if resume hangs", async () => { + it("should timeout and destroy context if resume hangs", async () => { // Make resume hang forever mockAudioContext.resume = vi.fn().mockImplementation(() => new Promise(() => {})); @@ -197,7 +213,8 @@ describe("AudioService", () => { const result = await promise; expect(result.played).toBe(false); - expect(result.error).toContain("timeout"); + // Context gets destroyed after timeout, so it reports destroyed state + expect(result.error).toContain("destroyed"); vi.useRealTimers(); }); diff --git a/src/services/audioService.ts b/src/services/audioService.ts index 25fcdd2..d8267b4 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -8,7 +8,7 @@ * - Single shared AudioContext (Safari limits to 4 max) * - Auto-unlock on first user gesture * - Handles suspended/interrupted/closed states - * - Self-cleaning event listeners after unlock + * - Destroys and recreates context on resume failure (iOS recovery) * - Records audio events to session recorder for debugging * * Usage: @@ -38,6 +38,9 @@ type AudioEventType = | "audio:test_failed" | "audio:silent_warmup"; +// Timeout for resume() calls - iOS Safari can hang indefinitely +const RESUME_TIMEOUT_MS = 3000; + // Helper to record audio events to session recorder function recordAudioEvent(type: AudioEventType, details?: Record): void { sessionRecorder.recordStateChange({ @@ -47,12 +50,8 @@ function recordAudioEvent(type: AudioEventType, details?: Record(promise: Promise, ms: number, errorMsg: string): Promise { return Promise.race([ @@ -66,7 +65,6 @@ function withTimeout(promise: Promise, ms: number, errorMsg: string): Prom /** * Play a silent buffer to "warm up" the AudioContext on iOS Safari * iOS requires actual audio playback (not just resume()) to unlock the context - * This is the proven pattern used by Howler.js and other audio libraries */ function playSilentBuffer(ctx: AudioContext): void { try { @@ -77,7 +75,6 @@ function playSilentBuffer(ctx: AudioContext): void { source.start(0); recordAudioEvent("audio:silent_warmup", { state: ctx.state }); } catch (error) { - // Ignore errors - this is a best-effort unlock attempt recordAudioEvent("audio:silent_warmup", { state: ctx.state, error: error instanceof Error ? error.message : String(error) @@ -85,7 +82,7 @@ function playSilentBuffer(ctx: AudioContext): void { } } -// Convenience functions for common audio events (like swing-analyzer pattern) +// Convenience functions for common audio events export function recordAudioPlayed(frequency: number, state: string): void { recordAudioEvent("audio:played", { frequency, state }); } @@ -112,10 +109,11 @@ export function recordAudioError(error: string, frequency?: number): void { class AudioService { private context: AudioContext | null = null; - private unlockPromise: Promise | null = null; + private unlockPromise: Promise | null = null; private unlockListenersAttached = false; private visibilityListenerAttached = false; - private resumeInProgress = false; // Prevent parallel resume() calls + private resumeInProgress = false; + private audioUnlocked = false; /** * Get or create the global AudioContext @@ -129,16 +127,16 @@ class AudioService { this.context = new AudioContextClass(); recordAudioEvent("audio:context_created", { state: this.context.state }); - // Set up auto-unlock listeners on creation this.setupUnlockListeners(); this.setupVisibilityListener(); + this.setupStateChangeListener(); } return this.context; } /** * Destroy context so it can be recreated fresh - * Used when iOS audio device fails and context is unrecoverable + * Called on ANY resume failure - iOS contexts get stuck and won't recover */ private destroyContext(): void { if (this.context) { @@ -155,8 +153,70 @@ class AudioService { } /** - * Set up visibility change listener to resume audio when user returns to tab - * iOS Safari may set context to "interrupted" when user switches tabs + * Core resume logic - DRY helper used by all resume paths + * Returns true if context is now running, false otherwise + */ + private async attemptResume(trigger: string): Promise { + const ctx = this.context; + if (!ctx) return false; + + const state = ctx.state as AudioContextState; + + // Already running + if (state === "running") { + return true; + } + + // Skip if another resume is in progress + if (this.resumeInProgress) { + // Wait for existing resume if there's a promise + if (this.unlockPromise) { + return this.unlockPromise; + } + return false; + } + + // Only resume from suspended/interrupted states + if (state !== "suspended" && state !== "interrupted") { + return false; + } + + this.resumeInProgress = true; + recordAudioEvent("audio:resuming", { trigger, fromState: state }); + playSilentBuffer(ctx); + + try { + await withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, `Resume timeout (${trigger})`); + this.audioUnlocked = true; + recordAudioResumed(ctx.state); + return ctx.state === "running"; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + recordAudioResumeFailed(errorMsg, state); + // Context is stuck - destroy it so next attempt gets a fresh one + this.destroyContext(); + return false; + } finally { + this.resumeInProgress = false; + } + } + + /** + * Listen for AudioContext state changes (pattern from Howler.js PR #1770) + */ + private setupStateChangeListener(): void { + if (!this.context?.addEventListener) return; + + this.context.addEventListener("statechange", () => { + if (!this.context || this.context.state === "running" || !this.audioUnlocked) { + return; + } + this.attemptResume("statechange"); + }); + } + + /** + * Resume audio when user returns to tab */ private setupVisibilityListener(): void { if (this.visibilityListenerAttached) return; @@ -164,138 +224,47 @@ class AudioService { document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && this.context) { - const state = this.context.state as AudioContextState; - if (state === "interrupted" || state === "suspended") { - // Skip if another resume is already in progress (prevents cascade) - if (this.resumeInProgress) { - return; - } - this.resumeInProgress = true; - recordAudioEvent("audio:resuming", { trigger: "visibility", fromState: state }); - playSilentBuffer(this.context); - withTimeout(this.context.resume(), RESUME_TIMEOUT_MS, "Resume timeout (visibility)") - .then(() => recordAudioResumed(this.context?.state ?? "unknown")) - .catch((err) => { - recordAudioResumeFailed(String(err), state); - // If audio device failed, destroy context for recovery - const errorMsg = String(err); - if (errorMsg.includes("Failed to start") || errorMsg.includes("audio device")) { - this.destroyContext(); - } - }) - .finally(() => { - this.resumeInProgress = false; - }); - } + this.attemptResume("visibility"); } }); } /** - * Set up event listeners to unlock audio on ANY user gesture - * Listeners stay attached to handle iOS re-interruption scenarios + * Unlock audio on ANY user gesture */ private setupUnlockListeners(): void { if (this.unlockListenersAttached) return; this.unlockListenersAttached = true; const events = ["touchstart", "touchend", "mousedown", "keydown", "click"]; - - const attemptUnlock = async () => { - const ctx = this.context; - if (!ctx) return; - - const state = ctx.state as AudioContextState; - // Already running - nothing to do - if (state === "running") { - return; - } - - // Skip if another resume is already in progress (prevents cascade) - if (this.resumeInProgress) { - return; - } - - // Try to resume on suspended or interrupted - if (state === "suspended" || state === "interrupted") { - try { - this.resumeInProgress = true; - recordAudioEvent("audio:resuming", { trigger: "gesture", fromState: state }); - // Play silent buffer FIRST to "warm up" iOS Safari - // This is the proven pattern - resume() alone often fails on iOS - playSilentBuffer(ctx); - await withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, "Resume timeout (gesture)"); - recordAudioResumed(ctx.state); - // Don't remove listeners - iOS can re-interrupt anytime - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioResumeFailed(errorMsg, state); - // If audio device failed, destroy context so it gets recreated - if (errorMsg.includes("Failed to start") || errorMsg.includes("audio device")) { - this.destroyContext(); - } - // Will retry on next gesture - } finally { - this.resumeInProgress = false; - } - } - }; - events.forEach((event) => - document.body.addEventListener(event, attemptUnlock, { passive: true }), + document.body.addEventListener(event, () => this.attemptResume("gesture"), { passive: true }), ); } /** * Ensure AudioContext is running before playing audio - * Safe to call multiple times - will reuse existing unlock promise - * Includes timeout because iOS Safari resume() can hang indefinitely */ async ensureRunning(): Promise { const ctx = this.getContext(); - const state = ctx.state as AudioContextState; - if (state === "running") { + if (ctx.state === "running") { return ctx; } - // Handle suspended or interrupted states - if (state === "suspended" || state === "interrupted") { - recordAudioEvent("audio:resuming", { trigger: "ensureRunning", fromState: state }); - // Play silent buffer to warm up iOS Safari before resume - playSilentBuffer(ctx); - // Reuse existing unlock promise if one is in progress - if (!this.unlockPromise) { - this.resumeInProgress = true; - this.unlockPromise = withTimeout( - ctx.resume(), - RESUME_TIMEOUT_MS, - "Resume timeout (ensureRunning)" - ).finally(() => { - this.unlockPromise = null; - this.resumeInProgress = false; - }); - } - - try { - await this.unlockPromise; - recordAudioResumed(this.context?.state ?? "unknown"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioResumeFailed(errorMsg, state); - // If audio device failed, destroy context so it gets recreated on next attempt - if (errorMsg.includes("Failed to start") || errorMsg.includes("audio device")) { - this.destroyContext(); - } - } + // Create shared promise so parallel calls don't race + if (!this.unlockPromise) { + this.unlockPromise = this.attemptResume("ensureRunning").finally(() => { + this.unlockPromise = null; + }); } - return this.getContext(); // Return fresh context if destroyed + await this.unlockPromise; + return this.getContext(); } /** - * Play a beep sound with the given parameters - * Handles all unlock logic internally + * Play a beep sound */ playBeep( frequency = 880, @@ -306,47 +275,29 @@ class AudioService { const ctx = this.getContext(); const state = ctx.state as AudioContextState; - // If context needs resuming, await it before playing - // This is critical - resume() is async and we must wait for it + if (state === "running") { + this.doPlayBeep(ctx, frequency, duration, type, volume); + return; + } + if (state === "suspended" || state === "interrupted") { - // Skip if resume already in progress (will be handled by that call) if (this.resumeInProgress) { recordAudioPlaySkipped("resume_in_progress", state); return; } - recordAudioResuming(state); - this.resumeInProgress = true; - playSilentBuffer(ctx); - withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, "Resume timeout (playBeep)") - .then(() => { - recordAudioResumed(ctx.state); - this.doPlayBeep(ctx, frequency, duration, type, volume); - }) - .catch((error) => { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioResumeFailed(errorMsg, state); - // If audio device failed, destroy context for recovery - if (errorMsg.includes("Failed to start") || errorMsg.includes("audio device")) { - this.destroyContext(); - } - }) - .finally(() => { - this.resumeInProgress = false; - }); + this.attemptResume("playBeep").then((success) => { + if (success && this.context?.state === "running") { + this.doPlayBeep(this.context, frequency, duration, type, volume); + } + }); return; } - // Context already running - play immediately - if (ctx.state === "running") { - this.doPlayBeep(ctx, frequency, duration, type, volume); - } else { - // Unexpected state - not suspended, not running - recordAudioPlaySkipped("unexpected_state", ctx.state); - } + recordAudioPlaySkipped("unexpected_state", ctx.state); } /** - * Internal method to actually play the beep + * Actually play the beep */ private doPlayBeep( ctx: AudioContext, @@ -355,7 +306,6 @@ class AudioService { type: OscillatorType, volume: number, ): void { - // Double-check context is still valid and running if (ctx.state !== "running") { recordAudioPlaySkipped("not_running", ctx.state); return; @@ -369,36 +319,29 @@ class AudioService { oscillator.frequency.value = frequency; oscillator.type = type; gainNode.gain.setValueAtTime(volume, ctx.currentTime); - gainNode.gain.exponentialRampToValueAtTime( - 0.01, - ctx.currentTime + duration, - ); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + duration); recordAudioPlayed(frequency, ctx.state); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); recordAudioError(errorMsg, frequency); - // If playback fails, destroy context so it gets recreated this.destroyContext(); } } /** - * Prime the audio context - call on app startup or first user interaction - * This ensures the context exists and unlock listeners are attached + * Prime the audio context on app startup */ prime(): void { this.getContext(); } /** - * Test sound - plays a recognizable beep and logs diagnostic info - * Useful for debugging audio issues on iOS - * Returns a promise that resolves when the sound has been played (or failed) + * Test sound - plays a beep and returns diagnostic info */ async testSound(): Promise<{ state: string; played: boolean; error?: string }> { - let ctx = this.getContext(); + const ctx = this.getContext(); const initialState = ctx.state; recordAudioEvent("audio:test_requested", { @@ -408,54 +351,26 @@ class AudioService { }); try { - // Resume if needed and wait for it (with timeout - iOS can hang) - if (ctx.state !== "running") { - // If resume already in progress, wait for it instead of starting another - if (this.unlockPromise) { - await this.unlockPromise; - } else { - this.resumeInProgress = true; - recordAudioResuming(ctx.state); - playSilentBuffer(ctx); - try { - await withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, "Resume timeout (testSound)"); - recordAudioResumed(ctx.state); - } finally { - this.resumeInProgress = false; - } - } - // Re-get context in case it was destroyed during resume - ctx = this.getContext(); - } + const success = await this.attemptResume("testSound"); - // Now try to play - if (ctx.state === "running") { - this.doPlayBeep(ctx, 440, 0.3, "sine", 0.8); // A4 note, 300ms - recordAudioEvent("audio:test_played", { state: ctx.state }); - return { state: ctx.state, played: true }; + if (success && this.context?.state === "running") { + this.doPlayBeep(this.context, 440, 0.3, "sine", 0.8); + recordAudioEvent("audio:test_played", { state: this.context.state }); + return { state: this.context.state, played: true }; } - recordAudioEvent("audio:test_failed", { - reason: "not_running_after_resume", - state: ctx.state - }); - return { state: ctx.state, played: false, error: `Context state: ${ctx.state}` }; + const finalState = this.context?.state ?? "destroyed"; + recordAudioEvent("audio:test_failed", { reason: "not_running", state: finalState }); + return { state: finalState, played: false, error: `Context state: ${finalState}` }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioEvent("audio:test_failed", { - error: errorMsg, - initialState - }); - // If audio device failed, destroy context for recovery on next attempt - if (errorMsg.includes("Failed to start") || errorMsg.includes("audio device")) { - this.destroyContext(); - } + recordAudioEvent("audio:test_failed", { error: errorMsg, initialState }); return { state: initialState, played: false, error: errorMsg }; } } /** - * Get current audio context state for diagnostics + * Get current state for diagnostics */ getState(): { contextExists: boolean; state: string | null; unlockListenersAttached: boolean } { return { @@ -466,5 +381,4 @@ class AudioService { } } -// Export singleton instance export const audioService = new AudioService(); diff --git a/src/test/setup.ts b/src/test/setup.ts index 8a33b5e..a3918f1 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,5 +1,8 @@ import "@testing-library/jest-dom"; +// Mock canvas getContext to suppress jsdom warning about WebGL +HTMLCanvasElement.prototype.getContext = () => null; + // Mock AudioContext for tests class MockAudioContext { createOscillator() { From b0813a82b137eb3f557b2c7fa0a00ef636246576 Mon Sep 17 00:00:00 2001 From: AI+idvorkin Date: Mon, 15 Dec 2025 01:53:55 +0000 Subject: [PATCH 2/4] test: mock useVersionCheck to fix act() warning --- src/App.test.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 1e01d31..83a5d21 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,18 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import App from "./App"; import { BugReporterProvider } from "./contexts/BugReporterContext"; +// Mock useVersionCheck to prevent act() warning from async state updates +vi.mock("./hooks/useVersionCheck", () => ({ + useVersionCheck: () => ({ + updateAvailable: false, + applyUpdate: vi.fn(), + dismissUpdate: vi.fn(), + }), +})); + function renderApp() { return render( From 7b7efaaca312d5ede84f4e67ee1152fe7bfe73a4 Mon Sep 17 00:00:00 2001 From: AI+idvorkin Date: Mon, 15 Dec 2025 01:55:39 +0000 Subject: [PATCH 3/4] fix: address code review - reset audioUnlocked flag and fix race condition --- src/services/audioService.ts | 62 +++++++++++++++++------------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/src/services/audioService.ts b/src/services/audioService.ts index d8267b4..6903434 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -149,6 +149,7 @@ class AudioService { this.context = null; this.unlockPromise = null; this.resumeInProgress = false; + this.audioUnlocked = false; // Reset so statechange listener doesn't auto-resume new context } } @@ -156,49 +157,51 @@ class AudioService { * Core resume logic - DRY helper used by all resume paths * Returns true if context is now running, false otherwise */ - private async attemptResume(trigger: string): Promise { + private attemptResume(trigger: string): Promise { const ctx = this.context; - if (!ctx) return false; + if (!ctx) return Promise.resolve(false); const state = ctx.state as AudioContextState; // Already running if (state === "running") { - return true; + return Promise.resolve(true); } - // Skip if another resume is in progress - if (this.resumeInProgress) { - // Wait for existing resume if there's a promise - if (this.unlockPromise) { - return this.unlockPromise; - } - return false; + // If another resume is in progress, wait for it + if (this.unlockPromise) { + return this.unlockPromise; } // Only resume from suspended/interrupted states if (state !== "suspended" && state !== "interrupted") { - return false; + return Promise.resolve(false); } + // Create promise that other callers can wait on this.resumeInProgress = true; recordAudioEvent("audio:resuming", { trigger, fromState: state }); playSilentBuffer(ctx); - try { - await withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, `Resume timeout (${trigger})`); - this.audioUnlocked = true; - recordAudioResumed(ctx.state); - return ctx.state === "running"; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioResumeFailed(errorMsg, state); - // Context is stuck - destroy it so next attempt gets a fresh one - this.destroyContext(); - return false; - } finally { - this.resumeInProgress = false; - } + this.unlockPromise = withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, `Resume timeout (${trigger})`) + .then(() => { + this.audioUnlocked = true; + recordAudioResumed(ctx.state); + return ctx.state === "running"; + }) + .catch((error) => { + const errorMsg = error instanceof Error ? error.message : String(error); + recordAudioResumeFailed(errorMsg, state); + // Context is stuck - destroy it so next attempt gets a fresh one + this.destroyContext(); + return false; + }) + .finally(() => { + this.resumeInProgress = false; + this.unlockPromise = null; + }); + + return this.unlockPromise; } /** @@ -252,14 +255,7 @@ class AudioService { return ctx; } - // Create shared promise so parallel calls don't race - if (!this.unlockPromise) { - this.unlockPromise = this.attemptResume("ensureRunning").finally(() => { - this.unlockPromise = null; - }); - } - - await this.unlockPromise; + await this.attemptResume("ensureRunning"); return this.getContext(); } From e6a0835b067f6c24a1cd729937572ab6dfb64e4c Mon Sep 17 00:00:00 2001 From: AI+idvorkin Date: Mon, 15 Dec 2025 01:58:18 +0000 Subject: [PATCH 4/4] refactor: architect-level cleanup of audioService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improvements for code review: 1. **Fixed timer leak**: withTimeout now clears timer on success 2. **Removed dead code**: Exported helper functions were unused 3. **Better naming**: audioUnlocked → hasUnlockedBefore (clearer intent) 4. **Consolidated flags**: 3 listener flags → 1 listenersAttached 5. **DRY helpers**: getErrorMessage() extracts error messages 6. **Clear sections**: Code organized with section headers 7. **Documented design decisions**: Why playBeep skips vs waits 8. **Improved JSDoc**: All public methods documented with examples 9. **Cleaner event types**: Renamed for consistency (test_played → test_success) Structure: - Types & Constants (top) - Helper functions (pure, testable) - AudioService class with clear sections: - Context Lifecycle - Resume Logic (core attemptResume method) - Event Listeners - Playback - Public API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/services/audioService.ts | 446 +++++++++++++++++++++-------------- 1 file changed, 264 insertions(+), 182 deletions(-) diff --git a/src/services/audioService.ts b/src/services/audioService.ts index 6903434..e0d1317 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -1,275 +1,315 @@ /** - * Global AudioContext Service with iOS Safari Auto-Unlock + * AudioService - iOS-Safe Web Audio Playback * - * Provides a reliable way to play Web Audio on all platforms, including iOS Safari - * which has strict requirements around AudioContext initialization and user gestures. + * A singleton service that handles Web Audio API playback with full iOS Safari support. + * iOS Safari has strict requirements that make audio playback tricky: * - * Best Practices Implemented: - * - Single shared AudioContext (Safari limits to 4 max) - * - Auto-unlock on first user gesture - * - Handles suspended/interrupted/closed states - * - Destroys and recreates context on resume failure (iOS recovery) - * - Records audio events to session recorder for debugging + * 1. AudioContext must be created/resumed within a user gesture + * 2. Safari limits pages to 4 AudioContext instances + * 3. Context can be "interrupted" by phone calls, Siri, tab switches + * 4. resume() can hang indefinitely (requires timeout) + * 5. Sometimes resume() times out and context becomes unrecoverable * - * Usage: - * import { audioService } from './services/audioService'; - * audioService.playBeep(440, 0.2); // Play 440Hz for 200ms + * Solution: Single shared context with aggressive recovery. On ANY resume failure, + * destroy the context entirely. Next user gesture creates a fresh one. + * + * @example + * // Simple beep + * audioService.playBeep(440, 0.2); + * + * // Ensure running before timer starts (call in click handler) + * await audioService.ensureRunning(); * * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices - * @see https://www.mattmontag.com/web/unlock-web-audio-in-safari-for-ios-and-macos + * @see https://webkit.org/blog/6784/new-video-policies-for-ios/ */ import { sessionRecorder } from "./pwaDebugServices"; -type AudioContextState = "suspended" | "running" | "closed" | "interrupted"; +// ============================================================================ +// Types +// ============================================================================ + +/** AudioContext states including iOS-specific "interrupted" */ +type ContextState = "suspended" | "running" | "closed" | "interrupted"; -// Audio event types for session recording +/** Events recorded to session for debugging audio issues */ type AudioEventType = - | "audio:played" - | "audio:play_skipped" - | "audio:play_error" + | "audio:context_created" + | "audio:context_destroyed" | "audio:resuming" | "audio:resumed" | "audio:resume_failed" - | "audio:context_created" - | "audio:context_closed" + | "audio:silent_warmup" + | "audio:played" + | "audio:play_skipped" + | "audio:play_error" | "audio:test_requested" - | "audio:test_played" - | "audio:test_failed" - | "audio:silent_warmup"; + | "audio:test_success" + | "audio:test_failed"; + +// ============================================================================ +// Constants +// ============================================================================ -// Timeout for resume() calls - iOS Safari can hang indefinitely +/** Timeout for resume() - iOS Safari can hang indefinitely */ const RESUME_TIMEOUT_MS = 3000; -// Helper to record audio events to session recorder -function recordAudioEvent(type: AudioEventType, details?: Record): void { - sessionRecorder.recordStateChange({ - type, - timestamp: Date.now(), - details, - }); +/** Events that indicate a user gesture (for iOS unlock) */ +const USER_GESTURE_EVENTS = ["touchstart", "touchend", "mousedown", "keydown", "click"] as const; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Record audio event to session recorder for debugging */ +function recordEvent(type: AudioEventType, details?: Record): void { + sessionRecorder.recordStateChange({ type, timestamp: Date.now(), details }); +} + +/** Extract error message from unknown error */ +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } /** - * Wrap a promise with a timeout - rejects if promise doesn't resolve in time + * Race a promise against a timeout. Cleans up timer on success. */ -function withTimeout(promise: Promise, ms: number, errorMsg: string): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(errorMsg)), ms) - ), - ]); +function withTimeout(promise: Promise, ms: number, timeoutMsg: string): Promise { + let timeoutId: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMsg)), ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)); } /** - * Play a silent buffer to "warm up" the AudioContext on iOS Safari - * iOS requires actual audio playback (not just resume()) to unlock the context + * Play silent buffer to "warm up" iOS Safari AudioContext. + * iOS sometimes requires actual playback (not just resume()) to unlock. + * Pattern used by Howler.js and other audio libraries. */ -function playSilentBuffer(ctx: AudioContext): void { +function playSilentWarmup(ctx: AudioContext): void { try { const buffer = ctx.createBuffer(1, 1, 22050); const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); source.start(0); - recordAudioEvent("audio:silent_warmup", { state: ctx.state }); + recordEvent("audio:silent_warmup", { state: ctx.state }); } catch (error) { - recordAudioEvent("audio:silent_warmup", { - state: ctx.state, - error: error instanceof Error ? error.message : String(error) - }); + // Best-effort - don't fail if warmup fails + recordEvent("audio:silent_warmup", { state: ctx.state, error: getErrorMessage(error) }); } } -// Convenience functions for common audio events -export function recordAudioPlayed(frequency: number, state: string): void { - recordAudioEvent("audio:played", { frequency, state }); -} +// ============================================================================ +// AudioService Class +// ============================================================================ -export function recordAudioPlaySkipped(reason: string, state: string): void { - recordAudioEvent("audio:play_skipped", { reason, state }); -} +class AudioService { + /** The shared AudioContext instance */ + private context: AudioContext | null = null; -export function recordAudioResuming(fromState: string): void { - recordAudioEvent("audio:resuming", { fromState }); -} + /** Promise for in-progress resume operation (allows coalescing parallel calls) */ + private resumePromise: Promise | null = null; -export function recordAudioResumed(newState: string): void { - recordAudioEvent("audio:resumed", { newState }); -} + /** Whether listeners have been attached (they persist across context recreation) */ + private listenersAttached = false; -export function recordAudioResumeFailed(error: string, fromState: string): void { - recordAudioEvent("audio:resume_failed", { error, fromState }); -} + /** Whether we've ever successfully unlocked (for statechange auto-recovery) */ + private hasUnlockedBefore = false; -export function recordAudioError(error: string, frequency?: number): void { - recordAudioEvent("audio:play_error", { error, frequency }); -} - -class AudioService { - private context: AudioContext | null = null; - private unlockPromise: Promise | null = null; - private unlockListenersAttached = false; - private visibilityListenerAttached = false; - private resumeInProgress = false; - private audioUnlocked = false; + // ========================================================================== + // Context Lifecycle + // ========================================================================== /** - * Get or create the global AudioContext + * Get or create the AudioContext. + * Lazily creates context on first access and sets up unlock listeners. */ private getContext(): AudioContext { if (!this.context || this.context.state === "closed") { const AudioContextClass = window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext; + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + this.context = new AudioContextClass(); - recordAudioEvent("audio:context_created", { state: this.context.state }); + recordEvent("audio:context_created", { state: this.context.state }); - this.setupUnlockListeners(); - this.setupVisibilityListener(); - this.setupStateChangeListener(); + this.setupListeners(); } return this.context; } /** - * Destroy context so it can be recreated fresh - * Called on ANY resume failure - iOS contexts get stuck and won't recover + * Destroy context for recovery. Called on ANY resume failure. + * + * iOS Safari contexts can get stuck in states where resume() times out + * but the context isn't technically "closed". Destroying and recreating + * is the only reliable recovery path. */ private destroyContext(): void { - if (this.context) { - recordAudioEvent("audio:context_closed", { reason: "destroyed_for_recovery" }); - try { - this.context.close(); - } catch { - // Ignore close errors - } - this.context = null; - this.unlockPromise = null; - this.resumeInProgress = false; - this.audioUnlocked = false; // Reset so statechange listener doesn't auto-resume new context + if (!this.context) return; + + recordEvent("audio:context_destroyed", { + previousState: this.context.state, + hadUnlocked: this.hasUnlockedBefore, + }); + + try { + this.context.close(); + } catch { + // Ignore close errors } + + this.context = null; + this.resumePromise = null; + this.hasUnlockedBefore = false; } + // ========================================================================== + // Resume Logic + // ========================================================================== + /** - * Core resume logic - DRY helper used by all resume paths - * Returns true if context is now running, false otherwise + * Attempt to resume the AudioContext. + * + * This is the core method - all resume paths go through here. + * Handles: coalescing parallel calls, timeout, silent warmup, error recovery. + * + * @param trigger - What triggered this resume (for logging) + * @returns Promise - true if context is now running */ private attemptResume(trigger: string): Promise { const ctx = this.context; if (!ctx) return Promise.resolve(false); - const state = ctx.state as AudioContextState; + const state = ctx.state as ContextState; - // Already running + // Already running - success if (state === "running") { return Promise.resolve(true); } - // If another resume is in progress, wait for it - if (this.unlockPromise) { - return this.unlockPromise; + // Resume already in progress - wait for it (coalesce parallel calls) + if (this.resumePromise) { + return this.resumePromise; } - // Only resume from suspended/interrupted states + // Can only resume from suspended/interrupted if (state !== "suspended" && state !== "interrupted") { return Promise.resolve(false); } - // Create promise that other callers can wait on - this.resumeInProgress = true; - recordAudioEvent("audio:resuming", { trigger, fromState: state }); - playSilentBuffer(ctx); + // Start resume operation + recordEvent("audio:resuming", { trigger, fromState: state }); + playSilentWarmup(ctx); - this.unlockPromise = withTimeout(ctx.resume(), RESUME_TIMEOUT_MS, `Resume timeout (${trigger})`) + this.resumePromise = withTimeout( + ctx.resume(), + RESUME_TIMEOUT_MS, + `Resume timeout (${trigger})` + ) .then(() => { - this.audioUnlocked = true; - recordAudioResumed(ctx.state); + this.hasUnlockedBefore = true; + recordEvent("audio:resumed", { state: ctx.state, trigger }); return ctx.state === "running"; }) .catch((error) => { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioResumeFailed(errorMsg, state); - // Context is stuck - destroy it so next attempt gets a fresh one + recordEvent("audio:resume_failed", { + error: getErrorMessage(error), + fromState: state, + trigger, + }); + // Context is stuck - destroy so next attempt gets fresh one this.destroyContext(); return false; }) .finally(() => { - this.resumeInProgress = false; - this.unlockPromise = null; + this.resumePromise = null; }); - return this.unlockPromise; + return this.resumePromise; } - /** - * Listen for AudioContext state changes (pattern from Howler.js PR #1770) - */ - private setupStateChangeListener(): void { - if (!this.context?.addEventListener) return; - - this.context.addEventListener("statechange", () => { - if (!this.context || this.context.state === "running" || !this.audioUnlocked) { - return; - } - this.attemptResume("statechange"); - }); - } + // ========================================================================== + // Event Listeners + // ========================================================================== /** - * Resume audio when user returns to tab + * Set up all event listeners for auto-unlock. + * Called once per AudioService lifetime (listeners persist across context recreation). */ - private setupVisibilityListener(): void { - if (this.visibilityListenerAttached) return; - this.visibilityListenerAttached = true; + private setupListeners(): void { + if (this.listenersAttached) return; + this.listenersAttached = true; + + // User gesture listeners - attempt unlock on any interaction + for (const event of USER_GESTURE_EVENTS) { + document.body.addEventListener( + event, + () => this.attemptResume("gesture"), + { passive: true } + ); + } + // Visibility listener - resume when user returns to tab document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && this.context) { this.attemptResume("visibility"); } }); - } - /** - * Unlock audio on ANY user gesture - */ - private setupUnlockListeners(): void { - if (this.unlockListenersAttached) return; - this.unlockListenersAttached = true; - - const events = ["touchstart", "touchend", "mousedown", "keydown", "click"]; - events.forEach((event) => - document.body.addEventListener(event, () => this.attemptResume("gesture"), { passive: true }), - ); + // StateChange listener - auto-recover from iOS interruptions + // Pattern from Howler.js PR #1770 + this.setupStateChangeListener(); } /** - * Ensure AudioContext is running before playing audio + * Listen for AudioContext statechange events. + * Must be re-attached for each new context instance. */ - async ensureRunning(): Promise { - const ctx = this.getContext(); - - if (ctx.state === "running") { - return ctx; - } + private setupStateChangeListener(): void { + if (!this.context?.addEventListener) return; - await this.attemptResume("ensureRunning"); - return this.getContext(); + this.context.addEventListener("statechange", () => { + // Only auto-resume if we've successfully unlocked before + // (don't auto-resume a fresh context that was never user-unlocked) + if (this.context?.state !== "running" && this.hasUnlockedBefore) { + this.attemptResume("statechange"); + } + }); } + // ========================================================================== + // Playback + // ========================================================================== + /** - * Play a beep sound + * Play a beep sound. + * + * If context needs resuming, attempts resume then plays. + * If resume is already in progress, skips this beep (fire-and-forget). + * + * Design decision: playBeep is fire-and-forget because beeps are usually + * time-sensitive (timers). Waiting 3s for a timeout would miss the moment. + * Better to skip and let the next beep succeed with a fresh context. + * + * @param frequency - Hz (default 880) + * @param duration - seconds (default 0.15) + * @param type - oscillator type (default "sine") + * @param volume - 0-1 (default 0.7) */ playBeep( frequency = 880, duration = 0.15, type: OscillatorType = "sine", - volume = 0.7, + volume = 0.7 ): void { const ctx = this.getContext(); - const state = ctx.state as AudioContextState; + const state = ctx.state as ContextState; if (state === "running") { this.doPlayBeep(ctx, frequency, duration, type, volume); @@ -277,10 +317,12 @@ class AudioService { } if (state === "suspended" || state === "interrupted") { - if (this.resumeInProgress) { - recordAudioPlaySkipped("resume_in_progress", state); + // If resume already in progress, skip (don't queue beeps) + if (this.resumePromise) { + recordEvent("audio:play_skipped", { reason: "resume_in_progress", state }); return; } + this.attemptResume("playBeep").then((success) => { if (success && this.context?.state === "running") { this.doPlayBeep(this.context, frequency, duration, type, volume); @@ -289,61 +331,93 @@ class AudioService { return; } - recordAudioPlaySkipped("unexpected_state", ctx.state); + recordEvent("audio:play_skipped", { reason: "unexpected_state", state }); } /** - * Actually play the beep + * Internal: actually play the beep (assumes context is running). */ private doPlayBeep( ctx: AudioContext, frequency: number, duration: number, type: OscillatorType, - volume: number, + volume: number ): void { if (ctx.state !== "running") { - recordAudioPlaySkipped("not_running", ctx.state); + recordEvent("audio:play_skipped", { reason: "not_running", state: ctx.state }); return; } try { const oscillator = ctx.createOscillator(); - const gainNode = ctx.createGain(); - oscillator.connect(gainNode); - gainNode.connect(ctx.destination); + const gain = ctx.createGain(); + + oscillator.connect(gain); + gain.connect(ctx.destination); + oscillator.frequency.value = frequency; oscillator.type = type; - gainNode.gain.setValueAtTime(volume, ctx.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); + + gain.gain.setValueAtTime(volume, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); + oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + duration); - recordAudioPlayed(frequency, ctx.state); + + recordEvent("audio:played", { frequency, duration, state: ctx.state }); } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioError(errorMsg, frequency); + recordEvent("audio:play_error", { error: getErrorMessage(error), frequency }); this.destroyContext(); } } + // ========================================================================== + // Public API + // ========================================================================== + /** - * Prime the audio context on app startup + * Prime the audio context. + * Call on app startup to create context and attach unlock listeners early. + * The context will still be suspended until a user gesture. */ prime(): void { this.getContext(); } /** - * Test sound - plays a beep and returns diagnostic info + * Ensure AudioContext is running. + * Call this in a click handler before starting a timer. + * Waits for resume to complete (unlike playBeep which is fire-and-forget). + * + * @returns The running AudioContext + */ + async ensureRunning(): Promise { + const ctx = this.getContext(); + + if (ctx.state === "running") { + return ctx; + } + + await this.attemptResume("ensureRunning"); + return this.getContext(); + } + + /** + * Test sound playback. + * Plays a recognizable beep and returns diagnostic info. + * Used by the settings panel "Test Sound" button. + * + * @returns Diagnostic info about the attempt */ async testSound(): Promise<{ state: string; played: boolean; error?: string }> { const ctx = this.getContext(); const initialState = ctx.state; - recordAudioEvent("audio:test_requested", { + recordEvent("audio:test_requested", { initialState, - contextExists: !!this.context, - resumeInProgress: this.resumeInProgress, + hasContext: !!this.context, + hasUnlockedBefore: this.hasUnlockedBefore, }); try { @@ -351,30 +425,38 @@ class AudioService { if (success && this.context?.state === "running") { this.doPlayBeep(this.context, 440, 0.3, "sine", 0.8); - recordAudioEvent("audio:test_played", { state: this.context.state }); + recordEvent("audio:test_success", { state: this.context.state }); return { state: this.context.state, played: true }; } const finalState = this.context?.state ?? "destroyed"; - recordAudioEvent("audio:test_failed", { reason: "not_running", state: finalState }); + recordEvent("audio:test_failed", { reason: "not_running", state: finalState }); return { state: finalState, played: false, error: `Context state: ${finalState}` }; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - recordAudioEvent("audio:test_failed", { error: errorMsg, initialState }); + const errorMsg = getErrorMessage(error); + recordEvent("audio:test_failed", { error: errorMsg, initialState }); return { state: initialState, played: false, error: errorMsg }; } } /** - * Get current state for diagnostics + * Get diagnostic state info. + * Used by debug UI to show current audio status. */ - getState(): { contextExists: boolean; state: string | null; unlockListenersAttached: boolean } { + getState(): { + contextExists: boolean; + state: string | null; + hasUnlockedBefore: boolean; + resumeInProgress: boolean; + } { return { contextExists: !!this.context, state: this.context?.state ?? null, - unlockListenersAttached: this.unlockListenersAttached, + hasUnlockedBefore: this.hasUnlockedBefore, + resumeInProgress: this.resumePromise !== null, }; } } +// Export singleton export const audioService = new AudioService();