Listening trial: blinded A/B thruster noise sweep (static page) #7

Merged
aqua merged 2 commits from aqua/listening-trial into main 2026-11-05 11:54:39 +00:00
11 changed files with 633 additions and 0 deletions

1
tools/aqua-trial/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View file

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

View file

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

View file

@ -0,0 +1,27 @@
// 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-<hash>/. 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.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"));
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)}`);

81
tools/aqua-trial/logic.js Normal file
View file

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

View file

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

174
tools/aqua-trial/test.mjs Normal file
View file

@ -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.js";
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`);

View file

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

View file

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AquaSynth listening trial</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<main>
<p class="eyebrow">AquaSynth lab</p>
<h1>Does it land?</h1>
<p class="lede">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.</p>
<section id="consent">
<label class="consent"><input id="agree" type="checkbox">
I agree to take part. My ratings, one-word answers, timestamps, browser user-agent string and audio sample rate are kept in this browser tab only. Nothing is sent anywhere, and nothing is stored on this device, unless I press <b>Export</b> at the end, which saves a file that I then choose whether to send.</label>
<button id="start" type="button" disabled>Start</button>
<span id="status" role="status"></span>
</section>
<section id="trial" hidden>
<h2 id="progress"></h2>
<div class="pair">
<fieldset data-pos="A"><legend>Sound A</legend>
<button type="button" class="play">Play A</button>
<div class="stars" role="radiogroup" aria-label="Rating for A"></div>
<input class="word" type="text" maxlength="32" placeholder="one word" autocomplete="off" aria-label="One word for A">
</fieldset>
<fieldset data-pos="B"><legend>Sound B</legend>
<button type="button" class="play">Play B</button>
<div class="stars" role="radiogroup" aria-label="Rating for B"></div>
<input class="word" type="text" maxlength="32" placeholder="one word" autocomplete="off" aria-label="One word for B">
</fieldset>
</div>
<button id="stop" type="button">Stop</button>
<button id="next" type="button" disabled>Next</button>
<p class="hint">Rate both sounds and give each exactly one word (no spaces) to continue. You can replay as often as you like.</p>
</section>
<section id="done" hidden>
<h2>Finished</h2>
<p>Nothing has left this page. <b>Export</b> saves a JSON file of your answers; send it to whoever asked you to take part, or do not.</p>
<button id="export" type="button">Export</button>
</section>
</main>
<script type="module" src="./trial.js"></script>
</body>
</html>

View file

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

View file

@ -0,0 +1,109 @@
// Browser glue for the listening trial. The decisions (design, statistics, validation, export shape) live in
// ../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.js";
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);
}