From 7593917e324e7d41c71ea353eb66e3fcd24b1914 Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 4 Nov 2026 21:34:18 +0000 Subject: [PATCH 1/2] Add blinded A/B listening trial page for the thruster noise sweep Static page, trial patch (thruster + noise control), shared logic, dependency-free analyzer, node tests. Never run in a browser; nothing has been heard. Co-Authored-By: Claude Sonnet 5.5 --- tools/aqua-trial/.gitignore | 1 + tools/aqua-trial/README.md | 37 ++++++ tools/aqua-trial/analyze.mjs | 95 +++++++++++++++ tools/aqua-trial/build.mjs | 27 +++++ tools/aqua-trial/logic.mjs | 81 +++++++++++++ tools/aqua-trial/package.json | 7 ++ tools/aqua-trial/test.mjs | 174 ++++++++++++++++++++++++++++ tools/aqua-trial/thruster-trial.dsp | 32 +++++ tools/aqua-trial/web/index.html | 49 ++++++++ tools/aqua-trial/web/styles.css | 21 ++++ tools/aqua-trial/web/trial.js | 109 +++++++++++++++++ 11 files changed, 633 insertions(+) create mode 100644 tools/aqua-trial/.gitignore create mode 100644 tools/aqua-trial/README.md create mode 100644 tools/aqua-trial/analyze.mjs create mode 100644 tools/aqua-trial/build.mjs create mode 100644 tools/aqua-trial/logic.mjs create mode 100644 tools/aqua-trial/package.json create mode 100644 tools/aqua-trial/test.mjs create mode 100644 tools/aqua-trial/thruster-trial.dsp create mode 100644 tools/aqua-trial/web/index.html create mode 100644 tools/aqua-trial/web/styles.css create mode 100644 tools/aqua-trial/web/trial.js diff --git a/tools/aqua-trial/.gitignore b/tools/aqua-trial/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/tools/aqua-trial/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/tools/aqua-trial/README.md b/tools/aqua-trial/README.md new file mode 100644 index 0000000..0870e58 --- /dev/null +++ b/tools/aqua-trial/README.md @@ -0,0 +1,37 @@ +# AquaSynth listening trial + +Blinded, randomized A/B ratings of a synthesized thruster, to test Deru's "dead radio" finding: that the +thruster's noise layers (rasp, rattle) are what makes it sound dead, and that a tone-forward mix lands better. + +**Status: the page has never been run in a browser, and nothing has been heard.** Node covers the logic and +the patch through the same faustwasm runtime the page loads (`node test.mjs`, 15 checks). The DOM glue +(`web/trial.js`), AudioContext playback and the download are untested. Follow-up: browser-verify. + +## Entry points + +- `web/`: the page. Consent checkbox gates Start; the page compiles `thruster-trial.dsp` in-browser, renders four + 4 s variants at the browser's sample rate, and runs six comparisons (every pair once, order and A/B position + randomized from a random seed). Per sound: rating 1-5 ("does it land?") and one word. Variant identity is never shown. +- `thruster-trial.dsp`: `patches/thruster/thruster.dsp` plus a `noise` control (percent). `noise` scales the rasp and + rattle layers only; the rumble (tone) is untouched, so lowering it moves the mix tone-forward. That reading of + "tone-forward mix" is this author's; at 100 the signal equals the thruster (asserted sample for sample). + Variants: rpm 600, noise 100 / 50 / 20 / 0. Not loudness-matched, so level and noise are confounded by design. +- `logic.mjs`: design, statistics, validation and export shape (shared by page and tests). +- `analyze.mjs`: dependency-free. `node analyze.mjs export1.json [export2.json ...]` prints per-variant n, mean, + sd, min/max, peak, rms, words, and whether peak / rms predicted the ratings (Spearman rho, permutation p; + "predicts" means |rho| >= 0.5 and p < 0.05, fixed before any data). +- `build.mjs`: assembles `dist/` and prints the content hash used in the publish path. Needs + `(cd ../aqua-playground && npm ci)` for the faustwasm runtime. `test.mjs` needs `dist/`. + +## Privacy + +Answers live in the page's memory only. The page makes no network request after loading its own assets and +writes nothing to storage. **Export** downloads a JSON file (patch, params, peak, rms, rating, word, time, +user agent, sample rate); sending it is the listener's act. + +## Measured finding before any listening + +Peak and rms hardly move across the sweep (48 kHz: rms 0.0734 / 0.0712 / 0.0706 / 0.0705; peak 0.189 / 0.161 / +0.148 / 0.140). At rpm 600 the rumble carries nearly all the energy, so these four variants differ mostly in +spectral texture, not level. That makes a peak/rms "prediction" of ratings less likely a priori, and the trial +a real test of whether the texture difference is audible. diff --git a/tools/aqua-trial/analyze.mjs b/tools/aqua-trial/analyze.mjs new file mode 100644 index 0000000..52b73ea --- /dev/null +++ b/tools/aqua-trial/analyze.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Dependency-free report over one or more trial exports: per-variant mean/spread of the "does it land?" rating, +// and whether render peak / rms predicted the ratings. Usage: node analyze.mjs export1.json [export2.json ...] +// Pure functions are exported for test.mjs; the CLI runs only when this file is the entry point. +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { rng, shuffle, SCHEMA } from "./logic.mjs"; + +export const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; +export const sd = (xs) => (xs.length < 2 ? NaN : Math.sqrt(xs.reduce((a, x) => a + (x - mean(xs)) ** 2, 0) / (xs.length - 1))); + +export function pearson(xs, ys) { + const mx = mean(xs), my = mean(ys); + let sxy = 0, sxx = 0, syy = 0; + xs.forEach((x, i) => { sxy += (x - mx) * (ys[i] - my); sxx += (x - mx) ** 2; syy += (ys[i] - my) ** 2; }); + return sxx === 0 || syy === 0 ? NaN : sxy / Math.sqrt(sxx * syy); +} + +// Average ranks (ties share the mean rank), so Spearman is Pearson over ranks. +export function ranks(xs) { + const order = xs.map((v, i) => [v, i]).sort((a, b) => a[0] - b[0]); + const r = new Array(xs.length); + for (let i = 0; i < order.length;) { + let j = i; + while (j + 1 < order.length && order[j + 1][0] === order[i][0]) j++; + for (let k = i; k <= j; k++) r[order[k][1]] = (i + j) / 2 + 1; + i = j + 1; + } + return r; +} +export const spearman = (xs, ys) => pearson(ranks(xs), ranks(ys)); + +// Two-sided permutation p-value for Spearman rho; seeded so the report is reproducible. +export function permutationP(xs, ys, rho, rounds = 5000) { + if (!Number.isFinite(rho)) return NaN; + const rand = rng(12345); + let hits = 0; + for (let i = 0; i < rounds; i++) if (Math.abs(spearman(xs, shuffle(ys, rand))) >= Math.abs(rho) - 1e-12) hits++; + return (hits + 1) / (rounds + 1); +} + +export function predictor(records, key) { + const xs = records.map((r) => r[key]), ys = records.map((r) => r.rating); + const rho = spearman(xs, ys); + const p = permutationP(xs, ys, rho); + // Threshold chosen before any data exists: |rho| >= 0.5 and p < 0.05. With few records this is deliberately hard to meet. + const predicts = Number.isFinite(rho) && Math.abs(rho) >= 0.5 && p < 0.05; + return { key, n: records.length, rho, p, predicts }; +} + +export function analyze(exports) { + const records = exports.flatMap((e, i) => e.records.map((r) => ({ ...r, listener: i }))); + const byVariant = new Map(); + for (const r of records) (byVariant.get(r.variant) ?? byVariant.set(r.variant, []).get(r.variant)).push(r); + const variants = [...byVariant.entries()].map(([id, rs]) => { + const ratings = rs.map((r) => r.rating); + const words = {}; + for (const r of rs) { const w = r.word.toLowerCase(); words[w] = (words[w] ?? 0) + 1; } + return { + id, noise: rs[0].params.noise, n: ratings.length, mean: mean(ratings), sd: sd(ratings), + min: Math.min(...ratings), max: Math.max(...ratings), + peak: mean(rs.map((r) => r.peak)), rms: mean(rs.map((r) => r.rms)), words, + }; + }).sort((a, b) => b.noise - a.noise); + const predictors = ["peak", "rms"].map((k) => predictor(records, k)); + // Context: the manipulated variable itself, so a peak/rms "prediction" can be told apart from noise-amount doing the work. + const noise = predictor(records.map((r) => ({ ...r, noise: r.params.noise })), "noise"); + return { listeners: exports.length, records: records.length, variants, predictors, noise }; +} + +const f = (x, d = 3) => (Number.isFinite(x) ? x.toFixed(d) : "n/a"); + +export function format(rep) { + const out = [`${rep.listeners} listener export(s), ${rep.records} ratings`, "", "variant noise% n mean sd min max peak rms words"]; + for (const v of rep.variants) { + const words = Object.entries(v.words).sort((a, b) => b[1] - a[1]).map(([w, c]) => (c > 1 ? `${w}x${c}` : w)).join(","); + out.push(`${v.id.padEnd(11)} ${String(v.noise).padStart(5)} ${String(v.n).padStart(4)} ${f(v.mean, 2).padStart(6)} ${f(v.sd, 2).padStart(6)} ${String(v.min).padStart(4)} ${String(v.max).padStart(3)} ${f(v.peak).padStart(7)} ${f(v.rms).padStart(7)} ${words}`); + } + out.push("", "Did render level predict the rating? (Spearman rho over individual ratings, permutation p; predicts = |rho|>=0.5 and p<0.05)"); + for (const p of rep.predictors) out.push(` ${p.key.padEnd(5)} rho ${f(p.rho, 2).padStart(6)} p ${f(p.p, 4)} n ${p.n} -> ${p.predicts ? "PREDICTS" : "no evidence it predicts"}`); + out.push(` (context) noise% rho ${f(rep.noise.rho, 2)} p ${f(rep.noise.p, 4)}`); + out.push("", "Peak and rms fall with noise amount here (noise is not loudness-compensated), so a level effect and a noise effect are confounded, and this script does not separate them."); + return out.join("\n"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const files = process.argv.slice(2); + if (!files.length) { console.error("usage: node analyze.mjs export.json [more.json ...]"); process.exit(2); } + const exports = files.map((p) => { + const e = JSON.parse(readFileSync(p, "utf8")); + if (e.schema !== SCHEMA) throw new Error(`${p}: schema ${e.schema}, expected ${SCHEMA}`); + return e; + }); + console.log(format(analyze(exports))); +} diff --git a/tools/aqua-trial/build.mjs b/tools/aqua-trial/build.mjs new file mode 100644 index 0000000..fa5c41f --- /dev/null +++ b/tools/aqua-trial/build.mjs @@ -0,0 +1,27 @@ +// Assembles dist/ (the static trial site): web/, logic.mjs, the trial patch, and the faustwasm runtime copied from +// the sibling aqua-playground's node_modules (run `npm install` there first). Prints a content hash for the +// publish path: minds.gamecult.org/aqua/trial-/. Usage: node build.mjs [outDir] +import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = resolve(process.argv[2] ?? join(here, "dist")); +const play = join(here, "../aqua-playground"); +const fw = join(play, "node_modules/@grame/faustwasm"); +rmSync(out, { recursive: true, force: true }); +mkdirSync(join(out, "vendor/faustwasm"), { recursive: true }); +cpSync(join(here, "web"), out, { recursive: true }); +cpSync(join(here, "logic.mjs"), join(out, "logic.mjs")); +cpSync(join(here, "thruster-trial.dsp"), join(out, "thruster-trial.dsp")); +cpSync(join(play, "web/ui.js"), join(out, "ui.js")); +cpSync(join(fw, "dist/esm/index.js"), join(out, "vendor/faustwasm/index.js")); +cpSync(join(fw, "COPYING.txt"), join(out, "vendor/faustwasm/COPYING.txt")); +for (const f of ["libfaust-wasm.js", "libfaust-wasm.wasm", "libfaust-wasm.data"]) + cpSync(join(fw, "libfaust-wasm", f), join(out, "vendor/libfaust-wasm", f)); + +const files = (d) => readdirSync(d).sort().flatMap((n) => (statSync(join(d, n)).isDirectory() ? files(join(d, n)) : [join(d, n)])); +const h = createHash("sha256"); +for (const f of files(out)) { h.update(relative(out, f)); h.update(readFileSync(f)); } +console.log(`built ${out}\nhash ${h.digest("hex").slice(0, 7)}`); diff --git a/tools/aqua-trial/logic.mjs b/tools/aqua-trial/logic.mjs new file mode 100644 index 0000000..aa24dd1 --- /dev/null +++ b/tools/aqua-trial/logic.mjs @@ -0,0 +1,81 @@ +// Trial logic shared by the browser page (web/trial.js) and the node tests: variants, the blinded pair design, +// render statistics, one-word validation and the export document. No DOM, no Faust import; the renderer is passed in. + +export const SCHEMA = "aquasynth.listening-trial.v1"; +export const PATCH = "thruster-trial"; +export const RPM = 600; +export const NOISE_PERCENTS = [100, 50, 20, 0]; +export const SECONDS = 4; + +export const variants = () => NOISE_PERCENTS.map((noise) => ({ id: `noise${noise}`, params: { rpm: RPM, noise } })); + +// mulberry32: small seeded PRNG so a session (and its tests) can be replayed from the seed. +export function rng(seed) { + 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; + }; +} + +export function shuffle(items, rand) { + const a = [...items]; + for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } + return a; +} + +// Every unordered pair of variants once, pair order shuffled, A/B assignment coin-flipped per pair. +// Each variant therefore appears in n-1 trials, and which one is A is independent of which one is quieter. +export function makeDesign(seed, vs = variants()) { + const rand = rng(seed); + const pairs = []; + for (let i = 0; i < vs.length; i++) for (let j = i + 1; j < vs.length; j++) pairs.push(rand() < 0.5 ? [vs[i], vs[j]] : [vs[j], vs[i]]); + return shuffle(pairs, rand).map(([a, b]) => ({ A: a, B: b })); +} + +export function stats(samples) { + let peak = 0, sq = 0, bad = 0; + for (const v of samples) { if (!Number.isFinite(v)) { bad++; continue; } peak = Math.max(peak, Math.abs(v)); sq += v * v; } + return { peak, rms: Math.sqrt(sq / Math.max(1, samples.length - bad)), nonFinite: bad }; +} + +// `generator` is a compiled faustwasm FaustMonoDspGenerator; `controls` are controlsOf(...) of its JSON. +export async function renderVariant(generator, controls, variant, sampleRate, seconds = SECONDS) { + const p = await generator.createOfflineProcessor(sampleRate, 512); + for (const [label, value] of Object.entries(variant.params)) { + const c = controls.find((x) => x.label === label); + if (!c) throw new Error(`patch has no control "${label}"`); + p.setParamValue(c.address, value); + } + const samples = p.render(null, Math.round(sampleRate * seconds))[0]; + return { samples, ...stats(samples) }; +} + +// One free word: trimmed, no inner whitespace, bounded. Returns the word or null. +export function cleanWord(text) { + const w = String(text ?? "").trim(); + return w.length > 0 && w.length <= 32 && !/\s/.test(w) ? w : null; +} + +export const validRating = (n) => Number.isInteger(n) && n >= 1 && n <= 5; + +// answers: [{ A: {rating, word, time, plays}, B: {...} }] aligned with design. +export function buildExport({ seed, design, renders, answers, sampleRate, userAgent, exportedAt }) { + const records = []; + design.forEach((trial, t) => { + for (const pos of ["A", "B"]) { + const a = answers[t]?.[pos]; + if (!a) continue; + const v = trial[pos]; + records.push({ + trial: t + 1, position: pos, patch: PATCH, variant: v.id, params: v.params, + peak: renders[v.id].peak, rms: renders[v.id].rms, + rating: a.rating, word: a.word, time: a.time, plays: a.plays, + }); + } + }); + return { schema: SCHEMA, patch: PATCH, seed, sampleRate, userAgent, exportedAt, seconds: SECONDS, records }; +} diff --git a/tools/aqua-trial/package.json b/tools/aqua-trial/package.json new file mode 100644 index 0000000..1edf509 --- /dev/null +++ b/tools/aqua-trial/package.json @@ -0,0 +1,7 @@ +{ + "name": "aqua-trial", + "private": true, + "type": "module", + "description": "Blinded A/B listening trial for AquaSynth patches (thruster noise-amount sweep). Static page; analysis script is dependency-free.", + "scripts": { "build": "node build.mjs", "test": "node test.mjs" } +} diff --git a/tools/aqua-trial/test.mjs b/tools/aqua-trial/test.mjs new file mode 100644 index 0000000..7570a86 --- /dev/null +++ b/tools/aqua-trial/test.mjs @@ -0,0 +1,174 @@ +// Node checks of the trial: design, validation, export shape, analyzer maths, and (through the same faustwasm runtime +// the page loads) the trial patch itself. It never runs the page in a browser and never plays or hears anything. +// Usage: (cd ../aqua-playground && npm ci) && node build.mjs && node test.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { makeDesign, variants, stats, cleanWord, validRating, buildExport, renderVariant, rng, shuffle, SCHEMA, PATCH } from "./logic.mjs"; +import { analyze, format, mean, sd, spearman, ranks, pearson } from "./analyze.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(process.argv[2] ?? join(here, "dist")); +let n = 0; +const t = async (name, fn) => { await fn(); n++; console.log(`ok ${name}`); }; + +await t("design: every unordered pair once, both positions used, replayable from the seed", () => { + const d = makeDesign(7); + assert.equal(d.length, 6); + const keys = d.map((x) => [x.A.id, x.B.id].sort().join("|")); + assert.equal(new Set(keys).size, 6); + for (const v of variants()) assert.equal(d.filter((x) => x.A.id === v.id || x.B.id === v.id).length, 3); + assert.deepEqual(makeDesign(7), d); + // position is not tied to variant: over many seeds each variant lands in A about half the time + const inA = Object.fromEntries(variants().map((v) => [v.id, 0])); + for (let s = 0; s < 2000; s++) for (const x of makeDesign(s)) inA[x.A.id]++; + for (const c of Object.values(inA)) assert.ok(Math.abs(c / 6000 - 0.5) < 0.05, `A share ${c / 6000}`); + assert.notDeepEqual(makeDesign(1).map((x) => x.A.id), makeDesign(2).map((x) => x.A.id)); +}); + +await t("variants: rpm 600 and noise 100/50/20/0", () => { + assert.deepEqual(variants().map((v) => v.params), [100, 50, 20, 0].map((noise) => ({ rpm: 600, noise }))); +}); + +await t("stats: peak, rms, non-finite counted and excluded", () => { + const s = stats([0, 1, -1, 0]); + assert.equal(s.peak, 1); assert.ok(Math.abs(s.rms - Math.sqrt(0.5)) < 1e-12); + assert.equal(stats([1, NaN, Infinity]).nonFinite, 2); +}); + +await t("one word: trimmed, no inner whitespace, bounded; ratings 1-5 integers", () => { + assert.equal(cleanWord(" hollow "), "hollow"); + for (const bad of ["", " ", "two words", "a".repeat(33), null, undefined]) assert.equal(cleanWord(bad), null); + assert.deepEqual([0, 1, 5, 6, 2.5, "3"].map(validRating), [false, true, true, false, false, false]); +}); + +const fakeExport = (seed, rate) => { + const design = makeDesign(seed), rand = rng(seed); + const renders = { noise100: { peak: 0.19, rms: 0.073 }, noise50: { peak: 0.15, rms: 0.05 }, noise20: { peak: 0.12, rms: 0.04 }, noise0: { peak: 0.1, rms: 0.035 } }; + const answers = design.map(() => Object.fromEntries(["A", "B"].map((p) => [p, { rating: 1 + Math.floor(rand() * 5), word: "w", time: "2026-11-04T00:00:00Z", plays: 1 }]))); + return { design, renders, answers, doc: buildExport({ seed, design, renders, answers, sampleRate: rate, userAgent: "test-ua", exportedAt: "2026-11-04T00:00:00Z" }) }; +}; + +await t("export: one record per heard sound with the required fields", () => { + const { doc, design, answers } = fakeExport(3, 44100); + assert.equal(doc.schema, SCHEMA); assert.equal(doc.sampleRate, 44100); assert.equal(doc.userAgent, "test-ua"); + assert.equal(doc.records.length, 12); + for (const r of doc.records) for (const k of ["patch", "params", "rating", "word", "time", "peak", "rms", "variant", "trial", "position"]) assert.ok(r[k] !== undefined, k); + const r = doc.records[0]; + assert.equal(r.variant, design[0].A.id); assert.equal(r.rating, answers[0].A.rating); assert.equal(r.patch, PATCH); + assert.deepEqual(JSON.parse(JSON.stringify(doc)), doc); +}); + +await t("export: unanswered trials are omitted, not invented", () => { + const { design, renders, answers } = fakeExport(3, 48000); + const doc = buildExport({ seed: 3, design, renders, answers: answers.slice(0, 2), sampleRate: 48000, userAgent: "u", exportedAt: "x" }); + assert.equal(doc.records.length, 4); +}); + +await t("analyze maths: ranks with ties, spearman, pearson, sd", () => { + assert.deepEqual(ranks([10, 20, 20, 30]), [1, 2.5, 2.5, 4]); + assert.equal(spearman([1, 2, 3, 4], [10, 20, 30, 40]), 1); + assert.equal(spearman([1, 2, 3, 4], [4, 3, 2, 1]), -1); + assert.ok(Number.isNaN(pearson([1, 1, 1], [1, 2, 3]))); + assert.ok(Math.abs(sd([2, 4, 4, 4, 5, 5, 7, 9]) - 2.138089935) < 1e-8); + assert.equal(mean([1, 2, 3]), 2); +}); + +const synth = (ratingOf, listeners = 12) => { + const exports = []; + for (let l = 0; l < listeners; l++) { + const e = fakeExport(100 + l, 48000).doc; + e.records.forEach((r) => { r.rating = ratingOf(r, l); }); + exports.push(e); + } + return exports; +}; + +await t("analyze: ratings that follow rms are reported as predicted by peak and rms", () => { + const rep = analyze(synth((r) => (r.rms > 0.06 ? 5 : r.rms > 0.045 ? 4 : r.rms > 0.037 ? 2 : 1))); + assert.equal(rep.records, 144); + assert.deepEqual(rep.variants.map((v) => v.noise), [100, 50, 20, 0]); + assert.ok(rep.variants[0].mean > rep.variants[3].mean); + for (const p of rep.predictors) assert.ok(p.predicts && p.rho > 0.9, `${p.key} rho ${p.rho}`); + assert.match(format(rep), /PREDICTS/); +}); + +await t("analyze: ratings unrelated to level are not reported as predicted", () => { + const rand = rng(99); + const rep = analyze(synth(() => 1 + Math.floor(rand() * 5))); + for (const p of rep.predictors) assert.ok(!p.predicts, `${p.key} rho ${p.rho} p ${p.p}`); + assert.match(format(rep), /no evidence/); +}); + +await t("analyze: per-variant mean and spread are computed per variant", () => { + const rep = analyze(synth((r) => (r.params.noise === 100 ? 5 : r.params.noise === 50 ? 3 : 1 + (r.trial % 2) * 2))); + const by = Object.fromEntries(rep.variants.map((v) => [v.id, v])); + assert.equal(by.noise100.mean, 5); assert.equal(by.noise100.sd, 0); + assert.equal(by.noise50.mean, 3); + assert.ok(by.noise20.sd > 0 && by.noise20.min === 1 && by.noise20.max === 3); +}); + +await t("analyze CLI: runs on export files and rejects a foreign schema", async () => { + const { execFileSync, spawnSync } = await import("node:child_process"); + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const d = mkdtempSync(join(tmpdir(), "trial-")); + const a = join(d, "a.json"), b = join(d, "b.json"); + writeFileSync(a, JSON.stringify(fakeExport(5, 48000).doc)); + writeFileSync(b, JSON.stringify({ ...fakeExport(5, 48000).doc, schema: "other" })); + assert.match(execFileSync("node", [join(here, "analyze.mjs"), a], { encoding: "utf8" }), /1 listener export/); + assert.notEqual(spawnSync("node", [join(here, "analyze.mjs"), b]).status, 0); + assert.equal(spawnSync("node", [join(here, "analyze.mjs")]).status, 2); +}); + +// --- the patch, through the runtime the page loads --- +const { instantiateFaustModuleFromFile, LibFaust, FaustCompiler, FaustMonoDspGenerator } = + await import(pathToFileURL(join(dist, "vendor/faustwasm/index.js")).href); +const { controlsOf } = await import(pathToFileURL(join(dist, "ui.js")).href); +const compiler = new FaustCompiler(new LibFaust(await instantiateFaustModuleFromFile(join(dist, "vendor/libfaust-wasm/libfaust-wasm.js")))); +const compile = async (path, name) => { + const g = new FaustMonoDspGenerator(); + assert.ok(await g.compile(compiler, name, readFileSync(path, "utf8"), "-ftz 2"), compiler.getErrorMessage()); + return { g, controls: controlsOf(JSON.parse(g.factory.json)) }; +}; +const trial = await compile(join(here, "thruster-trial.dsp"), "thruster_trial"); +const base = await compile(join(here, "../../patches/thruster/thruster.dsp"), "thruster"); +const SR = 48000; + +await t("patch: exposes rpm and noise controls", () => { + assert.ok(trial.controls.some((c) => c.label === "noise" && c.init === 100)); + assert.ok(trial.controls.some((c) => c.label === "rpm")); +}); + +await t("patch: noise=100 is sample-identical to patches/thruster/thruster.dsp at rpm 600", async () => { + const a = await renderVariant(trial.g, trial.controls, { params: { rpm: 600, noise: 100 } }, SR, 2); + const b = await renderVariant(base.g, base.controls, { params: { rpm: 600 } }, SR, 2); + assert.equal(a.samples.length, b.samples.length); + let maxDiff = 0; for (let i = 0; i < a.samples.length; i++) maxDiff = Math.max(maxDiff, Math.abs(a.samples[i] - b.samples[i])); + assert.ok(maxDiff < 1e-6, `max diff ${maxDiff}`); +}); + +await t("patch: all four variants finite and non-silent, rendered at 44100 and 48000, noise 0 is deterministic", async () => { + for (const rate of [44100, 48000]) { + const seen = []; + for (const v of variants()) { + const r = await renderVariant(trial.g, trial.controls, v, rate); + assert.equal(r.samples.length, rate * 4); + assert.equal(r.nonFinite, 0); assert.ok(r.peak > 1e-3, `${v.id} peak ${r.peak}`); + seen.push(r); + } + console.log(" " + variants().map((v, i) => `${v.id} peak ${seen[i].peak.toFixed(4)} rms ${seen[i].rms.toFixed(4)}`).join(" | ") + ` @${rate}`); + } + const x = await renderVariant(trial.g, trial.controls, variants()[3], SR, 1), y = await renderVariant(trial.g, trial.controls, variants()[3], SR, 1); + assert.deepEqual(x.samples, y.samples); +}); + +await t("patch: noise genuinely removes the noise layers (rms falls monotonically 100>50>20>0, rumble remains)", async () => { + const rms = []; + for (const v of variants()) rms.push((await renderVariant(trial.g, trial.controls, v, SR)).rms); + assert.ok(rms[0] > rms[1] && rms[1] > rms[2] && rms[2] > rms[3], rms.join(" > ")); + assert.ok(rms[3] > 1e-3); +}); + +console.log(`${n} passed`); diff --git a/tools/aqua-trial/thruster-trial.dsp b/tools/aqua-trial/thruster-trial.dsp new file mode 100644 index 0000000..672ffd8 --- /dev/null +++ b/tools/aqua-trial/thruster-trial.dsp @@ -0,0 +1,32 @@ +// thruster-trial: patches/thruster/thruster.dsp plus a `noise` control (percent, default 100) for the listening trial. +// `noise` scales the two noise-driven layers (rasp, rattle) and leaves the rumble (the tone) alone, so lowering it +// moves the mix tone-forward. At noise=100 it is the same signal as thruster.dsp (test.mjs asserts this sample for sample). +// Compile as Faust source; it is not a .aqua script. +// Control: rpm (300..6000). Rotor tone = 28 Hz + rpm*0.025 (about 36..180 Hz); three blades chop the rasp at 3x that. +declare name "thruster_trial"; +import("stdfaust.lib"); + +noise_amt = hslider("noise", 100, 0, 100, 1) / 100; +rpm = hslider("rpm", 1200, 300, 6000, 1) : si.smooth(ba.tau2pole(0.2)); +load = (rpm - 300) / 5700; +shaft = 28 + rpm * 0.025; + +// effort: slow random wander of the shaft speed +wander = no.noise : fi.lowpass(2, 5) : *(0.35); +f0 = shaft * (1 + 0.04 * wander); + +// low rumble: saw at shaft speed plus a square a sub-octave down, low-passed +rumble = (os.sawtooth(f0) + 0.7 * os.square(f0 * 0.5)) : fi.lowpass(3, 60 + 2.5 * shaft); + +// blade pass: rasp noise chopped at 3x shaft, band tracks rpm +chop = 0.5 + 0.5 * os.osc(f0 * 3) ^ 2; +rasp = no.noise : fi.resonbp(700 + rpm * 0.6, 1.5, 1) : *(chop); + +// brittle hull: high-Q modes rattled by noise, louder with load; tanh on the rattle alone keeps ring-up spikes from setting the peak +mode(f, q) = no.noise : fi.resonbp(f, q, 1); +rattle = (mode(173, 20) * (0.3 + 0.7 * load) + 0.8 * mode(311, 18) + 0.6 * mode(467, 16) + 0.4 * mode(1130, 14)) * (0.1 + 0.9 * load ^ 2) * 3 : *(4) : ma.tanh : /(4); + +mix = rumble * (0.5 + 0.5 * load) + noise_amt * (rasp * (0.3 + 1.5 * load) + rattle); +// drive falls only gently with load so tanh stays out of saturation while high rpm still ends up louder than low rpm +// final 0.425 trim keeps peak under 0.4 at rpm 4800 +process = mix * (0.4 + 0.6 * load) * (0.9 - 0.5 * load) : ma.tanh : *(0.425); diff --git a/tools/aqua-trial/web/index.html b/tools/aqua-trial/web/index.html new file mode 100644 index 0000000..0b96d43 --- /dev/null +++ b/tools/aqua-trial/web/index.html @@ -0,0 +1,49 @@ + + + + + + AquaSynth listening trial + + + +
+

AquaSynth lab

+

Does it land?

+

Six short comparisons of a synthesized spacecraft thruster. You hear two renders, A and B, in random order, and say how well each one lands: 1 means it does not, 5 means it does. Then give each a single word. Use headphones or speakers at a comfortable level, and a quiet room if you can.

+ + + + + + +
+ + + diff --git a/tools/aqua-trial/web/styles.css b/tools/aqua-trial/web/styles.css new file mode 100644 index 0000000..8c1ca47 --- /dev/null +++ b/tools/aqua-trial/web/styles.css @@ -0,0 +1,21 @@ +:root { color-scheme: dark; --bg:#0b1418; --fg:#d7e6ea; --dim:#7f9aa3; --acc:#3fc1c9; --line:#1f3640; } +* { box-sizing: border-box; } +body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; } +main { max-width:760px; margin:0 auto; padding:24px; } +.eyebrow { margin:0; color:var(--acc); text-transform:uppercase; letter-spacing:.12em; font-size:12px; } +h1 { margin:.1em 0 .2em; } h2 { font-size:18px; } +.lede, .hint, #status { color:var(--dim); } +.consent { display:flex; gap:10px; align-items:flex-start; margin-bottom:12px; } +.consent input { margin-top:5px; } +button { background:#10242b; color:var(--fg); border:1px solid var(--line); padding:8px 18px; border-radius:4px; font:inherit; } +button:hover:not(:disabled) { border-color:var(--acc); } button:disabled { opacity:.4; } +.pair { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; } +@media (max-width:560px) { .pair { grid-template-columns:1fr; } } +fieldset { border:1px solid var(--line); border-radius:6px; display:grid; gap:12px; padding:14px; } +legend { color:var(--acc); padding:0 6px; } +.stars { display:flex; gap:6px; } +.stars label { flex:1; text-align:center; border:1px solid var(--line); border-radius:4px; padding:6px 0; cursor:pointer; } +.stars input { position:absolute; opacity:0; } +.stars label:has(input:checked) { background:var(--acc); color:#06161a; border-color:var(--acc); } +.stars label:has(input:focus-visible) { outline:2px solid var(--acc); } +.word { background:#071013; color:var(--fg); border:1px solid var(--line); border-radius:4px; padding:8px; font:inherit; } diff --git a/tools/aqua-trial/web/trial.js b/tools/aqua-trial/web/trial.js new file mode 100644 index 0000000..ef4e721 --- /dev/null +++ b/tools/aqua-trial/web/trial.js @@ -0,0 +1,109 @@ +// Browser glue for the listening trial. The decisions (design, statistics, validation, export shape) live in +// ../logic.mjs and are tested in node; this file only touches the DOM, the AudioContext and the download. +// Nothing here makes a network request after the page assets load, and nothing is written to storage. +import { instantiateFaustModuleFromFile, LibFaust, FaustCompiler, FaustMonoDspGenerator } from "./vendor/faustwasm/index.js"; +import { controlsOf } from "./ui.js"; +import { variants, makeDesign, renderVariant, cleanWord, validRating, buildExport, PATCH } from "./logic.mjs"; + +const $ = (s, root = document) => root.querySelector(s); +const status = (t) => { $("#status").textContent = t; }; +const seed = crypto.getRandomValues(new Uint32Array(1))[0]; +const design = makeDesign(seed); +const answers = []; +const renders = {}; // variant id -> { buffer, peak, rms } +let ctx, source, current = 0, plays = { A: 0, B: 0 }; + +$("#agree").onchange = () => { $("#start").disabled = !$("#agree").checked; }; +$("#start").onclick = start; +$("#stop").onclick = stopSound; +$("#next").onclick = next; +$("#export").onclick = exportJson; +for (const fs of document.querySelectorAll("fieldset")) { + const stars = $(".stars", fs); + for (let n = 1; n <= 5; n++) { + const label = document.createElement("label"); + const input = Object.assign(document.createElement("input"), { type: "radio", name: `rate-${fs.dataset.pos}`, value: n }); + input.onchange = refresh; + label.append(input, String(n)); + stars.append(label); + } + $(".word", fs).oninput = refresh; + $(".play", fs).onclick = () => play(fs.dataset.pos); +} + +async function start() { + $("#start").disabled = true; $("#agree").disabled = true; + try { + ctx = new AudioContext(); // created inside the click so the browser allows playback + status("Loading the Faust compiler…"); + const compiler = new FaustCompiler(new LibFaust(await instantiateFaustModuleFromFile("./vendor/libfaust-wasm/libfaust-wasm.js"))); + const g = new FaustMonoDspGenerator(); + if (!await g.compile(compiler, "thruster_trial", await (await fetch(`./${PATCH}.dsp`)).text(), "-ftz 2")) throw new Error(compiler.getErrorMessage()); + const controls = controlsOf(JSON.parse(g.factory.json)); + for (const v of variants()) { + status(`Rendering ${Object.keys(renders).length + 1} of ${variants().length}…`); + const r = await renderVariant(g, controls, v, ctx.sampleRate); + if (r.nonFinite || !(r.peak > 1e-4)) throw new Error(`render ${v.id} is silent or not finite`); + const buffer = ctx.createBuffer(1, r.samples.length, ctx.sampleRate); + buffer.copyToChannel(r.samples, 0); + renders[v.id] = { buffer, peak: r.peak, rms: r.rms }; + } + } catch (err) { status(`Could not start: ${err}`); console.error(err); return; } + $("#consent").hidden = true; $("#trial").hidden = false; + show(); +} + +function show() { + plays = { A: 0, B: 0 }; + $("#progress").textContent = `Comparison ${current + 1} of ${design.length}`; + for (const fs of document.querySelectorAll("fieldset")) { + for (const i of fs.querySelectorAll(".stars input")) i.checked = false; + $(".word", fs).value = ""; + } + refresh(); +} + +function read(pos) { + const fs = $(`fieldset[data-pos="${pos}"]`); + const checked = $(".stars input:checked", fs); + return { rating: checked ? Number(checked.value) : null, word: cleanWord($(".word", fs).value) }; +} + +function refresh() { + const a = read("A"), b = read("B"); + $("#next").disabled = !(validRating(a.rating) && a.word && validRating(b.rating) && b.word && plays.A && plays.B); +} + +function play(pos) { + stopSound(); + ctx.resume(); + const { buffer } = renders[design[current][pos].id]; + source = ctx.createBufferSource(); source.buffer = buffer; + // 10 ms ramps so the start and stop of the file are not a click that colours the rating. + const g = ctx.createGain(), t = ctx.currentTime, end = t + buffer.duration; + g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.8, t + 0.01); + g.gain.setValueAtTime(0.8, end - 0.01); g.gain.linearRampToValueAtTime(0, end); + source.connect(g).connect(ctx.destination); source.start(t); + plays[pos]++; refresh(); +} + +function stopSound() { try { source?.stop(); } catch {} source = null; } + +function next() { + stopSound(); + const time = new Date().toISOString(); + answers[current] = {}; + for (const pos of ["A", "B"]) answers[current][pos] = { ...read(pos), time, plays: plays[pos] }; + if (++current < design.length) return show(); + $("#trial").hidden = true; $("#done").hidden = false; +} + +function exportJson() { + const doc = buildExport({ + seed, design, answers, sampleRate: ctx.sampleRate, userAgent: navigator.userAgent, exportedAt: new Date().toISOString(), + renders: Object.fromEntries(Object.entries(renders).map(([id, r]) => [id, { peak: r.peak, rms: r.rms }])), + }); + const url = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" })); + const a = Object.assign(document.createElement("a"), { href: url, download: `aquasynth-trial-${seed.toString(16)}.json` }); + a.click(); URL.revokeObjectURL(url); +} -- 2.47.3 From 45f62277f0e195480a8dd0934df41d286957468d Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 4 Nov 2026 21:34:34 +0000 Subject: [PATCH 2/2] Rename logic.mjs to logic.js so static hosts serve a JavaScript MIME type Co-Authored-By: Claude Sonnet 5.5 --- tools/aqua-trial/README.md | 2 +- tools/aqua-trial/analyze.mjs | 2 +- tools/aqua-trial/build.mjs | 4 ++-- tools/aqua-trial/{logic.mjs => logic.js} | 0 tools/aqua-trial/test.mjs | 2 +- tools/aqua-trial/web/trial.js | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) rename tools/aqua-trial/{logic.mjs => logic.js} (100%) diff --git a/tools/aqua-trial/README.md b/tools/aqua-trial/README.md index 0870e58..9f14194 100644 --- a/tools/aqua-trial/README.md +++ b/tools/aqua-trial/README.md @@ -16,7 +16,7 @@ the patch through the same faustwasm runtime the page loads (`node test.mjs`, 15 rattle layers only; the rumble (tone) is untouched, so lowering it moves the mix tone-forward. That reading of "tone-forward mix" is this author's; at 100 the signal equals the thruster (asserted sample for sample). Variants: rpm 600, noise 100 / 50 / 20 / 0. Not loudness-matched, so level and noise are confounded by design. -- `logic.mjs`: design, statistics, validation and export shape (shared by page and tests). +- `logic.js`: design, statistics, validation and export shape (shared by page and tests). - `analyze.mjs`: dependency-free. `node analyze.mjs export1.json [export2.json ...]` prints per-variant n, mean, sd, min/max, peak, rms, words, and whether peak / rms predicted the ratings (Spearman rho, permutation p; "predicts" means |rho| >= 0.5 and p < 0.05, fixed before any data). diff --git a/tools/aqua-trial/analyze.mjs b/tools/aqua-trial/analyze.mjs index 52b73ea..0414d4c 100644 --- a/tools/aqua-trial/analyze.mjs +++ b/tools/aqua-trial/analyze.mjs @@ -4,7 +4,7 @@ // Pure functions are exported for test.mjs; the CLI runs only when this file is the entry point. import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { rng, shuffle, SCHEMA } from "./logic.mjs"; +import { rng, shuffle, SCHEMA } from "./logic.js"; export const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; export const sd = (xs) => (xs.length < 2 ? NaN : Math.sqrt(xs.reduce((a, x) => a + (x - mean(xs)) ** 2, 0) / (xs.length - 1))); diff --git a/tools/aqua-trial/build.mjs b/tools/aqua-trial/build.mjs index fa5c41f..ac741ee 100644 --- a/tools/aqua-trial/build.mjs +++ b/tools/aqua-trial/build.mjs @@ -1,4 +1,4 @@ -// Assembles dist/ (the static trial site): web/, logic.mjs, the trial patch, and the faustwasm runtime copied from +// Assembles dist/ (the static trial site): web/, logic.js, the trial patch, and the faustwasm runtime copied from // the sibling aqua-playground's node_modules (run `npm install` there first). Prints a content hash for the // publish path: minds.gamecult.org/aqua/trial-/. Usage: node build.mjs [outDir] import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; @@ -13,7 +13,7 @@ const fw = join(play, "node_modules/@grame/faustwasm"); rmSync(out, { recursive: true, force: true }); mkdirSync(join(out, "vendor/faustwasm"), { recursive: true }); cpSync(join(here, "web"), out, { recursive: true }); -cpSync(join(here, "logic.mjs"), join(out, "logic.mjs")); +cpSync(join(here, "logic.js"), join(out, "logic.js")); cpSync(join(here, "thruster-trial.dsp"), join(out, "thruster-trial.dsp")); cpSync(join(play, "web/ui.js"), join(out, "ui.js")); cpSync(join(fw, "dist/esm/index.js"), join(out, "vendor/faustwasm/index.js")); diff --git a/tools/aqua-trial/logic.mjs b/tools/aqua-trial/logic.js similarity index 100% rename from tools/aqua-trial/logic.mjs rename to tools/aqua-trial/logic.js diff --git a/tools/aqua-trial/test.mjs b/tools/aqua-trial/test.mjs index 7570a86..9c519e8 100644 --- a/tools/aqua-trial/test.mjs +++ b/tools/aqua-trial/test.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { makeDesign, variants, stats, cleanWord, validRating, buildExport, renderVariant, rng, shuffle, SCHEMA, PATCH } from "./logic.mjs"; +import { makeDesign, variants, stats, cleanWord, validRating, buildExport, renderVariant, rng, shuffle, SCHEMA, PATCH } from "./logic.js"; import { analyze, format, mean, sd, spearman, ranks, pearson } from "./analyze.mjs"; const here = dirname(fileURLToPath(import.meta.url)); diff --git a/tools/aqua-trial/web/trial.js b/tools/aqua-trial/web/trial.js index ef4e721..9c3f4f8 100644 --- a/tools/aqua-trial/web/trial.js +++ b/tools/aqua-trial/web/trial.js @@ -1,9 +1,9 @@ // Browser glue for the listening trial. The decisions (design, statistics, validation, export shape) live in -// ../logic.mjs and are tested in node; this file only touches the DOM, the AudioContext and the download. +// ../logic.js and are tested in node; this file only touches the DOM, the AudioContext and the download. // Nothing here makes a network request after the page assets load, and nothing is written to storage. import { instantiateFaustModuleFromFile, LibFaust, FaustCompiler, FaustMonoDspGenerator } from "./vendor/faustwasm/index.js"; import { controlsOf } from "./ui.js"; -import { variants, makeDesign, renderVariant, cleanWord, validRating, buildExport, PATCH } from "./logic.mjs"; +import { variants, makeDesign, renderVariant, cleanWord, validRating, buildExport, PATCH } from "./logic.js"; const $ = (s, root = document) => root.querySelector(s); const status = (t) => { $("#status").textContent = t; }; -- 2.47.3