AquaSynth/tools/earcon-workshop/web/earcon.js
2026-11-12 10:33:48 +00:00

220 lines
13 KiB
JavaScript

// Earcon Workshop core. Pure JS, no dependencies, no randomness: the browser page and `node` run this one file, so
// the buffer you audition is the buffer you export. Motif and timbre ideas come from src/AquaSynth.Dings/DingCatalog.cs
// (semitone motifs on a root, FM-bell / triangle / pluck voices, no broadband noise). Nothing here has been heard.
export const SR = 48000;
export const PEAK_CEILING = 0.9; // hard cap: every export peaks at or under this, so < 1.0 with headroom for 16-bit rounding
export const FORMAT = "aquasynth.earcon-pack.v0";
// Motif per event: semitones above the identity's root, onset delay (ms), relative gain. Contours differ on purpose
// (rise, fall, repeat, single, resolve, sparse) so siblings are told apart by shape and rhythm, not timbre alone.
export const EVENTS = {
success: { label: "Success", meaning: "It worked: rising fifth then octave", notes: [[0, 0, 1], [7, 110, 1], [12, 220, 1]] },
fail: { label: "Fail", meaning: "It broke: falling minor figure, low landing", notes: [[5, 0, 1], [1, 160, 1], [-4, 320, 1]] },
warning: { label: "Warning", meaning: "Look at this: tritone zigzag, three quick notes", notes: [[9, 0, 1], [3, 110, 1], [9, 220, 1]] },
message: { label: "Message", meaning: "Someone spoke: quick two-note lift high in the register", notes: [[12, 0, 0.7], [19, 70, 0.9]] },
done: { label: "Done", meaning: "Finished: falling resolve onto the root, long tail", notes: [[12, 0, 1], [7, 200, 1], [0, 400, 1.1]] },
waiting: { label: "Waiting", meaning: "Needs you: quiet, sparse, slow low sway, three soft notes", notes: [[0, 0, 0.55], [5, 450, 0.55], [0, 900, 0.55]] },
};
// DEPRECATED as an identity owner (ruled 2026-11-12: DingCatalog owns Dings patch identity; see
// docs/dings-patch-identity-authority-map.md, 'Migration plan'). Kept working; do not add ids or copy catalog values here.
// Timbre identities. rootHz in the calm mid register; fmRatio/fmIndex/fmDecay shape the bell; harmonics > 0 selects the
// additive triangle; attack is seconds. Values start from the Dings catalog instruments and were not tuned by ear.
export const IDENTITIES = {
"warm-bell": { label: "Warm bell", note: "soft fundamental, restrained upper partial", rootHz: 261.6256, voice: "fm", fmRatio: 3.01, fmIndex: 2.6, fmDecay: 0.42, attack: 0.004, decay: 0.55, brightness: 0.34 },
"glass-chime": { label: "Glass chime", note: "clear and precise", rootHz: 329.6276, voice: "fm", fmRatio: 4.12, fmIndex: 4.1, fmDecay: 0.28, attack: 0.002, decay: 0.45, brightness: 0.72 },
"soft-triangle": { label: "Soft triangle", note: "rounded, calm", rootHz: 261.6256, voice: "tri", harmonics: 5, attack: 0.006, decay: 0.5, brightness: 0.46 },
"muted-pluck": { label: "Muted pluck", note: "short, dry", rootHz: 293.6648, voice: "tri", harmonics: 7, attack: 0.002, decay: 0.25, brightness: 0.56 },
};
export const DEFAULT_PARAMS = { tempo: 1, transpose: 0, brightness: 0, decay: 1, level: 0.7 };
// brightness is an offset on the identity's own brightness (-0.4..0.4); tempo scales note spacing; decay scales tails;
// level is the family's peak target (0.1..0.9) applied as one factor so relative event loudness survives.
export const PARAM_RANGES = {
tempo: [0.6, 1.6, 0.01], transpose: [-12, 12, 1], brightness: [-0.25, 0.25, 0.01], decay: [0.5, 1.6, 0.01], level: [0.1, PEAK_CEILING, 0.01],
};
export function clampParams(p = {}) {
const out = { ...DEFAULT_PARAMS };
for (const k of Object.keys(DEFAULT_PARAMS)) {
const v = Number(p[k] ?? DEFAULT_PARAMS[k]);
const [lo, hi] = PARAM_RANGES[k];
out[k] = Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : DEFAULT_PARAMS[k]));
}
return out;
}
const TAU = Math.PI * 2;
// One note into `out` at sample `start`. Linear attack (<= 6 ms), exponential decay reaching -60 dB at `decaySec`,
// two-pole lowpass (two cascaded one-pole stages) whose cutoff follows brightness, 3 ms linear fade at the voice end so no tail ends on a step.
function addNote(out, start, hz, gain, id, decaySec, bright) {
const len = Math.min(out.length - start, Math.ceil(decaySec * SR));
if (len <= 0) return;
const atk = Math.max(1, Math.round(id.attack * SR));
const tau = decaySec / Math.log(1000);
const fade = Math.round(0.003 * SR);
const cutoff = Math.min(9000, 700 * 2 ** (bright * 4)); // 700 Hz .. 9 kHz; two poles keep FM sidebands off Nyquist
const a = 1 - Math.exp(-TAU * cutoff / SR);
const idx = id.voice === "fm" ? id.fmIndex * (0.5 + bright) : 0; // brighter -> more sidebands
const tri = id.voice === "tri" ? Math.max(1, Math.min(id.harmonics, Math.floor((0.45 * SR) / hz / 2))) : 0;
let z = 0, z2 = 0;
for (let n = 0; n < len; n++) {
const t = n / SR;
const env = Math.min(1, (n + 1) / atk) * Math.exp(-n / SR / tau) * Math.min(1, (len - n) / fade);
let y;
if (id.voice === "fm") y = Math.sin(TAU * hz * t + idx * Math.exp(-t / id.fmDecay) * Math.sin(TAU * hz * id.fmRatio * t));
else { y = 0; for (let h = 0; h < tri; h++) { const k = 2 * h + 1; y += ((h & 1) ? -1 : 1) * Math.sin(TAU * hz * k * t) / (k * k); } y *= 8 / (Math.PI * Math.PI); }
z += a * (y - z); z2 += a * (z - z2);
out[start + n] += z2 * env * gain;
}
}
// Voice gains: fm and tri are both roughly unit-amplitude before the lowpass; the family scale fixes the final level.
export function renderEvent(eventId, identityId, params = {}, scale = 1) {
const ev = EVENTS[eventId], id = IDENTITIES[identityId];
if (!ev) throw new Error(`unknown event: ${eventId}`);
if (!id) throw new Error(`unknown identity: ${identityId}`);
const p = clampParams(params);
const decaySec = id.decay * p.decay * (eventId === "done" ? 1.4 : eventId === "message" ? 0.7 : 1);
const bright = Math.min(1, Math.max(0, id.brightness + p.brightness));
const lastMs = Math.max(...ev.notes.map((n) => n[1])) * p.tempo;
const out = new Float32Array(Math.ceil((lastMs / 1000 + decaySec) * SR) + 1);
for (const [semi, ms, g] of ev.notes) {
const hz = id.rootHz * 2 ** ((semi + p.transpose) / 12);
addNote(out, Math.round(ms * p.tempo * SR / 1000), hz, g, id, decaySec, bright);
}
for (let i = 0; i < out.length; i++) out[i] *= scale;
return out;
}
// A family is rendered as one unit: unscaled renders, then ONE factor sets the loudest event to `level`, so relative
// loudness between events (waiting quieter than done) is kept and no event can exceed PEAK_CEILING.
export function renderFamily(identityId, eventIds = Object.keys(EVENTS), params = {}) {
const p = clampParams(params);
const raw = eventIds.map((e) => renderEvent(e, identityId, p, 1));
let max = 0;
for (const b of raw) for (let i = 0; i < b.length; i++) { const v = Math.abs(b[i]); if (v > max) max = v; }
const f = max > 0 ? Math.min(p.level, PEAK_CEILING) / max : 0;
const items = raw.map((b, i) => { for (let j = 0; j < b.length; j++) b[j] *= f; return { event: eventIds[i], samples: b, ...measure(b) }; });
return { identity: identityId, params: p, sampleRate: SR, items };
}
export function measure(x) {
let peak = 0, sq = 0;
for (let i = 0; i < x.length; i++) { const a = Math.abs(x[i]); if (a > peak) peak = a; sq += x[i] * x[i]; }
let onset = -1;
for (let i = 0; i < x.length; i++) if (Math.abs(x[i]) >= 0.01) { onset = i; break; } // first sample at or above -40 dBFS
return { peak, rmsDb: sq > 0 ? 10 * Math.log10(sq / x.length) : -Infinity, onsetMs: onset < 0 ? null : (onset / SR) * 1000, durationMs: (x.length / SR) * 1000 };
}
// ---- WAV (16-bit PCM mono) ----------------------------------------------------------------------------------------
export function encodeWav(x, sr = SR) {
const buf = new ArrayBuffer(44 + x.length * 2), v = new DataView(buf);
const s = (o, t) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); };
s(0, "RIFF"); v.setUint32(4, 36 + x.length * 2, true); s(8, "WAVEfmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true);
v.setUint16(22, 1, true); v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
s(36, "data"); v.setUint32(40, x.length * 2, true);
for (let i = 0; i < x.length; i++) v.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, x[i])) * 32767), true);
return new Uint8Array(buf);
}
export function decodeWav(bytes) {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (v.getUint32(0) !== 0x52494646 || v.getUint32(8) !== 0x57415645) throw new Error("not a RIFF/WAVE file");
const sr = v.getUint32(24, true), n = v.getUint32(40, true) / 2, x = new Float32Array(n);
for (let i = 0; i < n; i++) x[i] = v.getInt16(44 + i * 2, true) / 32767;
return { sampleRate: sr, samples: x };
}
// ---- Pack ---------------------------------------------------------------------------------------------------------
const fileName = (identity, event) => `${identity}-${event}.wav`;
export function buildPack(family) {
return {
format: FORMAT,
heard: false, // nobody has listened to this pack; the flag stays false until a person edits it
sampleRate: family.sampleRate,
identity: family.identity,
params: family.params,
events: Object.fromEntries(family.items.map((it) => [it.event, {
file: fileName(family.identity, it.event),
meaning: EVENTS[it.event].meaning,
durationMs: Math.round(it.durationMs),
peak: +it.peak.toFixed(4),
onsetMs: +it.onsetMs.toFixed(2),
}])),
};
}
export function packReadme(pack) {
const ev = Object.entries(pack.events).map(([k, e]) => `| ${k} | \`${e.file}\` | ${e.durationMs} ms | ${e.meaning} |`).join("\n");
return `# Earcon pack: ${pack.identity}
Generated by the AquaSynth Earcon Workshop. **Nobody has listened to this pack.** Metrics (peak, onset, distinctness) say
the files are not silent, not clipped and differ from each other. They do not say the sounds are pleasant, clear in your
room, or fit your product. Audition them before you ship them.
16-bit PCM mono WAV, ${pack.sampleRate} Hz. \`pack.json\` maps event id to file; \`params\` regenerates the same family in the workshop.
| event | file | length | meaning |
|---|---|---|---|
${ev}
## Using it
**CI** (play on the runner or a dev box; never fail the build on a missing audio device):
\`\`\`sh
play_earcon() { f="$(dirname "$0")/earcons/$1"; { paplay "$f" || aplay -q "$f" || afplay "$f"; } 2>/dev/null || true; }
make test && play_earcon ${pack.events.success?.file ?? "success.wav"} || play_earcon ${pack.events.fail?.file ?? "fail.wav"}
\`\`\`
On a headless runner, attach the file to the job's notification instead (see chat bots).
**Chat bots** (Discord, Slack, Matrix): read \`pack.json\`, look up \`events[eventId].file\`, upload that WAV with the message.
\`\`\`js
const pack = JSON.parse(await fs.readFile("pack.json", "utf8"));
await channel.send({ content: "build finished", files: [path.join(dir, pack.events.done.file)] });
\`\`\`
**Apps** (web): \`new Audio(url).play()\` after a user gesture; browsers block autoplay. Keep one \`Audio\` per event and reset
\`currentTime\` before replaying. Native apps: load the WAV through the platform's short-sound API.
Keep the system volume yours: files peak at or under ${PEAK_CEILING} of full scale (level ${pack.params.level}), which is already loud on headphones.
`;
}
// ---- Minimal stored (uncompressed) ZIP, so the page can hand over one file ------------------------------------------
const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
export function crc32(b) { let c = 0xffffffff; for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
export function zip(files) { // files: [{name, bytes}]
const enc = new TextEncoder(), parts = [], central = [];
let off = 0;
for (const f of files) {
const name = enc.encode(f.name), crc = crc32(f.bytes), h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(8, 0, true); h.setUint16(12, 0x21, true); h.setUint16(10, 0, true);
h.setUint32(14, crc, true); h.setUint32(18, f.bytes.length, true); h.setUint32(22, f.bytes.length, true); h.setUint16(26, name.length, true);
parts.push(new Uint8Array(h.buffer), name, f.bytes);
const c = new DataView(new ArrayBuffer(46));
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(14, 0x21, true);
c.setUint32(16, crc, true); c.setUint32(20, f.bytes.length, true); c.setUint32(24, f.bytes.length, true); c.setUint16(28, name.length, true); c.setUint32(42, off, true);
central.push(new Uint8Array(c.buffer), name);
off += 30 + name.length + f.bytes.length;
}
const cdSize = central.reduce((s, p) => s + p.length, 0), end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true); end.setUint16(8, files.length, true); end.setUint16(10, files.length, true); end.setUint32(12, cdSize, true); end.setUint32(16, off, true);
const all = [...parts, ...central, new Uint8Array(end.buffer)], out = new Uint8Array(all.reduce((s, p) => s + p.length, 0));
let o = 0; for (const p of all) { out.set(p, o); o += p.length; }
return out;
}
export function packFiles(family) {
const pack = buildPack(family), enc = new TextEncoder();
return [
...family.items.map((it) => ({ name: fileName(family.identity, it.event), bytes: encodeWav(it.samples) })),
{ name: "pack.json", bytes: enc.encode(JSON.stringify(pack, null, 2) + "\n") },
{ name: "README.md", bytes: enc.encode(packReadme(pack)) },
];
}
export { fileName };