#!/usr/bin/env node /** * @file Generates the notification sounds `client/public/sounds/{chime,pop,alert}.wav` * — 16-bit PCM, 22.05 kHz, mono, each a few sine partials under a short * exponential decay — so the app ships no downloaded audio and no licence * question. Re-run after changing a tone: `node scripts/gen-notification-sounds.js`. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const fs = require("fs"); const path = require("path"); const RATE = 22050; /** One note: `partials` are [frequency Hz, amplitude], `decay` the e-folding time. */ function note({ at = 0, dur, partials, decay, sweep = 0 }) { return { at, dur, partials, decay, sweep }; } const SOUNDS = { // Two bell-like partials, a fifth apart, ringing out. chime: [ note({ dur: 0.42, decay: 0.14, partials: [ [880, 0.55], [1320, 0.3], [2640, 0.08], ], }), ], // A short drop in pitch. pop: [ note({ dur: 0.1, decay: 0.03, sweep: -0.5, partials: [ [620, 0.7], [1240, 0.15], ], }), ], // Two quick beeps, high then low. alert: [ note({ dur: 0.14, decay: 0.08, partials: [ [988, 0.5], [1976, 0.12], ], }), note({ at: 0.17, dur: 0.16, decay: 0.09, partials: [ [784, 0.5], [1568, 0.12], ], }), ], }; function render(notes) { const total = Math.max(...notes.map((n) => n.at + n.dur)); const samples = new Float32Array(Math.ceil(total * RATE)); for (const n of notes) { const start = Math.round(n.at * RATE); const len = Math.round(n.dur * RATE); for (let i = 0; i < len; i++) { const t = i / RATE; // 4 ms attack and a linear fade over the last 10 ms: no clicks. const env = Math.min(1, t / 0.004) * Math.exp(-t / n.decay) * Math.min(1, (len - i) / (0.01 * RATE)); const bend = 1 + (n.sweep * t) / n.dur; let v = 0; for (const [f, a] of n.partials) v += a * Math.sin(2 * Math.PI * f * bend * t); samples[start + i] += v * env; } } return samples; } function wav(samples) { const data = Buffer.alloc(samples.length * 2); samples.forEach((s, i) => data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, s)) * 32767), i * 2) ); const h = Buffer.alloc(44); h.write("RIFF", 0); h.writeUInt32LE(36 + data.length, 4); h.write("WAVE", 8); h.write("fmt ", 12); h.writeUInt32LE(16, 16); // fmt chunk size h.writeUInt16LE(1, 20); // PCM h.writeUInt16LE(1, 22); // mono h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); // byte rate h.writeUInt16LE(2, 32); // block align h.writeUInt16LE(16, 34); // bits per sample h.write("data", 36); h.writeUInt32LE(data.length, 40); return Buffer.concat([h, data]); } const out = path.join(__dirname, "..", "client", "public", "sounds"); fs.mkdirSync(out, { recursive: true }); for (const [name, notes] of Object.entries(SOUNDS)) { const file = path.join(out, `${name}.wav`); const buf = wav(render(notes)); if (buf.length > 20 * 1024) throw new Error(`${name}.wav is ${buf.length} bytes, over 20 KB`); fs.writeFileSync(file, buf); console.log(`${path.relative(process.cwd(), file)} ${buf.length} bytes`); }