Earcon Workshop: static earcon family generator with WAV/JSON export (unheard) #9
8 changed files with 567 additions and 0 deletions
64
tools/earcon-workshop/README.md
Normal file
64
tools/earcon-workshop/README.md
Normal 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.
|
||||
12
tools/earcon-workshop/cli.mjs
Normal file
12
tools/earcon-workshop/cli.mjs
Normal 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}`);
|
||||
7
tools/earcon-workshop/package.json
Normal file
7
tools/earcon-workshop/package.json
Normal 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" }
|
||||
}
|
||||
125
tools/earcon-workshop/test.mjs
Normal file
125
tools/earcon-workshop/test.mjs
Normal 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);
|
||||
77
tools/earcon-workshop/web/app.js
Normal file
77
tools/earcon-workshop/web/app.js
Normal 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();
|
||||
218
tools/earcon-workshop/web/earcon.js
Normal file
218
tools/earcon-workshop/web/earcon.js
Normal 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 };
|
||||
43
tools/earcon-workshop/web/index.html
Normal file
43
tools/earcon-workshop/web/index.html
Normal 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>
|
||||
21
tools/earcon-workshop/web/styles.css
Normal file
21
tools/earcon-workshop/web/styles.css
Normal 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; }
|
||||
Loading…
Reference in a new issue