import type { AudioSink } from '../runtime/systems/Audio'; const DEFAULT_BASE = '/game-maker/data/sounds'; /** One sound in flight: its source, its own gain, the file it came from and who is making it. */ interface Voice { file: string; /** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */ owner: string; source: AudioBufferSourceNode; gain: GainNode; } /** * Plays uploaded sounds, keyed by file name – the texture library's twin on the audio side. Files * are content addressed, so a decoded buffer can never go stale and nothing is evicted. * * Everything here is asynchronous on the way in and synchronous on the way out: `play` returns at * once and starts the voice when the bytes have been fetched and decoded. A sound fired in the * first frame of a run is therefore heard a moment later rather than not at all, and a graph never * waits on the network. * * The context is created on the first play, not at start-up: a browser refuses one before the page * has been interacted with, and an editor that was only ever looked at should not be holding an * audio device open. */ export class SoundLibrary implements AudioSink { private base = DEFAULT_BASE; private context: AudioContext | null = null; private readonly buffers = new Map(); private readonly loading = new Map>(); /** Never retried, so one broken reference cannot spam the network. */ private readonly failed = new Set(); private voices: Voice[] = []; private started = 0; private stopped = 0; /** The last voice actually started, with the values it was started at – what a check reads back. */ private last: { file: string; volume: number; loop: boolean } | null = null; /** * How often a stop has reached each file, and how often one has silenced everything. Counters * rather than a "cancelled" flag: what a play has to know is whether a stop happened *after it * was asked for*, and a flag cannot tell that from one that happened long before – which would * make the first play of a sound disappear whenever anything had ever stopped it. */ private readonly stops = new Map(); private allStops = 0; /** The same counter per owner, for the same reason – a behavior destroyed while bytes were in * flight must not be given its voice afterwards. */ private readonly ownerStops = new Map(); /** Where the files are served from. Set once at start-up, before anything plays. */ configure(base: string): void { this.base = base.replace(/\/+$/, ''); } play(file: string, volume: number, loop: boolean, owner = ''): void { if (file === '' || this.failed.has(file)) return; const buffer = this.buffers.get(file); if (buffer) { this.start(file, buffer, volume, loop, owner); return; } const askedAt = this.cancelsFor(file, owner); void this.load(file).then((loaded) => { // A `Stop sound` – or the destruction of the behavior that asked – that happened while the // bytes were in flight has to win, or a sound the run already ended would start afterwards. if (loaded && this.cancelsFor(file, owner) === askedAt) this.start(file, loaded, volume, loop, owner); }); } stop(file: string): void { this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file)); if (file === '') this.allStops += 1; else this.stops.set(file, (this.stops.get(file) ?? 0) + 1); } /** * Every voice of one behavior. An empty owner is deliberately no one: those are the voices that * were started to outlive their behavior, so "stop the owner" must not reach them. */ stopOwner(owner: string): void { if (owner === '') return; this.silence(this.voices.filter((voice) => voice.owner === owner)); this.ownerStops.set(owner, (this.ownerStops.get(owner) ?? 0) + 1); } /** Every voice at once: what ending a run does, so nothing outlives the world that started it. */ stopAll(): void { this.stop(''); } /** A pause holds the sounds where they are; resuming carries them on mid-note. */ setSuspended(suspended: boolean): void { const context = this.context; if (!context) return; if (suspended) void context.suspend(); else void context.resume(); } /** What has been played and what is playing – the only witness a muted check script has. */ stats(): { playing: number; started: number; stopped: number; decoded: number; failed: number; last: { file: string; volume: number; loop: boolean } | null; } { return { playing: this.voices.length, started: this.started, stopped: this.stopped, decoded: this.buffers.size, failed: this.failed.size, last: this.last, }; } /** How long a decoded sound runs, in seconds; null while it has not been loaded (test hook). */ duration(file: string): number | null { return this.buffers.get(file)?.duration ?? null; } /** Fetches and decodes once per file; a second play while it is in flight waits on the first. */ async load(file: string): Promise { const held = this.buffers.get(file); if (held) return held; if (this.failed.has(file)) return null; const running = this.loading.get(file); if (running) return running; const work = (async () => { try { const response = await fetch(`${this.base}/${file}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const bytes = await response.arrayBuffer(); const buffer = await this.audio().decodeAudioData(bytes); this.buffers.set(file, buffer); return buffer; } catch (error: unknown) { this.failed.add(file); console.error(`[sounds] cannot load ${file}`, error); return null; } finally { this.loading.delete(file); } })(); this.loading.set(file, work); return work; } /** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */ private silence(going: Voice[]): void { for (const voice of going) voice.source.stop(); this.voices = this.voices.filter((voice) => !going.includes(voice)); this.stopped += going.length; } private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean, owner: string): void { const context = this.audio(); // A context starts suspended where the page has not been interacted with; without this the // voice would run silently and end as if it had played. if (context.state === 'suspended') void context.resume(); const source = context.createBufferSource(); source.buffer = buffer; source.loop = loop; const gain = context.createGain(); gain.gain.value = Math.max(0, Math.min(1, volume)); this.last = { file, volume: gain.gain.value, loop }; source.connect(gain).connect(context.destination); const voice: Voice = { file, owner, source, gain }; source.onended = () => { this.voices = this.voices.filter((other) => other !== voice); }; this.voices.push(voice); this.started += 1; source.start(); } private audio(): AudioContext { return (this.context ??= new AudioContext()); } /** Everything that would have silenced this file for this owner, counted rather than flagged. */ private cancelsFor(file: string, owner: string): number { return (this.stops.get(file) ?? 0) + this.allStops + (this.ownerStops.get(owner) ?? 0); } } export const soundLibrary = new SoundLibrary();