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
+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';
}