Spaces:
Running
Running
| // One-shot generator for the bundled CC0 sound effects. Output is | |
| // committed (public/sounds/*.wav) in the robot's wire format: | |
| // 16 kHz mono 16-bit PCM WAV. Re-run with `npm run make-sounds`. | |
| // | |
| // Durations here must match src/audio/sounds.ts. | |
| import { mkdirSync, writeFileSync } from "node:fs"; | |
| import { dirname, join } from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const RATE = 16000; | |
| const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "sounds"); | |
| function wavBytes(samples) { | |
| const dataSize = samples.length * 2; | |
| const buf = Buffer.alloc(44 + dataSize); | |
| buf.write("RIFF", 0); | |
| buf.writeUInt32LE(36 + dataSize, 4); | |
| buf.write("WAVE", 8); | |
| buf.write("fmt ", 12); | |
| buf.writeUInt32LE(16, 16); | |
| buf.writeUInt16LE(1, 20); // PCM | |
| buf.writeUInt16LE(1, 22); // mono | |
| buf.writeUInt32LE(RATE, 24); | |
| buf.writeUInt32LE(RATE * 2, 28); | |
| buf.writeUInt16LE(2, 32); | |
| buf.writeUInt16LE(16, 34); | |
| buf.write("data", 36); | |
| buf.writeUInt32LE(dataSize, 40); | |
| samples.forEach((v, i) => { | |
| const s = Math.max(-1, Math.min(1, v)); | |
| buf.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), 44 + i * 2); | |
| }); | |
| return buf; | |
| } | |
| const seconds = (s) => Math.round(s * RATE); | |
| const silence = (s) => new Array(seconds(s)).fill(0); | |
| /** Sine tone with attack/release fades (avoids clicks). */ | |
| function tone(freq, durS, { gain = 0.6, fadeS = 0.012, glideTo = null } = {}) { | |
| const n = seconds(durS); | |
| const out = new Array(n); | |
| for (let i = 0; i < n; i++) { | |
| const t = i / RATE; | |
| const u = i / n; | |
| const f = glideTo === null ? freq : freq * Math.pow(glideTo / freq, u); | |
| const fade = Math.min(1, t / fadeS, (durS - t) / fadeS); | |
| out[i] = gain * fade * Math.sin(2 * Math.PI * f * t); | |
| } | |
| return out; | |
| } | |
| function decay(samples, halfLifeS) { | |
| return samples.map((v, i) => v * Math.pow(0.5, i / RATE / halfLifeS)); | |
| } | |
| const sounds = { | |
| // 350 ms friendly double beep. | |
| beep: [...tone(880, 0.15), ...silence(0.05), ...tone(1175, 0.15)], | |
| // 900 ms rising arpeggio + sparkle. | |
| tada: [ | |
| ...tone(523, 0.16), | |
| ...tone(659, 0.16), | |
| ...tone(784, 0.2), | |
| ...decay(tone(1047, 0.38, { gain: 0.7 }), 0.12), | |
| ], | |
| // 600 ms springy downward glide with wobble. | |
| boing: decay( | |
| tone(320, 0.6, { glideTo: 70, gain: 0.8 }).map( | |
| (v, i) => v * (1 + 0.35 * Math.sin(2 * Math.PI * 18 * (i / RATE))), | |
| ), | |
| 0.22, | |
| ), | |
| // 250 ms bubble pop: tiny noise burst + low thump. | |
| pop: [ | |
| ...decay(Array.from({ length: seconds(0.03) }, () => (Math.random() * 2 - 1) * 0.7), 0.008), | |
| ...decay(tone(180, 0.22, { gain: 0.8 }), 0.05), | |
| ], | |
| // 750 ms descending "uh-oh". | |
| uhoh: [...decay(tone(420, 0.25, { gain: 0.7 }), 0.2), ...silence(0.12), ...decay(tone(300, 0.38, { gain: 0.7 }), 0.2)], | |
| }; | |
| mkdirSync(OUT_DIR, { recursive: true }); | |
| for (const [name, samples] of Object.entries(sounds)) { | |
| const file = join(OUT_DIR, `${name}.wav`); | |
| writeFileSync(file, wavBytes(samples)); | |
| console.log(`${file} (${(samples.length / RATE).toFixed(2)}s)`); | |
| } | |