b71024f244
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.
119 lines
3.2 KiB
JavaScript
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`);
|
|
}
|