diff --git a/src/play.ts b/src/play.ts index 224d1d9..6c3ba87 100644 --- a/src/play.ts +++ b/src/play.ts @@ -266,6 +266,10 @@ async function main(): Promise { playBtn.addEventListener('click', async () => { if (!events) return; + // CRITICAL: On iOS, we must interact with AudioContext synchronously + // in the user gesture before any async work. Fire off unlock immediately. + const unlockPromise = unlockAudio(); + // Pause (implemented as stop + remembered position) if (isPlaying) { showTimeRow(); @@ -284,8 +288,8 @@ async function main(): Promise { } try { - // Unlock audio for iOS - must be called from user gesture - await unlockAudio(); + // Wait for unlock to complete + await unlockPromise; // First play generates the artifact (deterministically from MIDI structure). if (!isGenerated) { diff --git a/src/utils/audioUnlock.ts b/src/utils/audioUnlock.ts index 8964e6b..30c2b28 100644 --- a/src/utils/audioUnlock.ts +++ b/src/utils/audioUnlock.ts @@ -60,17 +60,14 @@ async function doUnlock(): Promise { return ctx; } - // Try to resume + // CRITICAL for iOS: Call resume() synchronously in user gesture context. + // Don't await - just fire it off immediately to register the user intent. if (ctx.state === 'suspended') { - try { - await ctx.resume(); - } catch (e) { - console.warn('AudioContext.resume() failed:', e); - } + ctx.resume().catch(() => {}); } - // Play a silent buffer to fully unlock on older iOS - // This is a no-op on modern browsers but essential for iOS < 14 + // Play a silent buffer IMMEDIATELY to fully unlock on iOS. + // This must happen synchronously in the user gesture. try { const buffer = ctx.createBuffer(1, 1, ctx.sampleRate); const source = ctx.createBufferSource(); @@ -82,6 +79,15 @@ async function doUnlock(): Promise { // Ignore - this is just a fallback unlock } + // Now try resume again and wait for it + if (ctx.state === 'suspended') { + try { + await ctx.resume(); + } catch (e) { + console.warn('AudioContext.resume() failed:', e); + } + } + // Wait briefly for state to update (cast to string to avoid TS narrowing issues) const getState = () => ctx.state as string;