SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Port of BrowserQuest client/js/audio.js (MPL-2.0).
// Music keeps the original <audio> elements (long looping tracks, area fades).
// Sound effects play through the Web Audio API instead of the original 4-channel
// <audio> pools: starting an <audio> element costs 1-146 ms (measured, usually
// ~90 ms) in today's browsers, as long as the short hit sounds themselves, so
// hits came late and busy pools dropped them. Web Audio decodes each sound once
// and starts it immediately from one persistent output. The <audio> pools remain
// as a fallback where Web Audio is unavailable. The original music was never
// published with the source; the area tracks come from the community fork
// (CC BY 3.0, see assets.ts).
import { music, sounds } from './assets.js';
import { Area } from './timing.js';
interface Music {
sound: HTMLAudioElement | null;
name: string;
}
type FadingAudio = HTMLAudioElement & { fadingOut?: number | null; fadingIn?: number | null };
const SOUND_NAMES = [
'loot', 'hit1', 'hit2', 'hurt', 'heal', 'chat', 'revive', 'death', 'firefox',
'achievement', 'kill1', 'kill2', 'noloot', 'teleport', 'chest', 'npc', 'npc-end',
];
/** Concurrent copies of one sound, as the original's 4 channels. */
const VOICES_PER_SOUND = 4;
/** Leading near-silence trimmed at decode (WebKit keeps the MP3 encoder padding). */
const SILENCE_THRESHOLD = 1e-3;
const MAX_TRIM_SECONDS = 0.1;
const UNLOCK_EVENTS = ['pointerdown', 'keydown', 'touchend'] as const;
type AudioContextCtor = typeof AudioContext;
function audioContextCtor(): AudioContextCtor | null {
const w = window as unknown as { AudioContext?: AudioContextCtor; webkitAudioContext?: AudioContextCtor };
return w.AudioContext ?? w.webkitAudioContext ?? null;
}
/** Bytes of a base64 data: URL, decoded in-process (the napplet CSP blocks fetch). */
function dataUrlBytes(url: string): Uint8Array | null {
const comma = url.indexOf(',');
if (!url.startsWith('data:') || comma < 0 || !url.slice(0, comma).endsWith(';base64')) return null;
const binary = atob(url.slice(comma + 1));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function leadingSilence(buffer: AudioBuffer): number {
const limit = Math.min(buffer.length, Math.floor(buffer.sampleRate * MAX_TRIM_SECONDS));
let first = limit;
for (let c = 0; c < buffer.numberOfChannels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < first; i++) {
if (Math.abs(data[i]) > SILENCE_THRESHOLD) {
first = i;
break;
}
}
}
return first >= limit ? 0 : first / buffer.sampleRate;
}
interface DecodedSound {
buffer: AudioBuffer;
startOffset: number;
}
/**
* Sound effects on one AudioContext. The host frame has no autoplay delegation,
* so the context is created and resumed from the first user gesture inside the
* napplet (the Play click), then every sound is decoded once.
*/
class SfxEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private readonly decoded = new Map<string, DecodedSound>();
private readonly voices = new Map<string, AudioBufferSourceNode[]>();
private listening = false;
private disposed = false;
constructor(
private readonly sources: Record<string, string>,
/** Called with each sound the engine cannot play, so the caller can fall back. */
private readonly onUnavailable: (name: string) => void,
) {
this.listen();
}
has(name: string): boolean {
return this.decoded.has(name);
}
play(name: string): void {
const ctx = this.ctx;
const sound = this.decoded.get(name);
if (!ctx || !this.master || !sound || ctx.state !== 'running') return;
const active = this.voices.get(name) ?? [];
// At the cap, steal the oldest voice rather than drop the new sound.
while (active.length >= VOICES_PER_SOUND) {
const oldest = active.shift()!;
oldest.onended = null;
try {
oldest.stop();
} catch {
// already stopped
}
}
const source = ctx.createBufferSource();
source.buffer = sound.buffer;
source.connect(this.master);
source.onended = () => {
const list = this.voices.get(name);
const index = list ? list.indexOf(source) : -1;
if (list && index >= 0) list.splice(index, 1);
source.disconnect();
};
active.push(source);
this.voices.set(name, active);
source.start(0, sound.startOffset);
}
stopAll(): void {
for (const list of this.voices.values()) {
for (const source of list) {
source.onended = null;
try {
source.stop();
} catch {
// already stopped
}
}
list.length = 0;
}
}
dispose(): void {
this.disposed = true;
this.unlisten();
this.stopAll();
void this.ctx?.close().catch(() => undefined);
this.ctx = null;
}
private readonly unlock = (): void => {
if (this.disposed) return;
if (!this.ctx && !this.create()) return;
const ctx = this.ctx!;
// resume() must be called inside the gesture (Safari).
void ctx.resume().catch(() => undefined).then(() => {
if (ctx.state === 'running') this.unlisten();
});
};
private create(): boolean {
const Ctor = audioContextCtor();
let ctx: AudioContext;
try {
if (!Ctor) throw new Error('no AudioContext');
ctx = new Ctor();
} catch {
this.unlisten();
for (const name of Object.keys(this.sources)) this.onUnavailable(name);
return false;
}
this.ctx = ctx;
this.master = ctx.createGain();
this.master.gain.value = 1;
this.master.connect(ctx.destination);
// Re-arm the gesture listeners if the context is suspended or interrupted
// later (tab switch, iOS interruptions).
ctx.onstatechange = () => {
if (ctx.state !== 'running' && ctx.state !== 'closed') this.listen();
};
void this.decodeAll(ctx);
return true;
}
private async decodeAll(ctx: AudioContext): Promise<void> {
await Promise.all(
Object.entries(this.sources).map(async ([name, url]) => {
try {
const bytes = dataUrlBytes(url);
if (!bytes) throw new Error('not an inline sound');
const buffer = await new Promise<AudioBuffer>((resolve, reject) => {
// Callback form too: older Safari has no promise-returning decodeAudioData.
const result = ctx.decodeAudioData(bytes.buffer as ArrayBuffer, resolve, reject);
if (result && typeof result.then === 'function') result.then(resolve, reject);
});
if (!this.disposed) this.decoded.set(name, { buffer, startOffset: leadingSilence(buffer) });
} catch {
if (!this.disposed) this.onUnavailable(name);
}
}),
);
}
private listen(): void {
if (this.listening || this.disposed) return;
this.listening = true;
for (const type of UNLOCK_EVENTS) window.addEventListener(type, this.unlock, true);
}
private unlisten(): void {
if (!this.listening) return;
this.listening = false;
for (const type of UNLOCK_EVENTS) window.removeEventListener(type, this.unlock, true);
}
}
export class AudioManager {
enabled = true;
private readonly sounds = new Map<string, HTMLAudioElement[]>();
private readonly areas: Area[] = [];
private currentMusic: Music | null = null;
private readonly sfx: SfxEngine | null;
constructor(private readonly getPlayer: () => { gridX: number; gridY: number } | null) {
const effects: Record<string, string> = {};
for (const name of SOUND_NAMES) if (sounds[name]) effects[name] = sounds[name];
if (audioContextCtor()) {
// <audio> pools are only built for sounds Web Audio cannot play.
this.sfx = new SfxEngine(effects, (name) => this.load(name, effects[name], VOICES_PER_SOUND));
} else {
this.sfx = null;
for (const [name, url] of Object.entries(effects)) this.load(name, url, VOICES_PER_SOUND);
}
for (const [name, url] of Object.entries(music)) this.registerMusic(name, url);
}
private load(name: string, url: string | undefined, channels: number, loop = false): void {
if (!url || this.sounds.has(name)) return;
const pool: HTMLAudioElement[] = [];
for (let i = 0; i < channels; i++) {
const sound = new Audio();
sound.preload = 'auto';
sound.loop = loop;
sound.src = url;
pool.push(sound);
}
this.sounds.set(name, pool);
}
/** Register a looping area track (e.g. 'village', 'cave'). */
registerMusic(name: string, url: string): void {
this.load(name, url, 1, true);
}
toggle(): void {
if (this.enabled) {
this.enabled = false;
this.sfx?.stopAll();
if (this.currentMusic) this.resetMusic(this.currentMusic);
} else {
this.enabled = true;
this.currentMusic = null;
this.updateMusic();
}
}
setEnabled(value: boolean): void {
if (value !== this.enabled) this.toggle();
}
private getSound(name: string): HTMLAudioElement | null {
const pool = this.sounds.get(name);
if (!pool) return null;
let sound = pool.find((s) => s.ended || s.paused);
if (sound && sound.ended) sound.currentTime = 0;
else if (!sound) sound = pool[0];
return sound;
}
playSound(name: string): void {
if (!this.enabled) return;
if (this.sfx?.has(name)) {
this.sfx.play(name);
return;
}
const sound = this.getSound(name);
if (sound) {
// Browsers reject play() before the first user gesture; that only mutes the sound.
sound.play().catch(() => undefined);
}
}
dispose(): void {
this.sfx?.dispose();
this.fadeOutCurrentMusic();
for (const pool of this.sounds.values()) for (const sound of pool) sound.pause();
}
addArea(x: number, y: number, width: number, height: number, musicName: string): void {
const area = new Area(x, y, width, height);
area.musicName = musicName;
this.areas.push(area);
}
getSurroundingMusic(entity: { gridX: number; gridY: number }): Music | null {
const area = this.areas.find((a) => a.contains(entity));
return area ? { sound: this.getSound(area.musicName), name: area.musicName } : null;
}
updateMusic(): void {
const player = this.getPlayer();
if (!this.enabled || !player) return;
const music = this.getSurroundingMusic(player);
if (music) {
if (!this.isCurrentMusic(music)) {
if (this.currentMusic) this.fadeOutCurrentMusic();
this.playMusic(music);
}
} else {
this.fadeOutCurrentMusic();
}
}
private isCurrentMusic(music: Music): boolean {
return !!this.currentMusic && music.name === this.currentMusic.name;
}
private playMusic(music: Music): void {
if (this.enabled && music.sound) {
const sound = music.sound as FadingAudio;
if (sound.fadingOut) {
this.fadeInMusic(music);
} else {
sound.volume = 1;
sound.play().catch(() => undefined);
}
this.currentMusic = music;
}
}
private resetMusic(music: Music): void {
if (music.sound && music.sound.readyState > 0) {
music.sound.pause();
music.sound.currentTime = 0;
}
}
private fadeOutMusic(music: Music, ended: (music: Music) => void): void {
const sound = music.sound as FadingAudio | null;
if (!sound || sound.fadingOut) return;
this.clearFadeIn(sound);
sound.fadingOut = window.setInterval(() => {
const step = 0.02;
const volume = sound.volume - step;
if (this.enabled && volume >= step) {
sound.volume = volume;
} else {
sound.volume = 0;
this.clearFadeOut(sound);
ended(music);
}
}, 50);
}
private fadeInMusic(music: Music): void {
const sound = music.sound as FadingAudio | null;
if (!sound || sound.fadingIn) return;
this.clearFadeOut(sound);
sound.fadingIn = window.setInterval(() => {
const step = 0.01;
const volume = sound.volume + step;
if (this.enabled && volume < 1 - step) {
sound.volume = volume;
} else {
sound.volume = 1;
this.clearFadeIn(sound);
}
}, 30);
}
private clearFadeOut(sound: FadingAudio): void {
if (sound.fadingOut) window.clearInterval(sound.fadingOut);
sound.fadingOut = null;
}
private clearFadeIn(sound: FadingAudio): void {
if (sound.fadingIn) window.clearInterval(sound.fadingIn);
sound.fadingIn = null;
}
fadeOutCurrentMusic(): void {
if (this.currentMusic) {
this.fadeOutMusic(this.currentMusic, (music) => this.resetMusic(music));
this.currentMusic = null;
}
}
}
