Back to Outbreak: Soycoon City
SOURCE / PINNED RELEASE

Made of little things.

Outbreak: Soycoon City

Release
db7750b1d009…
Author-recorded commit
3ee3f5fd27a6…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9

Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/audio.ts
import type { GameSound } from './sound-cues.js';
import type { Point } from './world.js';
export type AudioClip = { id: string; url: string; loop: boolean; gainDb: number; maxVoices: number; fadeMs: number };
type Decoded = AudioClip & { buffer: AudioBuffer };
type Voice = { id: string; source: AudioBufferSourceNode; gain: GainNode; pan?: StereoPannerNode; loop: boolean };
type Listener = Point & { yaw: number; room: string };

/** Inlined bytes + native Web Audio. No network, media session or shell grant. */
export class GameAudio {
  private context?: AudioContext;
  private master?: GainNode;
  private compressor?: DynamicsCompressorNode;
  private buffers = new Map<string, Decoded[]>();
  private voices = new Set<Voice>();
  private nextVariant = new Map<string, number>();
  private pending: { cue: GameSound; listener: Listener; at: number }[] = [];
  private loading?: Promise<void>;
  private playing = false;
  private finished = false;
  private room = '';
  private muted = false;
  private closed = false;
  private error = '';
  private lastStatus = '';
  constructor(private clips: AudioClip[], private changed: () => void = () => {}, private makeContext: () => AudioContext = () => new AudioContext()) {}
  get label(): string {
    if (this.muted) return 'Sound off';
    if (this.error) return 'Retry sound';
    if (!this.context || this.context.state !== 'running') return 'Enable sound';
    return this.loading ? 'Loading sound' : 'Sound on';
  }
  get enabled(): boolean { return !this.muted && this.context?.state === 'running' && !this.error; }
  get detail(): string {
    if (this.muted) return 'Sound muted. Click Sound off to unmute.';
    return this.error || (this.enabled ? 'Sound enabled. Pause and editor mode stop playback.' : 'Click Begin patrol or Enable sound to allow browser audio.');
  }
  private report(): void { const status = `${this.label}|${this.detail}`; if (status !== this.lastStatus) { this.lastStatus = status; this.changed(); } }
  /** Call inside the user's click/key/touch handler, before any awaited work. */
  activate(): void {
    if (this.closed || this.muted) return;
    try {
      if (!this.context) {
        this.context = this.makeContext();
        this.master = this.context.createGain(); this.master.gain.value = .85;
        this.compressor = this.context.createDynamicsCompressor();
        this.compressor.threshold.value = -6; this.compressor.ratio.value = 4;
        this.master.connect(this.compressor); this.compressor.connect(this.context.destination);
        this.context.onstatechange = () => { if (this.context?.state !== 'running') this.stopVoices(); this.report(); };
      }
      this.error = '';
      // Resume immediately in the gesture; decoding need not wait for permission.
      void this.context.resume().then(() => this.report()).catch(error => this.fail(error));
      if (!this.loading && this.buffers.size === 0) {
        const context = this.context;
        this.loading = (async () => {
          const decoded = await Promise.all(this.clips.map(async clip => {
            const match = /^data:audio\/(?:mpeg|mp3);base64,([A-Za-z0-9+/=]+)$/.exec(clip.url);
            if (!match) throw new Error('Sound bytes are missing from the build.');
            const bytes = Uint8Array.from(atob(match[1]), c => c.charCodeAt(0));
            return { ...clip, buffer: await context.decodeAudioData(bytes.buffer) };
          }));
          if (this.closed) return;
          for (const clip of decoded) { const group = this.buffers.get(clip.id) ?? []; group.push(clip); this.buffers.set(clip.id, group); }
          this.ensureLoops();
          for (const { cue, listener, at } of this.pending.splice(0)) if (Date.now() - at < 1200) this.play(cue, listener);
        })().catch(error => this.fail(error)).finally(() => { this.loading = undefined; this.report(); });
      }
    } catch (error) { this.fail(error); }
    this.report();
  }
  private fail(error: unknown): void { if (this.closed) return; this.error = `Sound unavailable: ${error instanceof Error ? error.message : String(error)}. Click Retry sound.`; this.stopVoices(); this.report(); }
  toggle(): void {
    if (this.error || !this.context || this.context.state !== 'running') { this.muted = false; this.activate(); return; }
    this.muted = !this.muted;
    if (this.muted) { this.pending = []; this.stopVoices(); } else { this.activate(); this.ensureLoops(); }
    this.report();
  }
  sync(playing: boolean, room: string, finished = false): void {
    this.playing = playing; this.finished = finished;
    if (this.room !== room) { for (const voice of [...this.voices]) if (voice.id.startsWith('ambient_')) this.stopVoice(voice); this.room = room; }
    if (!playing || this.muted || this.context?.state !== 'running') { this.pending = []; this.stopVoices(); }
    else if (finished) { for (const voice of [...this.voices]) if (voice.loop) this.stopVoice(voice); }
    else this.ensureLoops();
    this.report();
  }
  stop(): void { this.playing = false; this.pending = []; this.stopVoices(); }
  private ensureLoops(): void {
    if (!this.playing || this.finished || this.muted || this.error || this.context?.state !== 'running') return;
    const wanted = ['music_patrol', ...(this.room === 'corridor' ? ['ambient_corridor'] : [])];
    for (const id of wanted) if (![...this.voices].some(v => v.id === id)) { const clip = this.buffers.get(id)?.[0]; if (clip) this.startVoice(clip, 1, 0); }
  }
  play(cue: GameSound, listener: Listener): void {
    if (!this.playing || this.muted || this.error || this.closed || (cue.room && cue.room !== listener.room)) return;
    if (!this.buffers.size && this.loading) { this.pending.push({ cue, listener: { ...listener }, at: Date.now() }); if (this.pending.length > 16) this.pending.shift(); return; }
    if (this.context?.state !== 'running') return;
    const variants = this.buffers.get(cue.id); if (!variants?.length) return;
    const next = this.nextVariant.get(cue.id) ?? 0, clip = variants[next % variants.length]; this.nextVariant.set(cue.id, next + 1);
    let gain = 1, pan = 0;
    if (cue.position) {
      const dx = cue.position.x - listener.x, dz = cue.position.z - listener.z;
      gain = Math.max(0, 1 - Math.hypot(dx, dz) / 10);
      pan = Math.max(-1, Math.min(1, (dx * Math.cos(listener.yaw) - dz * Math.sin(listener.yaw)) / 6));
    }
    if (gain > 0) this.startVoice(clip, gain, pan, cue.delaySeconds ?? 0);
  }
  private startVoice(clip: Decoded, volume: number, stereo: number, delaySeconds = 0): void {
    const context = this.context!, same = [...this.voices].filter(v => v.id === clip.id);
    if (same.length >= clip.maxVoices) this.stopVoice(same[0]);
    if (this.voices.size >= 16) { const oldest = [...this.voices].find(v => !v.loop); if (oldest) this.stopVoice(oldest); }
    const source = context.createBufferSource(), gain = context.createGain();
    source.buffer = clip.buffer; source.loop = clip.loop;
    const pan = context.createStereoPanner(); pan.pan.value = stereo;
    source.connect(gain); gain.connect(pan); pan.connect(this.master!);
    const voice = { id: clip.id, source, gain, pan, loop: clip.loop }; this.voices.add(voice);
    const now = context.currentTime + Math.max(0, delaySeconds), level = volume * 10 ** (clip.gainDb / 20), fade = Math.min(clip.fadeMs / 1000, clip.buffer.duration / 4);
    gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(level, now + fade);
    if (!clip.loop) { gain.gain.setValueAtTime(level, now + clip.buffer.duration - fade); gain.gain.linearRampToValueAtTime(0, now + clip.buffer.duration); }
    source.onended = () => this.release(voice); source.start(now);
  }
  private release(voice: Voice): void { this.voices.delete(voice); voice.source.onended = null; voice.source.disconnect(); voice.gain.disconnect(); voice.pan?.disconnect(); }
  private stopVoice(voice: Voice): void { try { voice.source.stop(); } catch { /* Already ended. */ } this.release(voice); }
  private stopVoices(): void { for (const voice of [...this.voices]) this.stopVoice(voice); }
  dispose(): void { this.closed = true; this.stop(); this.buffers.clear(); if (this.context) { this.context.onstatechange = null; void this.context.close().catch(() => {}); } this.master?.disconnect(); this.compressor?.disconnect(); }
}