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 { MIDIService } from '../services/MIDIService';
|
||||||
import { RoleMapper } from './RoleMapper';
|
import { RoleMapper } from './RoleMapper';
|
||||||
import { SynthesisEngine } from '../synthesis/SynthesisEngine';
|
import { SynthesisEngine } from '../synthesis/SynthesisEngine';
|
||||||
|
import { unlockAudio } from '../utils/audioUnlock';
|
||||||
|
|
||||||
export class MotifEngine {
|
export class MotifEngine {
|
||||||
private audioContext: AudioContext | null = null;
|
private audioContext: AudioContext | null = null;
|
||||||
@@ -27,9 +28,9 @@ export class MotifEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise<void> {
|
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) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = await unlockAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
||||||
@@ -101,11 +102,11 @@ export class MotifEngine {
|
|||||||
const features = this.midiProcessor.extractFeatures(events);
|
const features = this.midiProcessor.extractFeatures(events);
|
||||||
const roleAssignments = this.roleMapper.assignRoles(features, 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) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = await unlockAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
||||||
this.synthesisEngine.setupLayers(roleAssignments);
|
this.synthesisEngine.setupLayers(roleAssignments);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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