Files
AgentHub/scripts/gen-notification-sounds.js
T
nntrivi2001 b71024f244 feat(ui): Orca Dark / Orca Light themes and notification sounds
Two optional named themes from Orca's neutral palette join the Appearance
picker (Orca Dark keeps a blue accent so white-on-accent buttons stay
legible; Orca Light's status colours are darkened to clear 4.5:1 on their
chips). A shown notification can play a sound — None (default), Chime, Pop
or Alert — chosen globally and per notification type in Settings →
Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
2026-10-01 17:10:46 +07:00

119 lines
3.2 KiB
JavaScript

#!/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ĩ <vinnt@smartgift.vn>
*/
"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`);
}