Files
feuerwerk/tools/lib/audio.mjs
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
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.
2026-09-15 07:54:28 +02:00

48 lines
1.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The seed's sounds, built here instead of shipped or taken from the system: unlike the DejaVu
* typefaces there is no audio file the host and the CI image are guaranteed to share, and a binary
* in the repo would be a binary in the repo. WAV is the one format that can be written by hand.
*
* The bytes are a pure function of the arguments, so storage stays content-addressed: re-seeding
* writes the same hash and the checks can name a file by its exact size.
*/
const SAMPLE_RATE = 8000;
const BITS = 16;
const CHANNELS = 1;
/**
* A mono 16-bit PCM sine, faded in and out over a millisecond so it neither clicks on start nor on
* stop – a decoder is happy either way, but a human listening to the editor's preview is not.
*/
export function sineWav(seconds, frequency = 440) {
const frames = Math.round(SAMPLE_RATE * seconds);
const data = Buffer.alloc(frames * 2);
const fade = Math.max(1, Math.round(SAMPLE_RATE * 0.001));
for (let i = 0; i < frames; i++) {
const envelope = Math.min(1, i / fade, (frames - 1 - i) / fade);
const value = Math.sin((2 * Math.PI * frequency * i) / SAMPLE_RATE) * envelope * 0.6;
data.writeInt16LE(Math.round(value * 32767), i * 2);
}
return withRiffHeader(data);
}
function withRiffHeader(data) {
const byteRate = (SAMPLE_RATE * CHANNELS * BITS) / 8;
const header = Buffer.alloc(44);
header.write('RIFF', 0);
header.writeUInt32LE(36 + data.length, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16); // PCM header length
header.writeUInt16LE(1, 20); // 1 = uncompressed PCM
header.writeUInt16LE(CHANNELS, 22);
header.writeUInt32LE(SAMPLE_RATE, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE((CHANNELS * BITS) / 8, 32); // block align
header.writeUInt16LE(BITS, 34);
header.write('data', 36);
header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}