AquaSynth/tools/earcon-workshop/web/app.js
Aqua 0c9708e376 Add Earcon Workshop: static earcon family generator, WAV/zip/JSON pack, node check
Six events x four Dings-derived identities, pure-JS deterministic renderer shared by
page and node, five sliders, offline export. Unheard: signal checks only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:07:26 +00:00

77 lines
5.4 KiB
JavaScript

// 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();