aqua-listen: listening-test kit (one self-contained page, verdict export) #16
9 changed files with 513 additions and 0 deletions
59
tools/aqua-listen/README.md
Normal file
59
tools/aqua-listen/README.md
Normal 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`
|
||||
54
tools/aqua-listen/build.mjs
Normal file
54
tools/aqua-listen/build.mjs
Normal 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, "&").replace(/</g, "<");
|
||||
// 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
103
tools/aqua-listen/logic.cjs
Normal 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 };
|
||||
7
tools/aqua-listen/package.json
Normal file
7
tools/aqua-listen/package.json
Normal 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" }
|
||||
}
|
||||
38
tools/aqua-listen/smoke.mjs
Normal file
38
tools/aqua-listen/smoke.mjs
Normal 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
109
tools/aqua-listen/test.mjs
Normal 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`);
|
||||
16
tools/aqua-listen/web/index.html
Normal file
16
tools/aqua-listen/web/index.html
Normal 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>
|
||||
109
tools/aqua-listen/web/page.js
Normal file
109
tools/aqua-listen/web/page.js
Normal 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();
|
||||
})();
|
||||
18
tools/aqua-listen/web/styles.css
Normal file
18
tools/aqua-listen/web/styles.css
Normal 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; }
|
||||
Loading…
Reference in a new issue