diff --git a/tools/earcon-workshop/README.md b/tools/earcon-workshop/README.md new file mode 100644 index 0000000..4c533d3 --- /dev/null +++ b/tools/earcon-workshop/README.md @@ -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. diff --git a/tools/earcon-workshop/cli.mjs b/tools/earcon-workshop/cli.mjs new file mode 100644 index 0000000..a8b853e --- /dev/null +++ b/tools/earcon-workshop/cli.mjs @@ -0,0 +1,12 @@ +// node cli.mjs [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("|")}> [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}`); diff --git a/tools/earcon-workshop/package.json b/tools/earcon-workshop/package.json new file mode 100644 index 0000000..93923f0 --- /dev/null +++ b/tools/earcon-workshop/package.json @@ -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" } +} diff --git a/tools/earcon-workshop/test.mjs b/tools/earcon-workshop/test.mjs new file mode 100644 index 0000000..9006cb5 --- /dev/null +++ b/tools/earcon-workshop/test.mjs @@ -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); diff --git a/tools/earcon-workshop/web/app.js b/tools/earcon-workshop/web/app.js new file mode 100644 index 0000000..bd4dabf --- /dev/null +++ b/tools/earcon-workshop/web/app.js @@ -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]) => ``).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]) => `
+
${e.label}${e.meaning}
+
`).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(); diff --git a/tools/earcon-workshop/web/earcon.js b/tools/earcon-workshop/web/earcon.js new file mode 100644 index 0000000..6dcee6a --- /dev/null +++ b/tools/earcon-workshop/web/earcon.js @@ -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 }; diff --git a/tools/earcon-workshop/web/index.html b/tools/earcon-workshop/web/index.html new file mode 100644 index 0000000..deffe04 --- /dev/null +++ b/tools/earcon-workshop/web/index.html @@ -0,0 +1,43 @@ + + + + + + AquaSynth Earcon Workshop + + + +
+
+

AquaSynth lab

+

Earcon Workshop

+

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. Nobody has listened to any of this. The checks measure silence, clipping, onset and difference between siblings; they cannot tell you it sounds good.

+
+
+ + + + + + +
+
+
+

Events

+
+

Tweak (one factor each, applied to the whole family)

+
+

+
+
+

Export

+

+

The zip holds one 16-bit mono WAV per chosen event, pack.json and a README.md on using them in CI, chat bots and apps. Offline render from the same code that played above.

+

Pack (measured, not judged)

+

+      
+
+
+ + + diff --git a/tools/earcon-workshop/web/styles.css b/tools/earcon-workshop/web/styles.css new file mode 100644 index 0000000..5b68151 --- /dev/null +++ b/tools/earcon-workshop/web/styles.css @@ -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; }