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.
48 lines
1.9 KiB
JavaScript
48 lines
1.9 KiB
JavaScript
/**
|
||
* 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]);
|
||
}
|