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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/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`);
|
||||
}
|
||||
Reference in New Issue
Block a user