Add shared iOS audio unlock helper.

This commit is contained in:
b1rdmania
2025-12-18 23:21:48 +00:00
parent 9eb40ce614
commit bfb96c65b5
2 changed files with 110 additions and 5 deletions
+5 -4
View File
@@ -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);
+104
View File
@@ -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';
}