diff --git a/tools/aqua-trial/README.md b/tools/aqua-trial/README.md index ea9c9b6..81890fd 100644 --- a/tools/aqua-trial/README.md +++ b/tools/aqua-trial/README.md @@ -9,13 +9,14 @@ the patch through the same faustwasm runtime the page loads (`node test.mjs`, 15 ## 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 +- `web/`: the page. Consent checkbox gates Start; the page compiles `thruster-trial.dsp` in-browser, renders five + 4 s variants at the browser's sample rate, and runs ten 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. + Variants: rpm 600, current mix at noise 100 / 50 / 20 / 0, plus the v5 mix at noise 100 (`mix` control 0 = current + thruster.dsp, 1 = the v5 mix as cut at c6561da; `thruster-v5-c6561da.dsp` is that file, kept as the test fixture). 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; @@ -33,8 +34,11 @@ user agent, sample rate); sending it is the listener's act. Peak and rms hardly move across the sweep (48 kHz, current mix: rms 0.0725 / 0.0724 / 0.0723 / 0.0723; peak 0.160 / 0.151 / 0.148 / 0.147). **The sweep got narrower on 2026-11-05:** `patches/thruster/thruster.dsp` (and this file, which must equal it -at noise 100) was changed to cut the idle-level rasp and rattle floors and open the rumble low-pass at low load, after +at noise 100, mix 0) was changed to cut the idle-level rasp and rattle floors and open the rumble low-pass at low load, after `tools/aqua-feel` measured the A-weighted noise-to-tone ratio at rpm 300/600 as +21/+23 dB. At rpm 600 the noise layers are now already small, so noise 100 vs 0 differs far less than it did in the original mix (rms 0.0734 / 0.0712 / 0.0706 / 0.0705, peak -0.189 / 0.161 / 0.148 / 0.140). A trial of Deru's "dead radio" claim against the old mix needs that mix restored as a variant; -open as `aqua-body:follow_up:trial-add-v5-variant`. Nothing here was heard, before or after the change. +0.189 / 0.161 / 0.148 / 0.140). The v5 mix is now a variant (`v5noise100`), so the noise layers can be compared against the current mix and the old one. The +v5 variant measured at 48 kHz, rpm 600: rms 0.0734, peak 0.189, against 0.0725 and 0.160 for the current mix at noise 100: a +level difference of about 0.1 dB rms, so the variants again differ mostly in texture, not level. Adding it makes ten comparisons +rather than six. `analyze.mjs` prints `mix` beside `noise%`; its `(context) noise%` correlation now pools the v5 variant (noise 100) +with the current noise-100 variant, so read it with the table. Nothing here was heard, before or after the change. diff --git a/tools/aqua-trial/analyze.mjs b/tools/aqua-trial/analyze.mjs index 0414d4c..2b5fa40 100644 --- a/tools/aqua-trial/analyze.mjs +++ b/tools/aqua-trial/analyze.mjs @@ -57,11 +57,11 @@ export function analyze(exports) { 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), + id, noise: rs[0].params.noise, mix: rs[0].params.mix ?? 0, 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); + }).sort((a, b) => a.mix - b.mix || 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"); @@ -71,10 +71,10 @@ export function analyze(exports) { 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"]; + const out = [`${rep.listeners} listener export(s), ${rep.records} ratings`, "", "variant mix 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(`${v.id.padEnd(12)} ${String(v.mix).padStart(3)} ${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"}`); diff --git a/tools/aqua-trial/logic.js b/tools/aqua-trial/logic.js index aa24dd1..fcce6c6 100644 --- a/tools/aqua-trial/logic.js +++ b/tools/aqua-trial/logic.js @@ -7,7 +7,12 @@ 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 } })); +// mix 0 is the current thruster.dsp; mix 1 is the v5 mix as cut at c6561da (before aqua-feel's 2026-11-05 edit). The four +// noise levels run on the current mix; the v5 mix is added at noise 100 so the noise sweep has the old mix to compare against. +export const variants = () => [ + ...NOISE_PERCENTS.map((noise) => ({ id: `noise${noise}`, params: { rpm: RPM, noise, mix: 0 } })), + { id: "v5noise100", params: { rpm: RPM, noise: 100, mix: 1 } }, +]; // mulberry32: small seeded PRNG so a session (and its tests) can be replayed from the seed. export function rng(seed) { diff --git a/tools/aqua-trial/test.mjs b/tools/aqua-trial/test.mjs index 9c519e8..ac15813 100644 --- a/tools/aqua-trial/test.mjs +++ b/tools/aqua-trial/test.mjs @@ -15,20 +15,23 @@ 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); + assert.equal(d.length, 10); 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.equal(new Set(keys).size, 10); + for (const v of variants()) assert.equal(d.filter((x) => x.A.id === v.id || x.B.id === v.id).length, 4); 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}`); + for (const c of Object.values(inA)) assert.ok(Math.abs(c / 8000 - 0.5) < 0.05, `A share ${c / 8000}`); 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("variants: rpm 600, current mix at noise 100/50/20/0, and the v5 mix at noise 100", () => { + assert.deepEqual(variants().map((v) => v.params), [ + ...[100, 50, 20, 0].map((noise) => ({ rpm: 600, noise, mix: 0 })), + { rpm: 600, noise: 100, mix: 1 }, + ]); }); await t("stats: peak, rms, non-finite counted and excluded", () => { @@ -45,7 +48,7 @@ await t("one word: trimmed, no inner whitespace, bounded; ratings 1-5 integers", 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 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 }, v5noise100: { peak: 0.19, rms: 0.073 } }; 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" }) }; }; @@ -53,7 +56,7 @@ const fakeExport = (seed, rate) => { 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); + assert.equal(doc.records.length, 20); 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); @@ -87,9 +90,10 @@ const synth = (ratingOf, listeners = 12) => { 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.equal(rep.records, 240); + assert.deepEqual(rep.variants.map((v) => [v.mix, v.noise]), [[0, 100], [0, 50], [0, 20], [0, 0], [1, 100]]); assert.ok(rep.variants[0].mean > rep.variants[3].mean); + assert.ok(rep.variants[4].id === "v5noise100"); for (const p of rep.predictors) assert.ok(p.predicts && p.rho > 0.9, `${p.key} rho ${p.rho}`); assert.match(format(rep), /PREDICTS/); }); @@ -134,9 +138,12 @@ const compile = async (path, name) => { }; const trial = await compile(join(here, "thruster-trial.dsp"), "thruster_trial"); const base = await compile(join(here, "../../patches/thruster/thruster.dsp"), "thruster"); +const v5 = await compile(join(here, "thruster-v5-c6561da.dsp"), "thruster_v5"); const SR = 48000; +const maxDiffOf = (a, b) => { assert.equal(a.samples.length, b.samples.length); let m = 0; for (let i = 0; i < a.samples.length; i++) m = Math.max(m, Math.abs(a.samples[i] - b.samples[i])); return m; }; -await t("patch: exposes rpm and noise controls", () => { +await t("patch: exposes rpm, noise and mix controls", () => { + assert.ok(trial.controls.some((c) => c.label === "mix" && c.init === 0)); assert.ok(trial.controls.some((c) => c.label === "noise" && c.init === 100)); assert.ok(trial.controls.some((c) => c.label === "rpm")); }); @@ -149,7 +156,16 @@ await t("patch: noise=100 is sample-identical to patches/thruster/thruster.dsp a 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 () => { +await t("patch: mix=1 noise=100 is sample-identical to the v5 thruster as cut at c6561da, at rpm 600, and differs from mix=0", async () => { + const a = await renderVariant(trial.g, trial.controls, { params: { rpm: 600, noise: 100, mix: 1 } }, SR, 2); + const b = await renderVariant(v5.g, v5.controls, { params: { rpm: 600 } }, SR, 2); + const cur = await renderVariant(trial.g, trial.controls, { params: { rpm: 600, noise: 100, mix: 0 } }, SR, 2); + assert.ok(maxDiffOf(a, b) < 1e-6, `max diff ${maxDiffOf(a, b)}`); + assert.ok(maxDiffOf(a, cur) > 1e-2, "the two mixes must be audibly different signals, not equal"); + assert.ok(a.rms > cur.rms, `v5 rms ${a.rms} should exceed current ${cur.rms}`); +}); + +await t("patch: all 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()) { @@ -166,7 +182,7 @@ await t("patch: all four variants finite and non-silent, rendered at 44100 and 4 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); + for (const v of variants().slice(0, 4)) 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); }); diff --git a/tools/aqua-trial/thruster-trial.dsp b/tools/aqua-trial/thruster-trial.dsp index 211ef47..bfa17aa 100644 --- a/tools/aqua-trial/thruster-trial.dsp +++ b/tools/aqua-trial/thruster-trial.dsp @@ -1,12 +1,17 @@ -// 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). +// thruster-trial: patches/thruster/thruster.dsp plus two controls for the listening trial. +// `mix` (0 or 1, default 0) selects the mix: 0 is the current thruster.dsp, 1 is the v5 mix as cut at c6561da, before +// aqua-feel's 2026-11-05 edit (tools/aqua-trial/thruster-v5-c6561da.dsp holds that file; test.mjs asserts mix=1 equals it). +// The edit changed three things, each blended by `mix`: the rumble low-pass opening at low load, the rasp idle floor and slope, +// and the rattle idle floor. +// `noise` (percent, default 100) 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 the selected mix's thruster file (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; +mix_v5 = hslider("mix", 0, 0, 1, 1); rpm = hslider("rpm", 1200, 300, 6000, 1) : si.smooth(ba.tau2pole(0.2)); load = (rpm - 300) / 5700; shaft = 28 + rpm * 0.025; @@ -17,17 +22,18 @@ f0 = shaft * (1 + 0.04 * wander); // low rumble: saw at shaft speed plus a square a sub-octave down, low-passed // the low-pass opens up as load falls, so at low rpm the rotor tone reaches the 150-300 Hz range a small speaker can show -rumble = (os.sawtooth(f0) + 0.7 * os.square(f0 * 0.5)) : fi.lowpass(3, 60 + 2.5 * shaft + 140 * (1 - load)); +rumble = (os.sawtooth(f0) + 0.7 * os.square(f0 * 0.5)) : fi.lowpass(3, 60 + 2.5 * shaft + 140 * (1 - load) * (1 - mix_v5)); // 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 (a 1.5% floor at idle, so low rpm is tone-led); tanh on the rattle alone keeps ring-up spikes from setting the peak +rattle_floor = 0.015 + 0.085 * mix_v5; 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.015 + 0.985 * load ^ 2) * 3 : *(4) : ma.tanh : /(4); +rattle = (mode(173, 20) * (0.3 + 0.7 * load) + 0.8 * mode(311, 18) + 0.6 * mode(467, 16) + 0.4 * mode(1130, 14)) * (rattle_floor + (1 - rattle_floor) * load ^ 2) * 3 : *(4) : ma.tanh : /(4); -mix = rumble * (0.5 + 0.5 * load) + noise_amt * (rasp * (0.05 + 1.75 * load) + rattle); +mix = rumble * (0.5 + 0.5 * load) + noise_amt * (rasp * ((0.05 + 0.25 * mix_v5) + (1.75 - 0.25 * mix_v5) * 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/thruster-v5-c6561da.dsp b/tools/aqua-trial/thruster-v5-c6561da.dsp new file mode 100644 index 0000000..d3879b1 --- /dev/null +++ b/tools/aqua-trial/thruster-v5-c6561da.dsp @@ -0,0 +1,29 @@ +// thruster (v5 mix as cut at c6561da; fixture for test.mjs): sustained, heavy, brittle-hull thruster with an RPM control. +// Compile as Faust source (AquaSynthPatchCompiler.CompileSource); 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"; +import("stdfaust.lib"); + +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) + 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 index 0b96d43..1569004 100644 --- a/tools/aqua-trial/web/index.html +++ b/tools/aqua-trial/web/index.html @@ -10,7 +10,7 @@

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.

+

Ten 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.