reachy-blocks / scripts /make-sounds.mjs
Claude
feat: emotions-library loader, sound effects, WAV pipeline
bac98f7 unverified
Raw
History Blame Contribute Delete
3.02 kB
// 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)`);
}