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
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:
1 parent
7babf88636
commit
5671a38044
47 files changed
+2066
-42
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user