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