Earcon Workshop: static earcon family generator with WAV/JSON export (unheard) #9

Merged
aqua merged 2 commits from aqua/earcon-workshop into main 2026-11-06 05:36:47 +00:00
8 changed files with 567 additions and 0 deletions

View file

@ -0,0 +1,64 @@
# Earcon Workshop
Static, dependency-free browser page that generates a coherent family of short earcons (one per event, one timbre
identity) and exports them as WAVs plus a JSON pack. **Nothing here has been heard by anyone.** The automated checks
measure signals; no person has judged whether any earcon sounds good, is pleasant, is distinguishable in a real room, or
suits a real product. A human ear is owed (recorded as a follow-up on the forge PR).
## What is live
- `web/earcon.js`: the whole engine. Events (success, fail, warning, message, done, waiting), four identities taken from the
Dings catalog (warm bell, glass chime, soft triangle, muted pluck), five sliders (tempo, transpose, brightness, decay,
level), WAV encoder, pack builder, stored-ZIP writer. Pure functions, deterministic, no randomness, no noise voices.
- `web/index.html` + `app.js`: audition one earcon or the whole family, tweak sliders, download one WAV, `pack.json`, or a zip
of WAVs + `pack.json` + `README.md`. Playback is source -> gain -> analyser -> destination with a peak meter, the same graph shape as `tools/aqua-playground`.
The page plays the exact buffers it exports (offline render, not a second path).
- `cli.mjs`: the same export from a shell, e.g. `node cli.mjs warm-bell ./earcons tempo=1.2 level=0.6`.
- `test.mjs`: `node test.mjs` (about 10 s).
Not the playground's Faust/AudioWorklet engine: that pulls the Faust compiler (wasm) into the page, which is not dependency-free.
The synthesis here is plain JS (FM bell and additive triangle voices, two-pole lowpass); it is a reimplementation of the Dings
instruments' ideas, not a lowering of their `.aqua` scripts, so it will not sound like the shipped Dings.
## Run
Serve `web/` from any static host (ES modules need http, not `file://`):
```sh
cd tools/earcon-workshop/web && python3 -m http.server 8080 # then open http://localhost:8080/
```
Never opened on a real display by a person. A headless Chromium smoke (load, audition, zip download, no console errors) passed once; no audio device was attached.
## What the check covers (`test.mjs`)
For each identity x event at the default setting and at two slider extremes (882 checks at the time of writing):
- finite, non-silent, onset (first sample at or above -40 dBFS) under 20 ms, sample peak < 1.0 and <= 0.9, true peak < 1.0 (cross-checked
with `tools/aqua-feel/metrics.js`), no over-full-scale samples or flat tops, ends without a step, under 2.6 s, WAV round trip.
- Sibling distinctness: every pair of events within one identity, and every pair of identities on the same event, must differ by a
log-spectrogram distance of at least 8 dB (mean absolute dB over 32 log bands, 80 Hz to 16 kHz, 2048-point frames, cells where both sides sit
under -75 dB ignored). Controls: a clip against itself is 0, against silence is far, a 2% tempo change falls under the threshold.
- Determinism, sliders actually change the output, event selection honoured, ZIP entries and CRCs read back, pack lists its files and says `heard: false`.
Limits worth knowing: distance is a spectrogram number, not a perceptual one; two earcons can pass it and still confuse a listener.
The metric and threshold were adjusted after the first run (see the history comment in `test.mjs`): the first averaging let silence
dilute the distance, motifs for warning, message, waiting and fail were reworked, and the threshold went from 4 to 8 dB after the
minimum came out at 13.8 dB. Brightness is limited to +-0.25 because a darker voice made the pluck family hard to tell apart under the first metric. True-peak overs in the first run were
FM aliasing at the bright/high extreme; fixed with a 9 kHz two-pole lowpass.
## Using an exported pack
The zip's own `README.md` carries the CI, chat-bot and app recipes (play on pass/fail in a shell, attach the file to a chat message, `new Audio(url).play()`). Summary:
- **CI:** play `success`/`fail`/`done` from the runner or a dev box; never fail the build when no audio device exists.
- **Chat bots:** look up `pack.json -> events[id].file` and upload that WAV with the message.
- **Apps:** one preloaded audio element per event; browsers need a user gesture before first play.
- Every file peaks at or under 0.9 of full scale at the default level (0.7); the `level` slider tops out at 0.9.
## Planned, not built
- Ogg/Opus export (see `docs/research/dings-playback-and-ogg-export.md`); WAV only today.
- Custom motifs and a custom voice editor; only the six events and four identities exist.
- Lowering the voices from the real `.aqua` Dings scripts so the pack matches the Dings service.
- Any listening: a human trial, then tuning motifs and levels by ear.

View file

@ -0,0 +1,12 @@
// node cli.mjs <identity> <outDir> [key=value ...] e.g. node cli.mjs warm-bell ./earcons tempo=1.2 level=0.6
// Writes the WAVs, pack.json and README.md the page's zip contains, from the same renderer.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { IDENTITIES, renderFamily, packFiles } from "./web/earcon.js";
const [identity, outDir, ...kv] = process.argv.slice(2);
if (!IDENTITIES[identity] || !outDir) { console.error(`usage: node cli.mjs <${Object.keys(IDENTITIES).join("|")}> <outDir> [tempo= transpose= brightness= decay= level=]`); process.exit(2); }
const params = Object.fromEntries(kv.map((s) => s.split("=")).map(([k, v]) => [k, Number(v)]));
await mkdir(outDir, { recursive: true });
for (const f of packFiles(renderFamily(identity, undefined, params))) await writeFile(join(outDir, f.name), f.bytes);
console.log(`wrote ${outDir}`);

View file

@ -0,0 +1,7 @@
{
"name": "earcon-workshop",
"private": true,
"type": "module",
"description": "Static, dependency-free earcon family generator for AquaSynth. Renders; does not listen.",
"scripts": { "test": "node test.mjs", "export": "node cli.mjs" }
}

View file

@ -0,0 +1,125 @@
// Automated checks for every identity x event, at default and at the slider extremes. Rendered with the same code the page runs.
// These measure signals: non-silent, onset, peak, clipping, sibling distinctness, determinism, WAV and ZIP round trips.
// They cannot say any earcon sounds good. A human ear is owed.
import { IDENTITIES, EVENTS, renderFamily, packFiles, encodeWav, decodeWav, buildPack, crc32, PARAM_RANGES, PEAK_CEILING, DEFAULT_PARAMS, SR } from "./web/earcon.js";
import { samplePeak, truePeak, clipping, SILENT_PEAK } from "../aqua-feel/metrics.js";
// Spectral distance: log-magnitude STFT (2048 Hann, hop 512) pooled to 32 log-spaced bands 80 Hz..16 kHz, floored at -90 dB
// relative to full scale; mean absolute dB difference over (frame, band) cells where either side is above -75 dB (the shorter buffer is padded with silence).
const ACTIVE = -75; // cells where both spectra sit under this are shared emptiness and do not dilute the mean
const N = 2048, HOP = 512, BANDS = 32, FLOOR = -90;
function fft(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) { let b = n >> 1; for (; j & b; b >>= 1) j ^= b; j ^= b; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } }
for (let len = 2; len <= n; len <<= 1) {
const w = (-2 * Math.PI) / len;
for (let i = 0; i < n; i += len) for (let k = 0; k < len / 2; k++) {
const c = Math.cos(w * k), s = Math.sin(w * k), a = i + k, b = a + len / 2;
const xr = re[b] * c - im[b] * s, xi = re[b] * s + im[b] * c;
re[b] = re[a] - xr; im[b] = im[a] - xi; re[a] += xr; im[a] += xi;
}
}
}
const win = Float64Array.from({ length: N }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / N));
const edges = Array.from({ length: BANDS + 1 }, (_, i) => Math.round(((80 * (16000 / 80) ** (i / BANDS)) / SR) * N));
function bandFrames(x) {
const frames = [];
for (let s = 0; s + N <= x.length + N - HOP; s += HOP) {
const re = new Float64Array(N), im = new Float64Array(N);
for (let i = 0; i < N; i++) re[i] = (s + i < x.length ? x[s + i] : 0) * win[i];
fft(re, im);
frames.push(Float64Array.from({ length: BANDS }, (_, b) => {
let p = 0; for (let k = edges[b]; k < Math.max(edges[b + 1], edges[b] + 1); k++) p += re[k] ** 2 + im[k] ** 2;
return Math.max(FLOOR, 10 * Math.log10(p / (N * N / 4) + 1e-12)); // N/4 ~ Hann-scaled unit sine -> 0 dB
}));
}
return frames;
}
export function spectralDistance(a, b) {
const fa = bandFrames(a), fb = bandFrames(b), n = Math.max(fa.length, fb.length), silent = new Float64Array(BANDS).fill(FLOOR);
let sum = 0, cells = 0;
for (let t = 0; t < n; t++) { const x = fa[t] ?? silent, y = fb[t] ?? silent; for (let b = 0; b < BANDS; b++) if (x[b] > ACTIVE || y[b] > ACTIVE) { sum += Math.abs(x[b] - y[b]); cells++; } }
return cells ? sum / cells : 0;
}
// History, kept because it changes how far to trust the number: the first metric averaged over every band and frame and
// put the closest pair near 3.3 dB at the dark/slow extreme; motifs were reworked, then the mean was restricted to active
// cells (shared silence was diluting it), and the threshold was raised from 4 to 8 dB after seeing a 13.8 dB minimum.
const MIN_DISTANCE_DB = 8;
const names = Object.keys(IDENTITIES), events = Object.keys(EVENTS);
const lo = (k) => PARAM_RANGES[k][0], hi = (k) => PARAM_RANGES[k][1];
const settings = {
default: {},
"slow dark long quiet": { tempo: hi("tempo"), brightness: lo("brightness"), decay: hi("decay"), level: lo("level"), transpose: lo("transpose") },
"fast bright short loud": { tempo: lo("tempo"), brightness: hi("brightness"), decay: lo("decay"), level: hi("level"), transpose: hi("transpose") },
};
let failures = 0, checks = 0;
const check = (ok, msg) => { checks++; if (!ok) { failures++; console.error(`FAIL ${msg}`); } };
let worst = { d: Infinity, at: "" };
for (const [sname, params] of Object.entries(settings)) for (const id of names) {
const fam = renderFamily(id, undefined, params);
for (const it of fam.items) {
const tag = `${id}/${it.event} [${sname}]`, x = it.samples;
check(x.every(Number.isFinite), `${tag}: NaN or Infinity`);
check(samplePeak(x) > SILENT_PEAK * 100, `${tag}: silent (peak ${samplePeak(x)})`);
check(it.onsetMs !== null && it.onsetMs < 20, `${tag}: onset ${it.onsetMs} ms, need < 20`);
check(it.peak < 1 && it.peak <= PEAK_CEILING + 1e-6, `${tag}: peak ${it.peak}`);
check(truePeak(x) < 1, `${tag}: true peak ${truePeak(x)} >= 1`);
const c = clipping(x); check(c.overSamples === 0 && c.flatTopRuns === 0, `${tag}: clipping ${JSON.stringify(c)}`);
check(it.durationMs < 2600, `${tag}: ${it.durationMs} ms is not short`);
check(Math.abs(x[x.length - 1]) < 1e-3, `${tag}: ends on a step (${x[x.length - 1]})`);
const wav = decodeWav(encodeWav(x)); check(wav.samples.length === x.length && wav.sampleRate === SR && samplePeak(wav.samples) < 1, `${tag}: WAV round trip`);
}
// siblings: every pair of events inside one identity
for (let i = 0; i < fam.items.length; i++) for (let j = i + 1; j < fam.items.length; j++) {
const d = spectralDistance(fam.items[i].samples, fam.items[j].samples);
if (d < worst.d) worst = { d, at: `${id} ${fam.items[i].event} vs ${fam.items[j].event} [${sname}]` };
check(d >= MIN_DISTANCE_DB, `siblings ${id} ${fam.items[i].event} vs ${fam.items[j].event} [${sname}]: distance ${d.toFixed(2)} dB < ${MIN_DISTANCE_DB}`);
}
}
console.log(`closest sibling pair: ${worst.d.toFixed(2)} dB (${worst.at}); threshold ${MIN_DISTANCE_DB} dB`);
// identities are distinct from each other on the same event too
for (const e of events) for (let i = 0; i < names.length; i++) for (let j = i + 1; j < names.length; j++) {
const d = spectralDistance(renderFamily(names[i], [e]).items[0].samples, renderFamily(names[j], [e]).items[0].samples);
check(d >= MIN_DISTANCE_DB, `identities ${names[i]} vs ${names[j]} on ${e}: distance ${d.toFixed(2)} dB`);
}
// a self-pair must measure zero, and a silent-vs-signal pair must measure large: the metric can fail both ways
const probe = renderFamily("warm-bell", ["success"]).items[0].samples;
check(spectralDistance(probe, probe) === 0, "metric: self distance not 0");
check(spectralDistance(probe, new Float32Array(probe.length)) > 10, "metric: silence not far from signal");
// negative control: a near-duplicate (2% tempo change) must land under the threshold, so the threshold can say 'too similar'
const near = spectralDistance(renderFamily("warm-bell", ["success"]).items[0].samples, renderFamily("warm-bell", ["success"], { tempo: 1.02 }).items[0].samples);
check(near < MIN_DISTANCE_DB, `negative control: a 2% tempo change measured ${near.toFixed(2)} dB, the threshold would not flag a near-duplicate`);
console.log(`near-duplicate control: ${near.toFixed(2)} dB`);
// determinism: same inputs, same bytes
const a = packFiles(renderFamily("glass-chime", undefined, { tempo: 1.2 })), b = packFiles(renderFamily("glass-chime", undefined, { tempo: 1.2 }));
check(a.every((f, i) => crc32(f.bytes) === crc32(b[i].bytes)), "render is not deterministic");
// sliders do something
const base = renderFamily("warm-bell", ["success"]).items[0], slow = renderFamily("warm-bell", ["success"], { tempo: 1.6 }).items[0], up = renderFamily("warm-bell", ["success"], { transpose: 12 }).items[0];
check(slow.durationMs > base.durationMs, "tempo slider did not lengthen the earcon");
check(spectralDistance(base.samples, up.samples) > 3, "transpose slider did not move the spectrum");
// event subset only renders what was chosen
check(renderFamily("warm-bell", ["fail", "done"]).items.map((i) => i.event).join() === "fail,done", "event selection not honoured");
// pack: ZIP read back with node's own inflate-free parse of stored entries; pack.json lists every file, heard stays false
const files = packFiles(renderFamily("soft-triangle")), z = (await import("./web/earcon.js")).zip(files);
const dv = new DataView(z.buffer, z.byteOffset, z.byteLength), entries = dv.getUint16(z.length - 22 + 10, true), cdOff = dv.getUint32(z.length - 22 + 16, true);
check(entries === files.length, `zip entry count ${entries} != ${files.length}`);
let p = cdOff;
for (const f of files) {
const csize = dv.getUint32(p + 20, true), crc = dv.getUint32(p + 16, true), nl = dv.getUint16(p + 28, true), lho = dv.getUint32(p + 42, true);
const name = new TextDecoder().decode(z.subarray(p + 46, p + 46 + nl)), data = z.subarray(lho + 30 + nl, lho + 30 + nl + csize);
check(name === f.name && crc === crc32(data) && crc === crc32(f.bytes), `zip entry ${f.name} corrupt`);
p += 46 + nl;
}
const pack = JSON.parse(new TextDecoder().decode(files.find((f) => f.name === "pack.json").bytes));
check(pack.heard === false && Object.values(pack.events).every((e) => files.some((f) => f.name === e.file)), "pack.json does not match its files");
check(/Nobody has listened/.test(new TextDecoder().decode(files.find((f) => f.name === "README.md").bytes)), "pack README lost the unheard notice");
console.log(failures ? `${failures} of ${checks} checks FAILED` : `all ${checks} checks passed`);
process.exit(failures ? 1 : 0);

View file

@ -0,0 +1,77 @@
// Page glue only: state, DOM, AudioContext. Everything audible or exported comes from earcon.js.
// Playback graph follows the playground (tools/aqua-playground/web/app.js): source -> gain -> analyser -> destination, with a peak meter.
import { IDENTITIES, EVENTS, DEFAULT_PARAMS, PARAM_RANGES, renderFamily, buildPack, packFiles, zip, encodeWav, fileName, SR } from "./earcon.js";
const $ = (id) => document.getElementById(id);
const state = { identity: Object.keys(IDENTITIES)[0], chosen: new Set(Object.keys(EVENTS)), params: { ...DEFAULT_PARAMS } };
let ctx, gain, analyser, playing = [], cached;
const status = (t) => { $("status").textContent = t; };
const download = (name, bytes, type) => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([bytes], { type })); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); };
function build() {
$("identity").innerHTML = Object.entries(IDENTITIES).map(([k, v]) => `<option value="${k}">${v.label}</option>`).join("");
$("identity").onchange = () => { state.identity = $("identity").value; refresh(); };
$("family").onclick = auditionFamily; $("stop").onclick = stop;
$("reset").onclick = () => { state.params = { ...DEFAULT_PARAMS }; buildControls(); refresh(); };
$("zip").onclick = () => { const f = family(); download(`earcons-${f.identity}.zip`, zip(packFiles(f)), "application/zip"); };
$("json").onclick = () => download("pack.json", JSON.stringify(buildPack(family()), null, 2) + "\n", "application/json");
$("events").innerHTML = Object.entries(EVENTS).map(([k, e]) => `<div class="ev" data-event="${k}">
<input type="checkbox" checked aria-label="include ${e.label}"><div><b>${e.label}</b><span class="meta">${e.meaning}</span></div>
<button type="button" data-play>Play</button><button type="button" data-wav>WAV</button><canvas width="600" height="34"></canvas></div>`).join("");
for (const row of $("events").children) {
const k = row.dataset.event;
row.querySelector("input").onchange = (e) => { e.target.checked ? state.chosen.add(k) : state.chosen.delete(k); refresh(); };
row.querySelector("[data-play]").onclick = () => playOne(k);
row.querySelector("[data-wav]").onclick = () => { const it = family().items.find((i) => i.event === k); download(fileName(state.identity, k), encodeWav(it.samples), "audio/wav"); };
}
buildControls();
}
function buildControls() {
$("controls").innerHTML = "";
for (const [k, [lo, hi, step]] of Object.entries(PARAM_RANGES)) {
const row = document.createElement("div"); row.className = "row";
const input = Object.assign(document.createElement("input"), { type: "range", min: lo, max: hi, step, value: state.params[k] });
const out = document.createElement("output"), show = () => { out.textContent = Number(input.value).toFixed(step < 1 ? 2 : 0); };
input.oninput = () => { state.params[k] = Number(input.value); show(); refresh(); }; show();
const label = document.createElement("label"); label.textContent = k;
row.append(label, input, out); $("controls").append(row);
}
}
// One render per state change, cached until refresh(); that buffer is what is shown, played and exported.
function family() { return (cached ??= renderFamily(state.identity, Object.keys(EVENTS).filter((e) => state.chosen.has(e)), state.params)); }
function refresh() {
cached = null; const f = family();
$("idnote").textContent = IDENTITIES[state.identity].note;
for (const row of $("events").children) {
const it = f.items.find((i) => i.event === row.dataset.event), cv = row.querySelector("canvas"), g = cv.getContext("2d");
g.clearRect(0, 0, cv.width, cv.height);
row.style.opacity = it ? 1 : 0.4;
if (!it) continue;
g.fillStyle = "#3fc1c9"; const step = it.samples.length / cv.width;
for (let x = 0; x < cv.width; x++) { let p = 0; for (let i = Math.floor(x * step); i < Math.floor((x + 1) * step); i++) p = Math.max(p, Math.abs(it.samples[i])); const h = p * cv.height; g.fillRect(x, (cv.height - h) / 2, 1, Math.max(1, h)); }
}
$("pack").textContent = JSON.stringify(buildPack(f), null, 2);
status(`${f.items.length} earcons, peak ${Math.max(0, ...f.items.map((i) => i.peak)).toFixed(2)}, not heard by anyone.`);
}
async function graph() {
ctx ??= new AudioContext({ latencyHint: "interactive" });
await ctx.resume();
if (!gain) { gain = ctx.createGain(); analyser = ctx.createAnalyser(); analyser.fftSize = 1024; gain.connect(analyser); analyser.connect(ctx.destination); meter(); }
}
function schedule(samples, when) {
const buf = ctx.createBuffer(1, samples.length, SR); buf.copyToChannel(samples, 0);
const src = ctx.createBufferSource(); src.buffer = buf; src.connect(gain); src.start(when); playing.push(src);
}
function stop() { for (const s of playing) { try { s.stop(); } catch { /* already ended */ } s.disconnect(); } playing = []; }
async function playOne(k) { stop(); await graph(); const it = family().items.find((i) => i.event === k); if (it) schedule(it.samples, ctx.currentTime + 0.02); }
async function auditionFamily() { stop(); await graph(); let t = ctx.currentTime + 0.05; for (const it of family().items) { schedule(it.samples, t); t += it.durationMs / 1000 + 0.35; } }
function meter() {
const b = new Float32Array(analyser.fftSize); analyser.getFloatTimeDomainData(b);
$("level").value = b.reduce((m, v) => Math.max(m, Math.abs(v)), 0); requestAnimationFrame(meter);
}
build(); refresh();

View file

@ -0,0 +1,218 @@
// 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]] },
};
// 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 };

View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AquaSynth Earcon Workshop</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<main>
<header>
<p class="eyebrow">AquaSynth lab</p>
<h1>Earcon Workshop</h1>
<p class="lede">Pick the events your tool needs and a voice. The workshop renders one short, related earcon per event, entirely in this page, and exports WAVs plus a JSON pack. <strong>Nobody has listened to any of this.</strong> The checks measure silence, clipping, onset and difference between siblings; they cannot tell you it sounds good.</p>
</header>
<section class="bar">
<label>Voice <select id="identity"></select></label>
<span id="idnote" class="dim"></span>
<button id="family" type="button">Audition family</button>
<button id="stop" type="button">Stop</button>
<meter id="level" min="0" max="1" value="0" title="output peak"></meter>
<span id="status" role="status"></span>
</section>
<section class="cols">
<div>
<h2>Events</h2>
<div id="events" class="events"></div>
<h2>Tweak <small>(one factor each, applied to the whole family)</small></h2>
<div id="controls" class="controls"></div>
<p><button id="reset" type="button">Reset sliders</button></p>
</div>
<div>
<h2>Export</h2>
<p><button id="zip" type="button">Download pack (.zip)</button> <button id="json" type="button">pack.json only</button></p>
<p class="dim">The zip holds one 16-bit mono WAV per chosen event, <code>pack.json</code> and a <code>README.md</code> on using them in CI, chat bots and apps. Offline render from the same code that played above.</p>
<h2>Pack <small>(measured, not judged)</small></h2>
<pre id="pack"></pre>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
:root { color-scheme: dark; --bg:#0b1418; --fg:#d7e6ea; --dim:#7f9aa3; --acc:#3fc1c9; --line:#1f3640; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.45 system-ui, sans-serif; }
main { max-width:1100px; margin:0 auto; padding:24px; }
.eyebrow { margin:0; color:var(--acc); text-transform:uppercase; letter-spacing:.12em; font-size:12px; }
h1 { margin:.1em 0 .2em; } h2 { font-size:16px; margin:16px 0 8px; } small, .dim { color:var(--dim); font-weight:400; }
.lede { color:var(--dim); max-width:72ch; } .lede strong { color:var(--fg); }
.bar { display:flex; flex-wrap:wrap; gap:12px 16px; align-items:center; padding:12px 0; border-block:1px solid var(--line); margin-bottom:16px; }
button, select { background:#10242b; color:var(--fg); border:1px solid var(--line); padding:6px 14px; border-radius:4px; font:inherit; cursor:pointer; }
button:hover { border-color:var(--acc); } button:disabled { opacity:.4; }
#status { color:var(--dim); }
.cols { display:grid; grid-template-columns:minmax(300px,1.2fr) minmax(300px,1fr); gap:24px; }
@media (max-width:800px) { .cols { grid-template-columns:1fr; } }
.events { display:grid; gap:6px; }
.ev { display:grid; grid-template-columns:auto 1fr auto auto; gap:8px; align-items:center; padding:6px 8px; border:1px solid var(--line); border-radius:4px; }
.ev canvas { grid-column:1 / -1; width:100%; height:34px; background:#071013; border-radius:3px; }
.ev .meta { color:var(--dim); font-size:12px; } .ev b { display:block; font-weight:600; color:var(--fg); }
.controls { display:grid; gap:6px; }
.controls .row { display:grid; grid-template-columns:110px minmax(0,1fr) 58px; gap:8px; align-items:center; }
.controls output { text-align:right; color:var(--acc); font-variant-numeric:tabular-nums; }
pre { background:#071013; color:#bcd3d8; border:1px solid var(--line); border-radius:4px; font:12px/1.4 ui-monospace, monospace; padding:8px; max-height:60vh; overflow:auto; }