Back to Browser Side Quest
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.

src/client/audio.ts
// 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;
    }
  }
}