diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index 8c87e3f..357f1e2 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -4,6 +4,7 @@ import { MIDIParser } from '../midi/MIDIParser'; import { MIDIService } from '../services/MIDIService'; import { RoleMapper } from './RoleMapper'; import { SynthesisEngine } from '../synthesis/SynthesisEngine'; +import { unlockAudio } from '../utils/audioUnlock'; export class MotifEngine { private audioContext: AudioContext | null = null; @@ -27,9 +28,9 @@ export class MotifEngine { } async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise { - // Initialize audio context and synthesis engine + // Initialize audio context using shared unlock (iOS compatibility) if (!this.audioContext) { - this.audioContext = new AudioContext(); + this.audioContext = await unlockAudio(); } this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); @@ -101,11 +102,11 @@ export class MotifEngine { const features = this.midiProcessor.extractFeatures(events); const roleAssignments = this.roleMapper.assignRoles(features, events); - // Initialize audio context and synthesis engine + // Initialize audio context using shared unlock (iOS compatibility) if (!this.audioContext) { - this.audioContext = new AudioContext(); + this.audioContext = await unlockAudio(); } - + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); this.synthesisEngine.setupLayers(roleAssignments); } diff --git a/src/utils/audioUnlock.ts b/src/utils/audioUnlock.ts new file mode 100644 index 0000000..ac6a849 --- /dev/null +++ b/src/utils/audioUnlock.ts @@ -0,0 +1,104 @@ +/** + * iOS/Safari audio unlock utility. + * + * Older iOS versions require: + * 1. AudioContext created during a user gesture + * 2. A silent buffer played to fully "unlock" the audio system + * 3. Verification that the context is actually running + * + * This utility handles all of that in a backward-compatible way. + */ + +let sharedAudioContext: AudioContext | null = null; +let unlockPromise: Promise | null = null; + +/** + * Get or create the shared AudioContext. + * Call this during a user gesture (click/touch) for best iOS compatibility. + */ +export function getAudioContext(): AudioContext { + if (!sharedAudioContext) { + sharedAudioContext = new AudioContext(); + } + return sharedAudioContext; +} + +/** + * Unlock audio for iOS/Safari. Safe to call multiple times. + * Returns the AudioContext once it's confirmed running. + * + * Must be called from a user gesture (click/touchend). + */ +export async function unlockAudio(): Promise { + // Return existing unlock in progress + if (unlockPromise) { + return unlockPromise; + } + + unlockPromise = doUnlock(); + return unlockPromise; +} + +async function doUnlock(): Promise { + const ctx = getAudioContext(); + + // Already running - nothing to do + if (ctx.state === 'running') { + return ctx; + } + + // Try to resume + if (ctx.state === 'suspended') { + try { + await ctx.resume(); + } catch (e) { + console.warn('AudioContext.resume() failed:', e); + } + } + + // Play a silent buffer to fully unlock on older iOS + // This is a no-op on modern browsers but essential for iOS < 14 + try { + const buffer = ctx.createBuffer(1, 1, ctx.sampleRate); + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(ctx.destination); + source.start(0); + source.stop(0.001); + } catch (e) { + // Ignore - this is just a fallback unlock + } + + // Wait briefly for state to update (cast to string to avoid TS narrowing issues) + const getState = () => ctx.state as string; + + if (getState() !== 'running') { + await new Promise((resolve) => { + const checkState = () => { + if (getState() === 'running') { + resolve(); + } else { + // Try one more resume + ctx.resume().catch(() => {}); + setTimeout(checkState, 50); + } + }; + // Give up after 500ms to avoid blocking forever + setTimeout(resolve, 500); + checkState(); + }); + } + + if (getState() !== 'running') { + console.warn('AudioContext still not running after unlock attempt, state:', ctx.state); + } + + return ctx; +} + +/** + * Check if audio is currently unlocked and ready. + */ +export function isAudioReady(): boolean { + return sharedAudioContext?.state === 'running'; +}