diff --git a/tools/aqua-listen/README.md b/tools/aqua-listen/README.md
new file mode 100644
index 0000000..eb3886e
--- /dev/null
+++ b/tools/aqua-listen/README.md
@@ -0,0 +1,59 @@
+# aqua-listen
+
+A listening-test kit: a folder of sound clips and a manifest become **one HTML file** you can send to a listener. They open it, listen, say keep / fix / drop for each sound, and send back two small files.
+
+**Status:** the page was driven in headless Chromium (consent gate, autosave across reload, export, zero network requests) and `node test.mjs` covers validation, ordering and the export shape. Nobody has listened with it yet, and the headless browser played nothing audibly; real playback on a phone or other browser is untried.
+
+## For the listener (no technical knowledge needed)
+
+1. Open the `listen.html` file you were sent by double-clicking it. Any recent browser works.
+2. Read the short note and tick the box. You can type a nickname or leave it blank.
+3. Pick **headphones** or **speakers** at the top, whichever you are using. Change it if you switch.
+4. For each sound: press play (as often as you like), read what it is *meant* to feel like, then choose
+ - **keep** (good as it is), **fix** (nearly, but something is wrong), or **drop** (not worth having),
+ - how well it **landed**, from 1 (not at all) to 5 (completely),
+ - and one line on why.
+5. Some sounds come as a pair, **A** and **B**. Rate both, then say which you prefer, or that there is no difference.
+6. The sounds are in a random order and their names are hidden on purpose. Please don't hunt for them.
+7. Your answers save themselves in your browser as you go. You can close the tab and come back to the same file later.
+8. When done, press **Export**. Two files land in your downloads: `verdict.json` and `verdict.md`. Send both to whoever gave you the page. That is the only way anything leaves your computer.
+
+Privacy: the page has no network access at all (the browser is told to refuse it), no account, no tracking. "Erase my answers" at the bottom wipes what the page saved.
+
+## For the person building a kit
+
+```
+node build.mjs --manifest kit.json --clips ./clips --out ./dist # writes dist/listen.html
+node test.mjs # 16 checks, no dependencies, no browser
+node smoke.mjs dist/listen.html # optional browser smoke, see below
+```
+
+`kit.json`:
+
+```json
+{ "kit": { "id": "my-kit-1", "title": "Thruster sweep", "note": "optional line shown under the title" },
+ "clips": [
+ { "id": "t1", "file": "thr-a.wav", "title": "Thruster, low", "feel": "heavy, steady, not hissy", "group": "thruster", "pair": "t2" },
+ { "id": "t2", "file": "thr-b.wav", "title": "Thruster, low, tonal", "feel": "heavy, steady, not hissy", "group": "thruster", "pair": "t1" }
+] }
+```
+
+- `id`, `file` (wav, mp3, ogg or flac, relative to `--clips`), `title`, `feel` (intended feel; shown to the listener), `group`: required. `pair`: optional, must name another clip whose `pair` names this one. Anything wrong stops the build with the full list of problems.
+- Title, id and group are hidden from the listener and appear only in the export. The intended feel is shown, since "did it land" needs it.
+- Order is shuffled from a random seed kept with the listener's answers (it is in the export). A pair is shuffled as one unit, with A/B position shuffled inside it.
+- Audio is embedded as-is (base64), so the page is about 1.4x the clip bytes. The build warns over 100 MB.
+- Autosave key is the kit id plus a hash of the manifest and clip bytes: a rebuilt kit with changed clips starts fresh instead of mixing answers.
+
+### Export
+
+`verdict.json` (`schema: "aqua.listen.verdict.v1"`): `kit`, `exportedAt`, `seed`, `consent`, `listener`, `device`, `notes`, `summary` (counts), `clips[]` in the order heard (`position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device`), `pairs[]` (`a, b, preferred`: a clip id or `no-difference`). `verdict.md` is the same as a table. Unanswered fields are `null`.
+
+The blank verdict files this was meant to match were not found under `/opt/commons/handoff/`, so this shape is the kit's own. If a house format exists, the change is in `buildVerdict` / `verdictMarkdown` in `logic.cjs`.
+
+### Browser smoke
+
+`PLAYWRIGHT_CORE=/path/to/playwright-core CHROME=/path/to/chrome node smoke.mjs dist/listen.html` ticks through consent, answers a clip, reloads, exports, and fails on any network request or page error. It is not in `node test.mjs` because the repo carries no browser dependency.
+
+## Files
+
+`logic.cjs` validation, ordering, export shape (shared by build, page, tests) · `build.mjs` assembles the page · `web/` page template, glue and styles · `test.mjs` · `smoke.mjs`
diff --git a/tools/aqua-listen/build.mjs b/tools/aqua-listen/build.mjs
new file mode 100644
index 0000000..16526ab
--- /dev/null
+++ b/tools/aqua-listen/build.mjs
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+// Builds one self-contained listening page. Usage:
+// node build.mjs --manifest kit.json --clips DIR --out DIR (writes DIR/listen.html; --clips defaults to the manifest's folder)
+import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { createRequire } from "node:module";
+import { dirname, extname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const L = createRequire(import.meta.url)("./logic.cjs");
+const MIME = { ".wav": "audio/wav", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".flac": "audio/flac" };
+
+// Throws one Error listing every manifest problem and every missing file. Returns {html, hash, bytes}.
+export function buildKit(manifest, clipsDir) {
+ const errs = L.validateManifest(manifest);
+ if (errs.length === 0) for (const c of manifest.clips) {
+ const f = resolve(clipsDir, c.file);
+ if (!f.startsWith(resolve(clipsDir) + "/") && !f.startsWith(resolve(clipsDir) + "\\")) errs.push(`clip ${c.id}: ${c.file} leaves the clips folder`);
+ else if (!existsSync(f) || !statSync(f).isFile()) errs.push(`clip ${c.id}: file not found: ${c.file}`);
+ }
+ if (errs.length) throw new Error("manifest invalid:\n " + errs.join("\n "));
+ const audio = {}, h = createHash("sha256");
+ h.update(JSON.stringify(manifest));
+ for (const c of manifest.clips) {
+ const buf = readFileSync(resolve(clipsDir, c.file));
+ h.update(buf);
+ audio[c.id] = { mime: MIME[extname(c.file).toLowerCase()], b64: buf.toString("base64") };
+ }
+ const hash = h.digest("hex").slice(0, 8);
+ const data = JSON.stringify({ manifest, hash, audio }).replace(/{const module={exports:{}};${readFileSync(join(here, "logic.cjs"), "utf8")}\nreturn module.exports})();`;
+ const part = (n) => readFileSync(join(here, "web", n), "utf8");
+ const esc = (s) => s.replace(/&/g, "&").replace(/ esc(manifest.kit.title)).replace("/*STYLES*/", () => part("styles.css"))
+ .replace("/*DATA*/", () => data).replace("/*LOGIC*/", () => logic.replace(/<\/script/gi, "<\\/script")).replace("/*PAGE*/", () => part("page.js").replace(/<\/script/gi, "<\\/script"));
+ return { html, hash, bytes: Buffer.byteLength(html) };
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const a = process.argv.slice(2), opt = (n) => { const i = a.indexOf(n); return i < 0 ? undefined : a[i + 1]; };
+ const mf = opt("--manifest"), out = opt("--out");
+ if (!mf || !out) { console.error("usage: node build.mjs --manifest kit.json --clips DIR --out DIR"); process.exit(2); }
+ try {
+ const manifest = JSON.parse(readFileSync(mf, "utf8"));
+ const r = buildKit(manifest, resolve(opt("--clips") ?? dirname(resolve(mf))));
+ mkdirSync(out, { recursive: true });
+ writeFileSync(join(out, "listen.html"), r.html);
+ console.log(`built ${join(out, "listen.html")}\nclips ${manifest.clips.length} bytes ${r.bytes} hash ${r.hash}`);
+ if (r.bytes > 100e6) console.log("warning: over 100 MB; some browsers and mail systems will struggle. Trim clips.");
+ } catch (e) { console.error(e.message); process.exit(1); }
+}
diff --git a/tools/aqua-listen/logic.cjs b/tools/aqua-listen/logic.cjs
new file mode 100644
index 0000000..e7aec25
--- /dev/null
+++ b/tools/aqua-listen/logic.cjs
@@ -0,0 +1,103 @@
+// Shared by build.mjs, the page and test.mjs. CommonJS so build.mjs can inline this file into the page verbatim.
+"use strict";
+const SCHEMA = "aqua.listen.verdict.v1";
+const VERDICTS = ["keep", "fix", "drop"];
+const DEVICES = ["headphones", "speakers"];
+const AUDIO_EXT = /\.(wav|mp3|ogg|flac)$/i;
+
+// Manifest: { kit: {id, title, note?}, clips: [{id, file, title, feel, group, pair?}] }. Returns a list of problems (empty = valid).
+function validateManifest(m) {
+ const errs = [];
+ const str = (v) => typeof v === "string" && v.trim() !== "";
+ if (!m || typeof m !== "object" || Array.isArray(m)) return ["manifest must be an object with `kit` and `clips`"];
+ if (!m.kit || !str(m.kit.id) || !/^[A-Za-z0-9._-]+$/.test(m.kit.id)) errs.push("kit.id must be a non-empty string of letters, digits, . _ -");
+ if (!m.kit || !str(m.kit.title)) errs.push("kit.title is required");
+ if (!Array.isArray(m.clips) || m.clips.length === 0) { errs.push("clips must be a non-empty array"); return errs; }
+ const byId = new Map();
+ m.clips.forEach((c, i) => {
+ const at = `clips[${i}]${c && str(c.id) ? ` (${c.id})` : ""}`;
+ if (!c || typeof c !== "object") { errs.push(`${at}: must be an object`); return; }
+ for (const k of ["id", "file", "title", "feel", "group"]) if (!str(c[k])) errs.push(`${at}: ${k} is required`);
+ if (c.pair !== undefined && !str(c.pair)) errs.push(`${at}: pair must be a clip id`);
+ if (str(c.file)) {
+ if (!AUDIO_EXT.test(c.file)) errs.push(`${at}: file must be .wav, .mp3, .ogg or .flac`);
+ if (/^([A-Za-z]:|[\\/])/.test(c.file) || c.file.split(/[\\/]/).includes("..")) errs.push(`${at}: file must be a relative path inside the clips folder`);
+ }
+ if (str(c.id)) { if (byId.has(c.id)) errs.push(`${at}: duplicate id`); else byId.set(c.id, c); }
+ });
+ for (const c of m.clips) {
+ if (!c || !str(c.pair) || !str(c.id)) continue;
+ const at = `clip ${c.id}`, o = byId.get(c.pair);
+ if (c.pair === c.id) errs.push(`${at}: cannot be paired with itself`);
+ else if (!o) errs.push(`${at}: pair ${c.pair} is not a clip id`);
+ else if (o.pair !== c.id) errs.push(`${at}: pair ${c.pair} does not point back (pairs must be mutual)`);
+ }
+ return errs;
+}
+
+function rng(seed) { // mulberry32
+ let a = seed >>> 0;
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
+}
+function shuffle(xs, r) { const a = xs.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }
+
+// Presentation order from a seed: singles and A/B pairs are shuffled as units; inside a pair the A/B position is shuffled.
+// Returns [{id, slot: 'A'|'B'|null}], a pair's two clips adjacent.
+function planOrder(clips, seed) {
+ const r = rng(seed), seen = new Set(), units = [];
+ for (const c of clips) {
+ if (seen.has(c.id)) continue;
+ seen.add(c.id);
+ if (c.pair) { seen.add(c.pair); units.push([c.id, c.pair]); } else units.push([c.id]);
+ }
+ const out = [];
+ for (const u of shuffle(units, r)) {
+ if (u.length === 1) out.push({ id: u[0], slot: null });
+ else { const [x, y] = shuffle(u, r); out.push({ id: x, slot: "A" }, { id: y, slot: "B" }); }
+ }
+ return out;
+}
+
+// state: {seed, consent, listener, device, notes, answers: {id: {verdict, reason, landed, device}}, pairs: {"idA|idB": 'A'|'B'|'same'}}
+function buildVerdict(manifest, order, state, now) {
+ const byId = new Map(manifest.clips.map((c) => [c.id, c]));
+ const slot = new Map(order.map((o) => [o.id, o.slot]));
+ const clips = order.map((o, i) => {
+ const c = byId.get(o.id), a = state.answers[o.id] || {};
+ return { position: i + 1, id: c.id, title: c.title, group: c.group, feel: c.feel, file: c.file,
+ pair: c.pair || null, slot: o.slot,
+ verdict: VERDICTS.includes(a.verdict) ? a.verdict : null, reason: a.reason || "",
+ landed: Number.isInteger(a.landed) && a.landed >= 1 && a.landed <= 5 ? a.landed : null,
+ device: DEVICES.includes(a.device) ? a.device : null };
+ });
+ const pairs = [];
+ for (const o of order) {
+ const c = byId.get(o.id);
+ if (o.slot !== "A") continue;
+ const key = `${o.id}|${c.pair}`, p = state.pairs[key];
+ pairs.push({ a: o.id, b: c.pair, preferred: p === "A" ? o.id : p === "B" ? c.pair : p === "same" ? "no-difference" : null });
+ }
+ const count = (v) => clips.filter((c) => c.verdict === v).length;
+ return { schema: SCHEMA, kit: { id: manifest.kit.id, title: manifest.kit.title }, exportedAt: now, seed: state.seed,
+ consent: !!state.consent, listener: state.listener || "", device: state.device || null, notes: state.notes || "",
+ summary: { total: clips.length, keep: count("keep"), fix: count("fix"), drop: count("drop"), unanswered: clips.filter((c) => !c.verdict).length },
+ clips, pairs };
+}
+
+const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
+function verdictMarkdown(v) {
+ const L = [`# Listening verdict: ${v.kit.title}`, "",
+ `Kit \`${v.kit.id}\`, exported ${v.exportedAt}, seed ${v.seed}. Listener: ${v.listener || "(anonymous)"}. Listening on: ${v.device || "not stated"}.`,
+ `Consent given: ${v.consent ? "yes" : "no"}.`, "",
+ `**${v.summary.keep} keep, ${v.summary.fix} fix, ${v.summary.drop} drop, ${v.summary.unanswered} unanswered** of ${v.summary.total}.`, "",
+ "| # | clip | group | intended feel | verdict | landed (1-5) | device | reason |", "|---|---|---|---|---|---|---|---|"];
+ for (const c of v.clips) L.push(`| ${c.position} | ${cell(c.title)} (\`${cell(c.id)}\`)${c.slot ? ` [${c.slot}]` : ""} | ${cell(c.group)} | ${cell(c.feel)} | ${c.verdict ?? "-"} | ${c.landed ?? "-"} | ${c.device ?? "-"} | ${cell(c.reason)} |`);
+ if (v.pairs.length) {
+ L.push("", "## A/B pairs", "", "| A | B | preferred |", "|---|---|---|");
+ for (const p of v.pairs) L.push(`| \`${p.a}\` | \`${p.b}\` | ${p.preferred ?? "-"} |`);
+ }
+ if (v.notes) L.push("", "## Notes", "", v.notes);
+ return L.join("\n") + "\n";
+}
+
+module.exports = { SCHEMA, VERDICTS, DEVICES, AUDIO_EXT, validateManifest, planOrder, buildVerdict, verdictMarkdown, rng };
diff --git a/tools/aqua-listen/package.json b/tools/aqua-listen/package.json
new file mode 100644
index 0000000..0254838
--- /dev/null
+++ b/tools/aqua-listen/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "aqua-listen",
+ "private": true,
+ "type": "module",
+ "description": "Listening-test kit builder: a folder of clips and a manifest become one self-contained HTML page that collects keep/fix/drop verdicts. No dependencies.",
+ "scripts": { "build": "node build.mjs", "test": "node test.mjs" }
+}
diff --git a/tools/aqua-listen/smoke.mjs b/tools/aqua-listen/smoke.mjs
new file mode 100644
index 0000000..471eb0c
--- /dev/null
+++ b/tools/aqua-listen/smoke.mjs
@@ -0,0 +1,38 @@
+// Browser smoke for a built page: node smoke.mjs listen.html (needs playwright-core and a Chromium; set PLAYWRIGHT_CORE=/path/to/playwright-core
+// and optionally CHROME=/path/to/chrome). Not part of `node test.mjs`: the repo has no browser dependency.
+import { createRequire } from "node:module";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_CORE ?? "playwright-core");
+const file = resolve(process.argv[2]);
+const browser = await chromium.launch({ executablePath: process.env.CHROME || undefined });
+const ctx = await browser.newContext({ acceptDownloads: true }), page = await ctx.newPage();
+const requests = [], errors = [];
+page.on("request", (r) => { if (!r.url().startsWith("file:") && !r.url().startsWith("blob:") && !r.url().startsWith("data:")) requests.push(r.url()); });
+page.on("pageerror", (e) => errors.push(e.message));
+await page.goto("file://" + file);
+const check = (c, m) => { if (!c) { console.error("FAIL", m); process.exitCode = 1; } else console.log("ok", m); };
+check(await page.locator("button:has-text('Start listening')").isDisabled(), "Start is disabled until consent is ticked");
+check((await page.locator("audio").count()) === 0, "no clip is shown before consent");
+await page.check("#consent"); await page.fill("#alias", "Tester"); await page.click("button:has-text('Start listening')");
+const total = await page.locator("audio").count();
+check(total > 0, `${total} clips shown after consent`);
+check(!/Title|title/.test(await page.locator("main").innerText()), "titles are hidden while listening");
+await page.check("input[name=device][value=headphones]");
+const first = page.locator("section.card").first();
+await first.locator("input[value=fix]").check(); await first.locator("input[type=radio][name^=l-][value='4']").check(); await first.locator("input[type=text]").fill("a bit thin");
+await page.reload();
+check(await page.locator("section.card").first().locator("input[value=fix]").isChecked(), "answers survive a reload (localStorage autosave)");
+check((await page.locator("section.card").first().locator("input[type=text]").inputValue()) === "a bit thin", "reason survives a reload");
+const got = [];
+page.on("download", (d) => got.push(d));
+await page.click("button:has-text('Export')");
+for (let i = 0; i < 50 && got.length < 2; i++) await page.waitForTimeout(100);
+const names = got.map((d) => d.suggestedFilename()).sort();
+check(names.join() === "verdict.json,verdict.md", "export downloads verdict.json and verdict.md");
+const v = JSON.parse(readFileSync(await got.find((d) => d.suggestedFilename() === "verdict.json").path(), "utf8"));
+check(v.listener === "Tester" && v.device === "headphones" && v.clips[0].verdict === "fix" && v.clips[0].landed === 4 && v.clips[0].device === "headphones", "verdict.json carries the answers");
+check(v.consent === true && v.clips.length === total, "consent recorded, every clip present");
+check(requests.length === 0, `no network requests (${requests.join(", ")})`);
+check(errors.length === 0, `no page errors (${errors.join("; ")})`);
+await browser.close();
diff --git a/tools/aqua-listen/test.mjs b/tools/aqua-listen/test.mjs
new file mode 100644
index 0000000..702e122
--- /dev/null
+++ b/tools/aqua-listen/test.mjs
@@ -0,0 +1,109 @@
+// node test.mjs: manifest validation, ordering, verdict export shape, and the built page's isolation. No browser; see README for the browser smoke.
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildKit } from "./build.mjs";
+
+const L = createRequire(import.meta.url)("./logic.cjs");
+let n = 0;
+const test = (name, fn) => { fn(); n++; console.log("ok", name); };
+const clip = (id, extra = {}) => ({ id, file: `${id}.wav`, title: `Title ${id}`, feel: "calm", group: "g", ...extra });
+const mf = (clips) => ({ kit: { id: "k1", title: "Kit" }, clips });
+const has = (m, re) => L.validateManifest(m).some((e) => re.test(e));
+
+test("a valid manifest has no problems", () => assert.deepEqual(L.validateManifest(mf([clip("a", { pair: "b" }), clip("b", { pair: "a" }), clip("c")])), []));
+test("manifest must be an object with clips", () => { assert.ok(L.validateManifest([]).length); assert.ok(has(mf([]), /non-empty/)); });
+test("kit id and title are required", () => { assert.ok(has({ kit: { id: "a b", title: "x" }, clips: [clip("a")] }, /kit\.id/)); assert.ok(has({ kit: { id: "a" }, clips: [clip("a")] }, /kit\.title/)); });
+test("every clip field is required", () => { for (const k of ["id", "file", "title", "feel", "group"]) { const c = clip("a"); delete c[k]; assert.ok(has(mf([c]), new RegExp(k + " is required")), k); } });
+test("duplicate ids are rejected", () => assert.ok(has(mf([clip("a"), clip("a", { file: "z.wav" })]), /duplicate id/)));
+test("files must be audio and stay inside the folder", () => {
+ assert.ok(has(mf([clip("a", { file: "a.txt" })]), /must be \.wav/));
+ assert.ok(has(mf([clip("a", { file: "../a.wav" })]), /relative path/));
+ assert.ok(has(mf([clip("a", { file: "/etc/a.wav" })]), /relative path/));
+ assert.ok(has(mf([clip("a", { file: "C:\\a.wav" })]), /relative path/));
+});
+test("pairs must exist, be mutual, and not be self", () => {
+ assert.ok(has(mf([clip("a", { pair: "zz" })]), /not a clip id/));
+ assert.ok(has(mf([clip("a", { pair: "b" }), clip("b")]), /does not point back/));
+ assert.ok(has(mf([clip("a", { pair: "b" }), clip("b", { pair: "c" }), clip("c", { pair: "b" })]), /does not point back/));
+ assert.ok(has(mf([clip("a", { pair: "a" })]), /itself/));
+});
+
+const clips = Array.from({ length: 12 }, (_, i) => clip("c" + i, i < 4 ? { pair: i % 2 ? "c" + (i - 1) : "c" + (i + 1) } : {}));
+test("order is deterministic per seed, covers every clip once, and varies by seed", () => {
+ const a = L.planOrder(clips, 7), b = L.planOrder(clips, 7);
+ assert.deepEqual(a, b);
+ assert.deepEqual(a.map((o) => o.id).sort(), clips.map((c) => c.id).sort());
+ assert.notDeepEqual(L.planOrder(clips, 8).map((o) => o.id), a.map((o) => o.id));
+});
+test("a pair is adjacent, slotted A then B, and both clips take position A across seeds", () => {
+ const firstA = new Set();
+ for (let s = 1; s <= 40; s++) {
+ const o = L.planOrder(clips, s);
+ o.forEach((x, i) => { if (x.slot === "A") { assert.equal(o[i + 1].slot, "B"); assert.equal(clips.find((c) => c.id === x.id).pair, o[i + 1].id); if (x.id === "c0" || x.id === "c1") firstA.add(x.id); } else if (x.slot === null) assert.ok(!clips.find((c) => c.id === x.id).pair); });
+ }
+ assert.deepEqual([...firstA].sort(), ["c0", "c1"]);
+});
+
+const manifest = mf([clip("a", { pair: "b", title: "Pipe | one" }), clip("b", { pair: "a" }), clip("c")]);
+test("verdict.json has the exported shape", () => {
+ const order = [{ id: "c", slot: null }, { id: "b", slot: "A" }, { id: "a", slot: "B" }];
+ const v = L.buildVerdict(manifest, order, { seed: 5, consent: true, listener: "Pat", device: "headphones", notes: "hi",
+ answers: { c: { verdict: "keep", reason: "clean", landed: 5, device: "headphones" }, a: { verdict: "bogus", landed: 9 } }, pairs: { "b|a": "B" } }, "2026-01-01T00:00:00Z");
+ assert.equal(v.schema, L.SCHEMA);
+ assert.deepEqual(Object.keys(v), ["schema", "kit", "exportedAt", "seed", "consent", "listener", "device", "notes", "summary", "clips", "pairs"]);
+ assert.deepEqual(v.summary, { total: 3, keep: 1, fix: 0, drop: 0, unanswered: 2 });
+ assert.deepEqual(Object.keys(v.clips[0]), ["position", "id", "title", "group", "feel", "file", "pair", "slot", "verdict", "reason", "landed", "device"]);
+ assert.deepEqual(v.clips.map((c) => c.id), ["c", "b", "a"]);
+ assert.equal(v.clips[0].verdict, "keep"); assert.equal(v.clips[0].landed, 5);
+ assert.equal(v.clips[2].verdict, null, "an unknown verdict is blank, not passed through"); assert.equal(v.clips[2].landed, null, "landed outside 1-5 is blank");
+ assert.deepEqual(v.pairs, [{ a: "b", b: "a", preferred: "a" }], "pair choice B means the clip shown second");
+ JSON.parse(JSON.stringify(v));
+});
+test("verdict.md lists every clip, escapes pipes, and states the totals", () => {
+ const order = L.planOrder(manifest.clips, 3);
+ const v = L.buildVerdict(manifest, order, { seed: 3, consent: true, answers: { c: { verdict: "drop", reason: "line1\nline2 | x", landed: 1 } }, pairs: {} }, "t");
+ const md = L.verdictMarkdown(v);
+ assert.match(md, /^# Listening verdict: Kit/);
+ assert.match(md, /0 keep, 0 fix, 1 drop, 2 unanswered\*\* of 3/);
+ assert.match(md, /Pipe \\\| one/); assert.match(md, /line1 line2 \\\| x/);
+ assert.equal(md.split("\n").filter((l) => /^\| \d+ \|/.test(l)).length, 3);
+ assert.match(md, /## A\/B pairs/);
+});
+
+const dir = mkdtempSync(join(tmpdir(), "aqua-listen-"));
+const wav = Buffer.alloc(44 + 400); wav.write("RIFF", 0); wav.write("WAVE", 8);
+for (const id of ["a", "b", "c"]) writeFileSync(join(dir, id + ".wav"), wav);
+test("the built page is one file with no network reach", () => {
+ const r = buildKit(manifest, dir), h = r.html;
+ assert.match(h, /Content-Security-Policy" content="default-src 'none'/);
+ assert.doesNotMatch(h, /connect-src|img-src|]*\ssrc=|
(.*?)<\/script>/s.exec(h)[1]);
+ assert.equal(Buffer.from(data.audio.a.b64, "base64").compare(wav), 0);
+ assert.equal(data.hash, r.hash);
+});
+test("the page shows the consent note first and skips it only for a returning, consenting listener", () => {
+ const h = buildKit(manifest, dir).html;
+ assert.ok(h.indexOf("function consent()") < h.indexOf("function main()"));
+ assert.ok(h.includes("if (load() && load().consent) start(); else consent();"));
+ assert.ok(h.includes("sends nothing anywhere"));
+});
+test("the content hash changes when a clip changes", () => {
+ const h1 = buildKit(manifest, dir).hash; writeFileSync(join(dir, "c.wav"), Buffer.concat([wav, Buffer.from([1])]));
+ assert.notEqual(buildKit(manifest, dir).hash, h1);
+});
+test("build refuses a missing file and a manifest escape, naming them", () => {
+ assert.throws(() => buildKit(mf([clip("zz")]), dir), /file not found: zz\.wav/);
+ assert.throws(() => buildKit(mf([clip("a", { file: "../x.wav" })]), dir), /relative path/);
+ assert.throws(() => buildKit(mf([clip("a", { title: "" })]), dir), /title is required/);
+});
+test("markup in a title cannot break the page", () => {
+ const m = mf([clip("a", { title: "" , feel: ""})]);
+ const h = buildKit(m, dir).html;
+ assert.equal((h.match(/<\/script>/g) || []).length, 3);
+});
+console.log(`${n} tests passed`);
diff --git a/tools/aqua-listen/web/index.html b/tools/aqua-listen/web/index.html
new file mode 100644
index 0000000..b54a6bb
--- /dev/null
+++ b/tools/aqua-listen/web/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+/*TITLE*/
+
+
+
+
+
+
+
+
+
diff --git a/tools/aqua-listen/web/page.js b/tools/aqua-listen/web/page.js
new file mode 100644
index 0000000..e232f3a
--- /dev/null
+++ b/tools/aqua-listen/web/page.js
@@ -0,0 +1,109 @@
+// Page glue. The kit (manifest, content hash, base64 audio) is in #kit-data; logic is AquaListen (logic.cjs, inlined).
+(() => {
+ const L = globalThis.AquaListen, kit = JSON.parse(document.getElementById("kit-data").textContent);
+ const app = document.getElementById("app"), KEY = `aqua-listen:${kit.manifest.kit.id}:${kit.hash}`;
+ let state = null, order = null;
+
+ const save = () => { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* private mode: the export still works */ } };
+ const load = () => { try { const s = JSON.parse(localStorage.getItem(KEY)); return s && s.seed ? s : null; } catch (e) { return null; } };
+ const h = (tag, attrs, ...kids) => {
+ const e = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) { if (k === "class") e.className = v; else if (k.startsWith("on")) e.addEventListener(k.slice(2), v); else if (v !== false && v != null) e.setAttribute(k, v === true ? "" : v); }
+ for (const k of kids.flat()) e.append(k);
+ return e;
+ };
+ const blobUrl = (id) => {
+ const a = kit.audio[id], bin = atob(a.b64), u = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
+ return URL.createObjectURL(new Blob([u], { type: a.mime }));
+ };
+
+ function consent() {
+ const box = h("input", { type: "checkbox", id: "consent" }), go = h("button", { disabled: true, onclick: start }, "Start listening");
+ box.addEventListener("change", () => { go.disabled = !box.checked; });
+ app.replaceChildren(
+ h("h1", {}, kit.manifest.kit.title),
+ h("div", { class: "card" },
+ h("p", {}, h("strong", {}, "Before you start. ")),
+ h("p", {}, `You will hear ${kit.manifest.clips.length} short sounds in a random order and say what you think of each. Your answers help decide which sounds get kept, fixed or dropped.`),
+ h("ul", {},
+ h("li", {}, "This page works on its own. It talks to no server, has no account and no tracking, and sends nothing anywhere."),
+ h("li", {}, "Your answers are saved in this browser only, so you can close the tab and come back. Clearing your browser data erases them."),
+ h("li", {}, "Nothing leaves your computer until you press Export, which saves two files. Sending those files to the person who gave you this page is your choice."),
+ h("li", {}, "You can stay anonymous. A name or nickname is optional.")),
+ h("p", {}, h("label", {}, box, " I understand, and I'm happy to take part.")),
+ h("p", {}, h("label", {}, "Name or nickname (optional) ", h("input", { type: "text", id: "alias", maxlength: "60" }))),
+ go));
+ }
+
+ function start() {
+ state = load() || { seed: (crypto.getRandomValues(new Uint32Array(1))[0] || 1), answers: {}, pairs: {}, device: null, notes: "", listener: "" };
+ state.consent = true; state.listener = document.getElementById("alias") ? document.getElementById("alias").value.trim() || state.listener : state.listener;
+ save(); main();
+ }
+
+ const answered = (id) => { const a = state.answers[id]; return !!(a && a.verdict && a.landed); };
+ function touch(id, patch) {
+ const a = state.answers[id] = Object.assign({}, state.answers[id], patch);
+ if (!a.device && state.device) a.device = state.device;
+ save(); progress();
+ const card = document.getElementById("clip-" + id); if (card) card.classList.toggle("done", answered(id));
+ }
+ function radios(name, values, current, onpick, labels) {
+ return values.map((v) => { const r = h("input", { type: "radio", name, value: String(v) }); if (String(current) === String(v)) r.checked = true; r.addEventListener("change", () => onpick(v)); return h("label", {}, r, " ", labels ? labels[v] : String(v)); });
+ }
+
+ function clipCard(o, n) {
+ const c = kit.manifest.clips.find((x) => x.id === o.id), a = state.answers[o.id] || {}, id = o.id;
+ const audio = h("audio", { controls: true, preload: "none" }); audio.src = blobUrl(id);
+ const reason = h("input", { type: "text", maxlength: "300", value: a.reason || "", placeholder: "One line: why?" });
+ reason.addEventListener("input", () => touch(id, { reason: reason.value }));
+ return h("section", { class: "card" + (answered(id) ? " done" : ""), id: "clip-" + id },
+ h("h2", {}, o.slot ? `Sound ${o.slot}` : `Sound ${n}`),
+ h("p", { class: "feel" }, `It should feel: ${c.feel}`),
+ audio,
+ h("div", { class: "row" }, h("span", {}, "Keep, fix or drop?"), radios("v-" + id, L.VERDICTS, a.verdict, (v) => touch(id, { verdict: v }))),
+ h("div", { class: "row" }, h("span", {}, "Did it land? 1 = not at all, 5 = completely"), radios("l-" + id, [1, 2, 3, 4, 5], a.landed, (v) => touch(id, { landed: Number(v) }))),
+ reason);
+ }
+
+ function main() {
+ order = L.planOrder(kit.manifest.clips, state.seed);
+ const byId = new Map(kit.manifest.clips.map((c) => [c.id, c]));
+ const prog = h("span", { id: "progress" });
+ const dev = h("span", {}, "I'm listening on: ", radios("device", L.DEVICES, state.device, (v) => { state.device = v; save(); }));
+ const cards = []; let n = 0;
+ for (let i = 0; i < order.length; i++) {
+ const o = order[i];
+ if (o.slot === "A") {
+ const b = order[i + 1], key = `${o.id}|${byId.get(o.id).pair}`;
+ const pick = h("div", { class: "row" }, h("span", {}, "Which do you prefer?"), radios("p-" + key, ["A", "B", "same"], state.pairs[key], (v) => { state.pairs[key] = v; save(); }, { A: "A", B: "B", same: "No difference" }));
+ n++; cards.push(h("div", { class: "pair" }, h("h3", {}, `Pair ${n}: A and B`), clipCard(o, n), clipCard(b, n), pick)); i++;
+ } else { n++; cards.push(clipCard(o, n)); }
+ }
+ const notes = h("textarea", { rows: "3", placeholder: "Anything else? (optional)" }, state.notes || ""); notes.addEventListener("input", () => { state.notes = notes.value; save(); });
+ app.replaceChildren(
+ h("h1", {}, kit.manifest.kit.title),
+ kit.manifest.kit.note ? h("p", { class: "hint" }, kit.manifest.kit.note) : "",
+ h("div", { class: "bar" }, h("div", { class: "row" }, dev, prog)),
+ ...cards,
+ h("div", { class: "card" }, h("h2", {}, "Finished?"), notes,
+ h("p", { class: "hint" }, "Export saves verdict.json and verdict.md to your downloads. Unanswered sounds are exported as blank."),
+ h("div", { class: "row" }, h("button", { onclick: exportAll }, "Export verdict.json + verdict.md"), h("button", { class: "quiet", onclick: reset }, "Erase my answers"))));
+ progress();
+ }
+
+ function progress() { const e = document.getElementById("progress"); if (e) e.textContent = `${kit.manifest.clips.filter((c) => answered(c.id)).length} of ${kit.manifest.clips.length} answered`; }
+ function download(name, text, type) {
+ const a = h("a", { href: URL.createObjectURL(new Blob([text], { type })), download: name });
+ document.body.append(a); a.click(); a.remove();
+ }
+ function exportAll() {
+ const v = L.buildVerdict(kit.manifest, order, state, new Date().toISOString());
+ download("verdict.json", JSON.stringify(v, null, 2) + "\n", "application/json");
+ download("verdict.md", L.verdictMarkdown(v), "text/markdown");
+ }
+ function reset() { if (confirm("Erase every answer you've given in this browser?")) { try { localStorage.removeItem(KEY); } catch (e) {} location.reload(); } }
+
+ if (load() && load().consent) start(); else consent();
+})();
diff --git a/tools/aqua-listen/web/styles.css b/tools/aqua-listen/web/styles.css
new file mode 100644
index 0000000..00652d8
--- /dev/null
+++ b/tools/aqua-listen/web/styles.css
@@ -0,0 +1,18 @@
+:root { color-scheme: light dark; --fg: #1d1f24; --bg: #f6f5f1; --card: #fff; --line: #cfcdc4; --accent: #1f5f8b; }
+@media (prefers-color-scheme: dark) { :root { --fg: #e8e6df; --bg: #17181b; --card: #212328; --line: #3a3d44; --accent: #6db3e0; } }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, sans-serif; }
+main { max-width: 46rem; margin: 0 auto; padding: 1rem; }
+h1 { font-size: 1.4rem; margin: 0.5rem 0; }
+.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 1rem; margin: 1rem 0; }
+.card.done { border-left: 6px solid var(--accent); }
+.feel { font-style: italic; }
+.row { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: center; margin: 0.5rem 0; }
+.row label { white-space: nowrap; }
+input[type=text], textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 0.4rem; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 4px; }
+audio { width: 100%; }
+button { font: inherit; padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
+button.quiet { background: transparent; color: var(--accent); }
+button:disabled { opacity: 0.4; cursor: default; }
+.bar { position: sticky; top: 0; background: var(--bg); padding: 0.5rem 0; border-bottom: 1px solid var(--line); z-index: 1; }
+.pair { border: 2px dashed var(--line); border-radius: 10px; padding: 0 0.75rem; margin: 1rem 0; }
+small, .hint { opacity: 0.75; }