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.
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(); }
}
