aqua-listen: listening-test kit (one self-contained page, verdict export) #16

Merged
aqua merged 1 commit from aqua/listen-kit into main 2026-11-10 17:08:38 +00:00
9 changed files with 513 additions and 0 deletions

View file

@ -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`

View file

@ -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(/</g, "\\u003c");
const logic = `globalThis.AquaListen=(()=>{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, "&amp;").replace(/</g, "&lt;");
// function replacers: no `$` patterns in the substituted text are interpreted
const html = part("index.html")
.replace("/*TITLE*/", () => 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); }
}

103
tools/aqua-listen/logic.cjs Normal file
View file

@ -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 };

View file

@ -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" }
}

View file

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

109
tools/aqua-listen/test.mjs Normal file
View file

@ -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|<link |<script[^>]*\ssrc=|<img |<iframe|@import|url\(/);
assert.doesNotMatch(h, /\b(fetch|XMLHttpRequest|WebSocket|sendBeacon)\b/);
assert.equal((h.match(/<script/g) || []).length, 3);
const data = JSON.parse(/<script id="kit-data" type="application\/json">(.*?)<\/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: "</script><b>" , feel: "</script>"})]);
const h = buildKit(m, dir).html;
assert.equal((h.match(/<\/script>/g) || []).length, 3);
});
console.log(`${n} tests passed`);

View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; media-src blob:">
<title>/*TITLE*/</title>
<style>/*STYLES*/</style>
</head>
<body>
<main id="app"></main>
<script id="kit-data" type="application/json">/*DATA*/</script>
<script>/*LOGIC*/</script>
<script>/*PAGE*/</script>
</body>
</html>

View file

@ -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();
})();

View file

@ -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; }