From 5bff81954caba5940779455e8e147e23df2cfd4f Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Sat, 20 Dec 2025 13:14:08 +0000 Subject: [PATCH] Fix iOS audio unlock retry and add API fetch retry. --- src/main.ts | 6 ++---- src/services/MIDIService.ts | 42 +++++++++++++++++++++++++------------ src/utils/audioUnlock.ts | 23 +++++++++++++++----- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/main.ts b/src/main.ts index aa86bd5..6ef50cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { MotifEngine } from './core/MotifEngine'; import { MIDIService } from './services/MIDIService'; import { MIDIParser } from './midi/MIDIParser'; import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; -import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock'; +import { getAudioContext, isAudioReady, peekAudioContext, unlockAudio } from './utils/audioUnlock'; import type { NoteEvent } from './types'; class MotifApp { @@ -209,9 +209,7 @@ class MotifApp { this.iosAudioBanner.style.display = ready ? 'none' : 'block'; // Optional tiny state readout (helps support debugging) - const ctx = (() => { - try { return getAudioContext(); } catch { return null; } - })(); + const ctx = peekAudioContext(); if (!ready && ctx) { this.iosAudioState.style.display = 'block'; this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`; diff --git a/src/services/MIDIService.ts b/src/services/MIDIService.ts index 1a325a7..b9cdecf 100644 --- a/src/services/MIDIService.ts +++ b/src/services/MIDIService.ts @@ -51,25 +51,41 @@ export class MIDIService { } } - async search(query: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/midi/search?q=${encodeURIComponent(query)}`); - - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); + private async fetchWithRetry(url: string, init?: RequestInit, timeoutMs = 20000): Promise { + const attempt = async (): Promise => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + try { + // iOS Safari can behave oddly with cached API responses; force no-store. + return await fetch(url, { + ...init, + cache: 'no-store', + signal: controller.signal, + }); + } finally { + window.clearTimeout(timeout); } + }; - const data: MIDISearchResponse = await response.json(); - return data.results; - } catch (error) { - console.error('MIDI search error:', error); - return []; + try { + return await attempt(); + } catch (e) { + // One fast retry for transient iOS/network hiccups. + await new Promise(r => window.setTimeout(r, 200)); + return await attempt(); } } + async search(query: string): Promise { + const response = await this.fetchWithRetry(`${this.baseUrl}/api/midi/search?q=${encodeURIComponent(query)}`); + if (!response.ok) throw new Error(`Search failed: ${response.status}`); + const data: MIDISearchResponse = await response.json(); + return data.results; + } + async fetchMIDI(url: string): Promise { try { - const response = await fetch(`${this.baseUrl}/api/midi/fetch?u=${encodeURIComponent(url)}`); + const response = await this.fetchWithRetry(`${this.baseUrl}/api/midi/fetch?u=${encodeURIComponent(url)}`); if (!response.ok) { throw new Error(`Fetch failed: ${response.status}`); @@ -84,7 +100,7 @@ export class MIDIService { async parseMIDI(url: string): Promise { try { - const response = await fetch(`${this.baseUrl}/api/midi/parse?u=${encodeURIComponent(url)}`); + const response = await this.fetchWithRetry(`${this.baseUrl}/api/midi/parse?u=${encodeURIComponent(url)}`); if (!response.ok) { throw new Error(`Parse failed: ${response.status}`); diff --git a/src/utils/audioUnlock.ts b/src/utils/audioUnlock.ts index ac6a849..8964e6b 100644 --- a/src/utils/audioUnlock.ts +++ b/src/utils/audioUnlock.ts @@ -23,6 +23,14 @@ export function getAudioContext(): AudioContext { return sharedAudioContext; } +/** + * Return the existing AudioContext without creating one. + * Useful for UI/debug without triggering iOS restrictions. + */ +export function peekAudioContext(): AudioContext | null { + return sharedAudioContext; +} + /** * Unlock audio for iOS/Safari. Safe to call multiple times. * Returns the AudioContext once it's confirmed running. @@ -30,12 +38,17 @@ export function getAudioContext(): AudioContext { * Must be called from a user gesture (click/touchend). */ export async function unlockAudio(): Promise { - // Return existing unlock in progress - if (unlockPromise) { - return unlockPromise; - } + const ctx = sharedAudioContext; + if (ctx?.state === 'running') return ctx; - unlockPromise = doUnlock(); + // IMPORTANT: allow retries. If a previous unlock attempt ran outside a user gesture + // or failed to transition the context to running, iOS can remain blocked. + // We only de-dupe concurrent calls; once an attempt finishes, we clear the promise. + if (unlockPromise) return unlockPromise; + + unlockPromise = doUnlock().finally(() => { + unlockPromise = null; + }); return unlockPromise; }