A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s

Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
This commit is contained in:
admin committed 2026-09-15 07:54:28 +02:00
1 parent 7babf88636
commit 5671a38044
47 files changed
+2066 -42

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
import type { AudioSink } from '../runtime/systems/Audio';
const DEFAULT_BASE = '/game-maker/data/sounds';
/** One sound in flight: its source, its own gain, and the file it came from. */
interface Voice {
file: 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<string, AudioBuffer>();
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
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<string, number>();
private allStops = 0;
/** 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): void {
if (file === '' || this.failed.has(file)) return;
const buffer = this.buffers.get(file);
if (buffer) {
this.start(file, buffer, volume, loop);
return;
}
const askedAt = this.stopsFor(file);
void this.load(file).then((loaded) => {
// A `Stop sound` that happened while the bytes were in flight has to win, or a sound the
// graph already silenced would start afterwards and play on its own.
if (loaded && this.stopsFor(file) === askedAt) this.start(file, loaded, volume, loop);
});
}
stop(file: string): void {
const going = file === '' ? this.voices : this.voices.filter((voice) => voice.file === file);
for (const voice of going) voice.source.stop();
// `onended` removes each one, but not before the next frame – the count has to be right now.
this.voices = this.voices.filter((voice) => !going.includes(voice));
this.stopped += going.length;
if (file === '') this.allStops += 1;
else this.stops.set(file, (this.stops.get(file) ?? 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<AudioBuffer | null> {
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;
}
private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean): 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, 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());
}
/** Stops this file has seen, its share of the "everything" ones included. */
private stopsFor(file: string): number {
return (this.stops.get(file) ?? 0) + this.allStops;
}
}
export const soundLibrary = new SoundLibrary();