Add shared iOS audio unlock helper.
This commit is contained in:
@@ -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<void> {
|
||||
// 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,9 +102,9 @@ 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);
|
||||
|
||||
@@ -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<AudioContext> | 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<AudioContext> {
|
||||
// Return existing unlock in progress
|
||||
if (unlockPromise) {
|
||||
return unlockPromise;
|
||||
}
|
||||
|
||||
unlockPromise = doUnlock();
|
||||
return unlockPromise;
|
||||
}
|
||||
|
||||
async function doUnlock(): Promise<AudioContext> {
|
||||
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<void>((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';
|
||||
}
|
||||
Reference in New Issue
Block a user