From d427f046b5c0f6795f92fa1a53f2a236ff0dd927 Mon Sep 17 00:00:00 2001 From: Aqua Date: Thu, 12 Nov 2026 10:46:57 +0000 Subject: [PATCH] aqua-trial: isolate the noise-slider kill; LF-normalise the DSP hash on both sides - test.mjs: a failing test no longer aborts the run, so a mutation shows every test that kills it. - New test: the noise slider is live (noise 20 vs 100 differ, rasp-band ratio 0.2 at rpm 4800) while noise 100 stays identical to thruster.dsp. - logic.js dspHash (CRLF folded to LF, then sha-256) is the one normalisation; the page export and analyze.mjs both use it. LF text hashes as before, so old exports stay valid. - Tests: CRLF == LF hash, LF == plain sha-256, analyze CLI raises no foreign-DSP warning for a CRLF checkout. Co-Authored-By: Claude Sonnet 5.5 --- tools/aqua-trial/analyze.mjs | 5 ++-- tools/aqua-trial/logic.js | 4 +++ tools/aqua-trial/test.mjs | 46 ++++++++++++++++++++++++++++++++--- tools/aqua-trial/web/trial.js | 4 +-- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/tools/aqua-trial/analyze.mjs b/tools/aqua-trial/analyze.mjs index ba23943..c11d5dd 100644 --- a/tools/aqua-trial/analyze.mjs +++ b/tools/aqua-trial/analyze.mjs @@ -3,10 +3,9 @@ // 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 { createHash } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { rng, shuffle, SCHEMA } from "./logic.js"; +import { rng, shuffle, dspHash, 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))); @@ -102,6 +101,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) if (e.schema !== SCHEMA) throw new Error(`${p}: schema ${e.schema}, expected ${SCHEMA}`); return e; }); - const expectedDsp = createHash("sha256").update(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "thruster-trial.dsp"))).digest("hex"); + const expectedDsp = await dspHash(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "thruster-trial.dsp"), "utf8")); console.log(format(analyze(exports, { expectedDsp }))); } diff --git a/tools/aqua-trial/logic.js b/tools/aqua-trial/logic.js index 95ddcb6..20cf589 100644 --- a/tools/aqua-trial/logic.js +++ b/tools/aqua-trial/logic.js @@ -73,6 +73,10 @@ export async function sha256Hex(text) { return [...d].map((b) => b.toString(16).padStart(2, "0")).join(""); } +// The DSP identity an export records and analyze.mjs compares: sha-256 of the source text with CRLF folded to LF, so a CRLF +// checkout of the same DSP hashes the same. LF text hashes exactly as before, so exports made before this stay valid. +export const dspHash = (text) => sha256Hex(String(text).replace(/\r\n/g, "\n")); + // answers: [{ A: {rating, word, time, plays}, B: {...} }] aligned with design. export function buildExport({ seed, design, renders, answers, dspSha256, sampleRate, userAgent, exportedAt }) { if (!/^[0-9a-f]{64}$/.test(dspSha256 ?? "")) throw new Error("export needs the sha-256 of the DSP source that was rendered"); diff --git a/tools/aqua-trial/test.mjs b/tools/aqua-trial/test.mjs index 36167a7..98cbf5d 100644 --- a/tools/aqua-trial/test.mjs +++ b/tools/aqua-trial/test.mjs @@ -5,14 +5,19 @@ 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, sha256Hex, renderVariant, rng, shuffle, SCHEMA, PATCH } from "./logic.js"; +import { makeDesign, variants, stats, cleanWord, validRating, buildExport, sha256Hex, dspHash, 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")); -const DSP_SHA = await sha256Hex(readFileSync(join(here, "thruster-trial.dsp"), "utf8")); +const DSP_SHA = await dspHash(readFileSync(join(here, "thruster-trial.dsp"), "utf8")); let n = 0; -const t = async (name, fn) => { await fn(); n++; console.log(`ok ${name}`); }; +const failed = []; +// A failing test is reported and the run goes on, so a mutation of the patch shows every test it kills, not only the first. +const t = async (name, fn) => { + try { await fn(); n++; console.log(`ok ${name}`); } + catch (e) { failed.push(name); console.log(`FAIL ${name}\n ${String(e.message).split("\n")[0].slice(0, 200)}`); } +}; await t("design: every unordered pair once, both positions used, replayable from the seed", () => { const d = makeDesign(7); @@ -159,6 +164,31 @@ await t("analyze CLI: runs on export files and rejects a foreign schema", async assert.equal(spawnSync("node", [join(here, "analyze.mjs")]).status, 2); }); +await t("dsp hash: CRLF and LF text hash equal, LF text hashes as plain sha-256 (old exports stay valid), other text differs", async () => { + const lf = readFileSync(join(here, "thruster-trial.dsp"), "utf8").replace(/\r\n/g, "\n"); + assert.equal(await dspHash(lf.replace(/\n/g, "\r\n")), await dspHash(lf)); + assert.equal(await dspHash(lf), await sha256Hex(lf)); + assert.equal(await dspHash(lf), DSP_SHA); + assert.notEqual(await dspHash(lf + " "), await dspHash(lf)); + assert.notEqual(await dspHash("a\nb"), await dspHash("a b")); +}); + +await t("analyze CLI: a CRLF checkout of the same DSP raises no foreign-DSP warning; a changed DSP still does", async () => { + const { execFileSync } = await import("node:child_process"); + const { mkdtempSync, writeFileSync, copyFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const lf = readFileSync(join(here, "thruster-trial.dsp"), "utf8"); + const run = (dsp) => { + const d = mkdtempSync(join(tmpdir(), "trial-crlf-")); + for (const f of ["analyze.mjs", "logic.js"]) copyFileSync(join(here, f), join(d, f)); + writeFileSync(join(d, "thruster-trial.dsp"), dsp); + writeFileSync(join(d, "e.json"), JSON.stringify(fakeExport(5, 48000).doc)); + return execFileSync("node", [join(d, "analyze.mjs"), join(d, "e.json")], { encoding: "utf8" }); + }; + assert.doesNotMatch(run(lf.replace(/\n/g, "\r\n")), /WARNING/); + assert.match(run(lf + "// edited\n"), /WARNING/); +}); + // --- the patch, through the runtime the page loads --- const { instantiateFaustModuleFromFile, LibFaust, FaustCompiler, FaustMonoDspGenerator } = await import(pathToFileURL(join(dist, "vendor/faustwasm/index.js")).href); @@ -261,4 +291,14 @@ await t("patch: the noise knob scales rasp and rattle together at rpm 600 (what assert.ok(Math.abs(half / full - 0.5) < 0.05, `half/full ${half / full}`); }); +await t("patch: the noise slider is live: noise 20 differs from noise 100 by the rasp-band ratio 0.2 at rpm 4800, while noise 100 stays identical to thruster.dsp", async () => { + const at = (noise) => renderVariant(trial.g, trial.controls, { params: { rpm: 4800, noise } }, SR, 1); + const [x20, x100, ref] = [await at(20), await at(100), await renderVariant(base.g, base.controls, { params: { rpm: 4800 } }, SR, 1)]; + assert.ok(maxDiffOf(x100, ref) < 1e-6, `noise 100 vs thruster.dsp: ${maxDiffOf(x100, ref)}`); + assert.ok(maxDiffOf(x20, x100) > 1e-2, `noise 20 and noise 100 must render different signals: ${maxDiffOf(x20, x100)}`); + const r = Math.sqrt(highBandEnergy(x20.samples) / highBandEnergy(x100.samples)); + assert.ok(Math.abs(r - 0.2) < 0.03, `rasp band amplitude ratio ${r}, expected 0.2`); +}); + +if (failed.length) { console.log(`${n} passed, ${failed.length} FAILED`); process.exit(1); } console.log(`${n} passed`); diff --git a/tools/aqua-trial/web/trial.js b/tools/aqua-trial/web/trial.js index dec8677..f5d4523 100644 --- a/tools/aqua-trial/web/trial.js +++ b/tools/aqua-trial/web/trial.js @@ -3,7 +3,7 @@ // 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, sha256Hex, PATCH } from "./logic.js"; +import { variants, makeDesign, renderVariant, cleanWord, validRating, buildExport, dspHash, PATCH } from "./logic.js"; const $ = (s, root = document) => root.querySelector(s); const status = (t) => { $("#status").textContent = t; }; @@ -39,7 +39,7 @@ async function start() { const compiler = new FaustCompiler(new LibFaust(await instantiateFaustModuleFromFile("./vendor/libfaust-wasm/libfaust-wasm.js"))); const g = new FaustMonoDspGenerator(); const dspText = await (await fetch(`./${PATCH}.dsp`)).text(); - dspSha256 = await sha256Hex(dspText); // the exact source compiled, so an export names the DSP that produced it + dspSha256 = await dspHash(dspText); // the exact source compiled, so an export names the DSP that produced it if (!await g.compile(compiler, "thruster_trial", dspText, "-ftz 2")) throw new Error(compiler.getErrorMessage()); const controls = controlsOf(JSON.parse(g.factory.json)); for (const v of variants()) { -- 2.47.3