Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/__tests__/sound/audio-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, describe, test, jest } from '@jest/globals';
import {
GESTURE_EVENTS,
resolveAudioContextCtor,
unlockAudioContextOnGesture,
} from '../../sound/audio-context';

const flush = () => new Promise((resolve) => setTimeout(resolve, 0));

function makeTarget() {
const listeners = new Map<string, Set<() => void>>();

return {
addEventListener(type: string, listener: () => void) {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}

listeners.get(type).add(listener);
},
removeEventListener(type: string, listener: () => void) {
listeners.get(type)?.delete(listener);
},
fire(type: string) {
[...(listeners.get(type) ?? [])].forEach((listener) => listener());
},
count() {
return [...listeners.values()].reduce((total, set) => total + set.size, 0);
},
};
}

describe('resolveAudioContextCtor', () => {
test('prefers the unprefixed constructor', () => {
const AudioContextCtor = function () {} as unknown as typeof AudioContext;
const webkitAudioContext = function () {} as unknown as typeof AudioContext;

expect(resolveAudioContextCtor({ AudioContext: AudioContextCtor, webkitAudioContext })).toBe(
AudioContextCtor,
);
});

test('falls back to the webkit constructor without touching a missing global', () => {
// Safari builds that only ship webkitAudioContext have no AudioContext
// binding at all, so this must resolve by property lookup and not throw.
const webkitAudioContext = function () {} as unknown as typeof AudioContext;

expect(resolveAudioContextCtor({ webkitAudioContext })).toBe(webkitAudioContext);
});

test('returns null when neither constructor exists', () => {
expect(resolveAudioContextCtor({})).toBeNull();
});
});

describe('unlockAudioContextOnGesture', () => {
test('does not listen when the context is already running', () => {
const target = makeTarget();
const resume = jest.fn(() => Promise.resolve());

unlockAudioContextOnGesture({ state: 'running', resume }, target);

expect(target.count()).toBe(0);
expect(resume).not.toHaveBeenCalled();
});

test('resumes on the first gesture and then stops listening', async () => {
const target = makeTarget();
const resume = jest.fn(() => Promise.resolve());

unlockAudioContextOnGesture({ state: 'suspended', resume }, target);
expect(target.count()).toBe(GESTURE_EVENTS.length);

target.fire('pointerdown');
expect(resume).toHaveBeenCalledTimes(1);

await flush();
expect(target.count()).toBe(0);
});

test('stays armed when the resume is still refused', async () => {
const target = makeTarget();
const resume = jest.fn(() => Promise.reject(new Error('blocked')));

unlockAudioContextOnGesture({ state: 'suspended', resume }, target);

target.fire('pointerdown');
await flush();

expect(target.count()).toBe(GESTURE_EVENTS.length);

target.fire('keydown');
expect(resume).toHaveBeenCalledTimes(2);
});

test('the returned canceller removes every listener', () => {
const target = makeTarget();
const resume = jest.fn(() => Promise.resolve());

const cancel = unlockAudioContextOnGesture({ state: 'suspended', resume }, target);
expect(target.count()).toBe(GESTURE_EVENTS.length);

cancel();
expect(target.count()).toBe(0);
});
});
73 changes: 73 additions & 0 deletions src/sound/audio-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* WebKit - every browser on iOS, plus desktop Safari - hands back an
* AudioContext in the "suspended" state unless it was constructed inside a user
* gesture, and it only leaves that state when something calls resume() from
* inside one. SoundSys builds its context while the page is still loading, so
* without an explicit unlock every effect stays silent for the whole session on
* those browsers.
*/

type AudioContextCtor = typeof AudioContext;

type AudioContextWindow = {
AudioContext?: AudioContextCtor;
webkitAudioContext?: AudioContextCtor;
};

type ResumableContext = Pick<AudioContext, 'state' | 'resume'>;

type GestureTarget = {
addEventListener(type: string, listener: () => void): void;
removeEventListener(type: string, listener: () => void): void;
};

/** The interactions WebKit accepts as a gesture for unlocking audio. */
export const GESTURE_EVENTS = ['pointerdown', 'touchend', 'keydown'];

/**
* Resolves the AudioContext constructor from a window object.
*
* Reading it as a property matters. Safari builds that only ship
* `webkitAudioContext` have no global binding named `AudioContext` at all, so an
* unqualified reference throws a ReferenceError instead of evaluating to
* undefined and falling through to the prefixed constructor.
*/
export function resolveAudioContextCtor(win?: AudioContextWindow): AudioContextCtor | null {
const target = win ?? (typeof window === 'undefined' ? undefined : window);

if (!target) {
return null;
}

return target.AudioContext ?? target.webkitAudioContext ?? null;
}

/**
* Resumes a suspended AudioContext on the first user gesture, then stops
* listening. If the resume is still refused the listeners stay armed for the
* next gesture. Returns a function that cancels the pending unlock.
*/
export function unlockAudioContextOnGesture(
context: ResumableContext,
target?: GestureTarget,
): () => void {
const listenTarget = target ?? (typeof document === 'undefined' ? undefined : document);

if (!listenTarget || context.state === 'running') {
return () => undefined;
}

function stop() {
GESTURE_EVENTS.forEach((event) => listenTarget.removeEventListener(event, unlock));
}

function unlock() {
Promise.resolve(context.resume()).then(stop, () => {
// Still refused - leave the listeners armed for the next gesture.
});
}

GESTURE_EVENTS.forEach((event) => listenTarget.addEventListener(event, unlock));

return stop;
}
41 changes: 41 additions & 0 deletions src/sound/musicplayer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as $j from 'jquery';
import skin from './skin';
import beastAudioFile from 'assets/sounds/AncientBeast.ogg';
import { GESTURE_EVENTS } from './audio-context';

export class MusicPlayer {
constructor() {
Expand All @@ -9,6 +10,7 @@ export class MusicPlayer {
this.tracks = this.playlist.find('li.epic');

this.repeat = true;
this._gestureRetry = null;

this.audio.volume = 0.25;
this.audio.pause();
Expand Down Expand Up @@ -161,15 +163,54 @@ export class MusicPlayer {
}

// Play audio
this.cancelGestureRetry();
this.audio.play().catch((error) => {
if (error?.name === 'AbortError') {
return;
}
if (error?.name === 'NotAllowedError') {
// Safari and iOS refuse playback that didn't start from a user
// gesture. Retry on the next one instead of staying silent.
this.playOnNextGesture();
return;
}
console.error('Error playing audio:', error);
});
}

/**
* Arms a one-shot retry so a track blocked by the autoplay policy starts
* on the next user interaction.
*/
playOnNextGesture() {
if (this._gestureRetry) {
return;
}

const retry = () => {
this.audio.play().then(
() => this.cancelGestureRetry(),
() => {
// Still refused - stay armed for the next gesture.
},
);
};

this._gestureRetry = retry;
GESTURE_EVENTS.forEach((event) => document.addEventListener(event, retry));
}

cancelGestureRetry() {
if (!this._gestureRetry) {
return;
}

GESTURE_EVENTS.forEach((event) => document.removeEventListener(event, this._gestureRetry));
this._gestureRetry = null;
}

stopMusic() {
this.cancelGestureRetry();
this.audio.pause();
}
}
13 changes: 9 additions & 4 deletions src/sound/soundsys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BufferLoader } from './bufferloader';
import { getUrl } from '../assets';
import { MusicPlayer } from './musicplayer';
import { clamp } from '../utility/math';
import { resolveAudioContextCtor, unlockAudioContextOnGesture } from './audio-context';

export type AudioMode = 'full' | 'sfx' | 'muted';
let currentAudioMode: AudioMode = 'full';
Expand All @@ -21,7 +22,7 @@ type SoundSysConfig = {
export class SoundSys {
musicPlayer: MusicPlayer;

private envHasSound = window && ('AudioContext' in window || 'webkitAudioContext' in window);
private envHasSound = resolveAudioContextCtor() !== null;
private context: AudioContext;
private loadedPaths: Record<string, AudioBuffer> = {};

Expand All @@ -39,9 +40,13 @@ export class SoundSys {
constructor(config: SoundSysConfig) {
this.musicPlayer = new MusicPlayer();

if (this.envHasSound) {
this.context = new (AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const AudioContextCtor = resolveAudioContextCtor();

if (this.envHasSound && AudioContextCtor) {
this.context = new AudioContextCtor();
// WebKit suspends a context that wasn't created inside a user gesture,
// so resume it on the first interaction or every effect stays silent.
unlockAudioContextOnGesture(this.context);
this.musicGainNode = this.context.createGain();
this.musicGainNode.connect(this.context.destination);
if ('musicVolume' in config) {
Expand Down