..."); return 2; }
var outDir = Path.GetFullPath(args[0]);
var root = Path.GetFullPath(args[1]);
diff --git a/tools/aqua-feel/README.md b/tools/aqua-feel/README.md
new file mode 100644
index 0000000..5d7d8e4
--- /dev/null
+++ b/tools/aqua-feel/README.md
@@ -0,0 +1,73 @@
+# aqua-feel
+
+Headless feel meter for AquaSynth. It renders every shipped patch and every Ding offline, measures the buffers, writes
+JSON and a static HTML gallery, and gates the numbers against thresholds.
+
+**Nothing here was heard.** Every number is a measurement of an offline-rendered buffer. Metrics are evidence for a
+listener to check, not verdicts: a render can pass every threshold and sound wrong, and fail one and sound fine.
+The WAVs and the gallery exist so a person can press play.
+
+## Run
+
+```
+(cd ../aqua-playground && npm ci) # faustwasm runtime, once
+node test.mjs # 26 checks of the meter on synthetic signals; renders no patch
+node feel.mjs run # render 81 items (~15 s), write out/{report.json,index.html,wav/}, gate; exit 1 on failure
+node feel.mjs run --only thruster # a subset (gate skips id-rule and waiver bookkeeping); --out DIR; --no-gate
+node feel.mjs gate out/report.json # re-gate a saved report (--partial for a subset)
+node feel.mjs compare before.json after.json [--only SUBSTR] # before/after table
+node feel.mjs correlate trial-export.json ... # feel metrics vs aqua-trial listener ratings
+```
+
+Open `out/index.html` in a browser (it references `wav/` relatively). The gate is the CI step: `node feel.mjs run` exits
+non-zero on any unwaived failure or stale waiver. No CI runner exists in this repo yet; nothing runs it automatically.
+
+## What it renders
+
+- Every patch in `tools/aqua-playground/lowered/` (41 `.aqua` files lowered by `tools/AquaLower`, plus the hand-written
+ `thruster.dsp` at rpm 300 / 600 / 1200 / 4800), 3 s each at 48 kHz, no gate or trigger: the sound the patch makes when started.
+ `advanced/additive-pad` and `advanced/electro-drop-demo` lower to ~1.5 MB of Faust each and are not in the playground, so not here.
+- Every `DingCatalog` event on every instrument (9 x 4 = 36), mixed the way `DingsPlaybackHost` does it: each note rendered at
+ `root * 2^(semitones/12)` for 1.5 s, scaled by command gain 0.6 and the note gain, placed at its delay.
+ `lowered-dings/` is `dotnet run --project tools/AquaLower -- --dings tools/aqua-feel/lowered-dings`; `DingCatalogTests.LoweredDingsFedToTheFeelMeterMatchTheCatalog` fails when it drifts from `DingCatalog.cs`.
+ The command gain 0.6 is an assumption (`DingsPlaybackHost` takes it from the caller as 0..1), so the Ding loudness window depends on it.
+
+Render path: the same faustwasm offline processor the playground and the listening trial use (libfaust 2.90.x, `-ftz 2`),
+mono channel 0. It is **not** the daemon's native libfaust path (`-vec`); the same Faust source should render alike, but
+nobody has diffed the two.
+
+## What it measures (per item, `report.json` `items[].metrics`)
+
+| metric | meaning | limits |
+|---|---|---|
+| `onsetMs` | first sample at or above max(-40 dB re peak, -70 dBFS), from note-on | Offline render has no device latency; this is the patch's own lead-in only |
+| `attackMs`, `attackSlopeDbPerMs`, `attackWindowLimited` | 10% to 90% of the envelope maximum inside the first 150 ms after onset, on a 0.17 ms peak-hold envelope (30 ms release, so a low tone's zero crossings are not read as dips) | A faster attack reads as one hop. A swell longer than 150 ms reports a lower bound and sets `attackWindowLimited`. The envelope is a peak follower, so a tone below ~30 Hz would still ripple |
+| `samplePeak`, `truePeakDb`, `overSamples`, `flatTopRuns` | peak, 4x-oversampled true peak, samples beyond 1.0, runs of 3+ samples at 0.999 | WAVs are float32 so overs survive to disk |
+| `loudnessLufs` | BS.1770-4 integrated, mono, K-weighted (48 kHz coefficients; renders are fixed at 48 kHz), gated | A mono sound with no reference level; compare items, do not read it as a delivered loudness. A buffer under 400 ms is zero-padded to one block and reads low (0.1 s of full 1 kHz sine: -9.0, true -3.0); every shipped item is 1.5 s or longer |
+| `crestDb`, `rmsDb` | over the active span (onset to last frame within 50 dB of peak) | |
+| `centroidMedianHz`, `centroidCurve`, `fluxMean`, `fluxCurve` | STFT 2048/512 over active frames; flux is the change between unit-norm spectra | Flux on stationary noise is high; it is reported, not gated |
+| `noiseToToneDb`, `noiseToToneADb` | energy not in stable spectral lines vs energy in them, flat and A-weighted | A heuristic split (see `metrics.js`): steady or decaying partials read tonal, noise-excited resonances read noise, gliding partials read noise. 8192-point window for spans of 1 s or more, else 2048, so compare the same sound across edits, not different sounds. It is window- and Q-dependent: white noise through a Q20 band-pass at 1 kHz reads as noise over 3 s (8192 window) but partly tonal over 0.5 s (2048 window), and a Q100 resonance reads tonal. The thruster rpm caps are fit to this heuristic at its 8192 window |
+| `lowMotion` | span of 1 s or more with centroid spread under 0.08 octave and level range under 6 dB | The song-swarm collapse shape (docs/music-agent-orchestration-lessons.md): a held drone or a wash. A legitimate sustained drone is flagged too |
+
+## The gate
+
+`thresholds.json`: rules scoped to `all`, `patch`, `ding` or an id glob, each with a `why`; `level: warn` prints without failing.
+`waivers` turn a known failure into a printed line and must name a reason and a follow-up id; a numeric waiver carries a bound
+(`max`/`min`) and covers the item only up to it, so a waived item cannot get worse unseen. A waiver that no longer matches a
+failing rule, and a rule with an `id` that matches no item (a renamed or dropped guard), are failures on a full run, so waivers
+are deleted when the thing they excuse is fixed and a rename cannot silently disarm a cap. A missing metric fails its rule. The numbers were chosen after looking
+at the shipped renders. They are tripwires, not taste.
+
+Current waivers: `sfxr-classic-classic-sfxr-abstract-golf` (+0.9 dBTP, -5.3 LUFS, held noise) and `sfxr-explosion` (3 s of
+steady noise without decay). Nobody has listened to either to see if the number is the sound.
+
+## Reuse
+
+`report.js` imports `mean`, `spearman` and `permutationP` from `../aqua-trial/analyze.mjs` and the variants from
+`../aqua-trial/logic.js`: `correlate` renders the trial's four thruster variants, measures them, and asks the trial's own
+question (|rho| >= 0.5 and p < 0.05) of each metric against real ratings once listeners export some. With four variants
+every correlation is really about the variant ordering.
+
+## Recorded first run
+
+`docs/renders/aqua-feel-2026-11-05.md`: before/after numbers for the PRC SYNTH1 and thruster edits.
diff --git a/tools/aqua-feel/catalog.js b/tools/aqua-feel/catalog.js
new file mode 100644
index 0000000..76ba31a
--- /dev/null
+++ b/tools/aqua-feel/catalog.js
@@ -0,0 +1,27 @@
+// What aqua-feel renders: every playground patch, the thruster at four rpm, and every Ding event on every instrument.
+import { join } from "node:path";
+import { readText, playground } from "./render.js";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+export const THRUSTER_RPM = [300, 600, 1200, 4800];
+export const PATCH_SECONDS = 3, DING_COMMAND_GAIN = 0.6;
+
+export function patchItems() {
+ const dir = join(playground, "lowered"), cat = JSON.parse(readText(join(dir, "catalog.json"))), items = [];
+ for (const e of cat) {
+ const source = readText(join(dir, e.dsp));
+ if (e.name === "thruster") for (const rpm of THRUSTER_RPM) items.push({ id: `thruster@rpm${rpm}`, group: "patch", origin: e.origin, source, params: { rpm }, seconds: PATCH_SECONDS });
+ else items.push({ id: e.name, group: "patch", origin: e.origin, source, params: {}, seconds: PATCH_SECONDS });
+ }
+ return items;
+}
+
+export function dingItems() {
+ const dir = join(here, "lowered-dings"), d = JSON.parse(readText(join(dir, "dings.json"))), items = [];
+ for (const ins of d.instruments) for (const ev of d.events)
+ items.push({ id: `ding/${ev.id}/${ins.id}`, group: "ding", origin: `DingCatalog ${ev.id} on ${ins.id}`, ding: { instrument: ins, event: ev, source: readText(join(dir, ins.dsp)), noteSeconds: d.noteSeconds, commandGain: DING_COMMAND_GAIN } });
+ return items;
+}
+export const allItems = () => [...patchItems(), ...dingItems()];
diff --git a/tools/aqua-feel/feel.mjs b/tools/aqua-feel/feel.mjs
new file mode 100644
index 0000000..f5d220c
--- /dev/null
+++ b/tools/aqua-feel/feel.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// aqua-feel: headless feel meter. Renders every shipped patch and Ding offline, measures the buffers, writes
+// report.json plus a static gallery, and gates the numbers against thresholds.json.
+// NOTHING HERE HEARS ANYTHING. These are measurements of rendered buffers; a number is evidence, never a verdict.
+// Usage: node feel.mjs run [--out DIR] [--only SUBSTR] [--no-gate] render, measure, write report + gallery, gate
+// node feel.mjs gate REPORT.json [THRESHOLDS.json] [--partial] re-gate a report (--partial: a subset; skips id-rule and waiver bookkeeping)
+// node feel.mjs compare BEFORE.json AFTER.json [--only SUBSTR] before/after table
+// node feel.mjs correlate TRIAL_EXPORT.json ... feel metrics vs listening-trial ratings
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { measure, SR } from "./metrics.js";
+import { loadRuntime, compile, renderOnce, renderDing, writeWav } from "./render.js";
+import { allItems } from "./catalog.js";
+import { gate, formatGate } from "./gate.js";
+import { gallery } from "./gallery.js";
+import { compareTable, correlate } from "./report.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+export const CAVEAT = "Nothing here was heard. These are measurements of offline-rendered buffers (faustwasm, mono channel 0); metrics are evidence for a listener to check, not verdicts.";
+const args = process.argv.slice(2), cmd = args.shift();
+const flag = (n) => { const i = args.indexOf(n); if (i < 0) return undefined; return args.splice(i, i + 2 > args.length ? 1 : 2).pop(); };
+const bool = (n) => { const i = args.indexOf(n); if (i < 0) return false; args.splice(i, 1); return true; };
+const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
+
+async function run() {
+ const out = resolve(flag("--out") ?? join(here, "out")), only = flag("--only"), noGate = bool("--no-gate");
+ const items = allItems().filter((it) => !only || it.id.includes(only));
+ if (!items.length) { console.error(`no item matches ${only}`); process.exit(2); }
+ const rt = await loadRuntime();
+ console.log(`libfaust ${rt.version}; ${items.length} items; ${SR} Hz`);
+ const records = [];
+ for (const it of items) {
+ const t0 = Date.now();
+ try {
+ const r = it.ding ? await renderDing(rt, it.ding.instrument, it.ding.source, it.ding.event, it.ding) : await renderOnce(await compile(rt, it.id, it.source), it.params, it.seconds);
+ const wav = `wav/${it.id.replace(/[^A-Za-z0-9._@-]+/g, "_")}.wav`;
+ writeWav(join(out, wav), r.samples);
+ records.push({ id: it.id, group: it.group, origin: it.origin, params: it.params ?? {}, seconds: r.samples.length / SR, channels: r.channels, wav, metrics: measure(r.samples) });
+ console.log(`ok ${it.id} (${Date.now() - t0} ms)`);
+ } catch (e) {
+ records.push({ id: it.id, group: it.group, origin: it.origin, error: String(e.message ?? e) });
+ console.log(`FAIL ${it.id}: ${e.message}`);
+ }
+ }
+ const thresholds = readJson(join(here, "thresholds.json"));
+ const report = { schema: "aqua-feel.report.v1", caveat: CAVEAT, libfaust: rt.version, sampleRate: SR, items: records };
+ report.gate = gate(report, thresholds, { partial: Boolean(only) });
+ mkdirSync(out, { recursive: true });
+ writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 1));
+ writeFileSync(join(out, "index.html"), gallery(report));
+ console.log(`\nwrote ${join(out, "report.json")} and index.html\n${CAVEAT}\n\n${formatGate(report.gate)}`);
+ process.exit(!noGate && !report.gate.pass ? 1 : 0);
+}
+
+const cmds = {
+ run,
+ gate() {
+ const partial = bool("--partial"), report = readJson(args[0]), g = gate(report, readJson(args[1] ?? join(here, "thresholds.json")), { partial });
+ console.log(formatGate(g)); process.exit(g.pass ? 0 : 1);
+ },
+ compare() { const only = flag("--only"); console.log(compareTable(readJson(args[0]), readJson(args[1]), only)); },
+ async correlate() { console.log(await correlate(args.map(readJson), join(here, "../aqua-trial"))); },
+};
+if (!cmds[cmd]) { console.error(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 9).join("\n")); process.exit(2); }
+await cmds[cmd]();
diff --git a/tools/aqua-feel/gallery.js b/tools/aqua-feel/gallery.js
new file mode 100644
index 0000000..b800729
--- /dev/null
+++ b/tools/aqua-feel/gallery.js
@@ -0,0 +1,51 @@
+// Static HTML gallery from a report: no scripts, no network. Each card has the WAV (native audio controls, for a human
+// to press), an SVG level envelope, an SVG spectral-centroid curve, and the metric table with gate failures marked.
+// The page says on its face that nothing was heard.
+const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
+const num = (v, d = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(d) : "–");
+
+function plot(points, { w = 260, h = 56, ymin, ymax, log = false, color }) {
+ if (!points?.length) return ` `;
+ const tmax = points[points.length - 1].t || 1, ys = points.map((p) => (log ? Math.log2(Math.max(p.v, 20)) : p.v));
+ const lo = ymin ?? Math.min(...ys), hi = ymax ?? Math.max(...ys), span = hi - lo || 1;
+ const path = points.map((p, i) => `${i ? "L" : "M"}${((p.t / tmax) * (w - 2) + 1).toFixed(1)},${(h - 2 - ((ys[i] - lo) / span) * (h - 4)).toFixed(1)}`).join("");
+ return ` `;
+}
+
+const ROWS = [
+ ["onset ms", "onsetMs", 2], ["attack ms (10→90% in first 150 ms)", "attackMs", 2], ["attack window-limited", "attackWindowLimited", 0], ["attack slope dB/ms", "attackSlopeDbPerMs", 1],
+ ["sample peak", "samplePeak", 3], ["true peak dBTP", "truePeakDb", 1], ["over-1.0 samples", "overSamples", 0], ["flat-top runs", "flatTopRuns", 0],
+ ["loudness LUFS", "loudnessLufs", 1], ["crest dB", "crestDb", 1], ["centroid median Hz", "centroidMedianHz", 0], ["flux mean", "fluxMean", 3],
+ ["noise-to-tone dB (flat)", "noiseToToneDb", 1], ["noise-to-tone dB (A-weighted)", "noiseToToneADb", 1], ["level range dB", "envRangeDb", 1], ["centroid spread (oct)", "centroidSpreadOct", 2],
+ ["active ms", "activeMs", 0], ["low motion", "lowMotion", 0],
+];
+
+export function gallery(report) {
+ const bad = new Map();
+ for (const [kind, list] of [["fail", report.gate?.failures ?? []], ["waived", report.gate?.waived ?? []], ["warn", report.gate?.warnings ?? []]])
+ for (const e of list) { const k = `${e.id}|${e.metric}`; if (!bad.has(k) || kind === "fail") bad.set(k, kind); }
+ const cards = report.items.map((it) => {
+ const m = it.metrics;
+ if (!m) return `${esc(it.id)} did not render: ${esc(it.error ?? "unknown")}
`;
+ const rows = ROWS.map(([label, key, d]) => {
+ const v = m[key], cls = bad.get(`${it.id}|${key}`) ?? "", shown = typeof v === "boolean" ? String(v) : num(v, d);
+ return `${label} ${shown}${cls ? ` ${cls} ` : ""} `;
+ }).join("");
+ const worst = ["fail", "waived", "warn"].find((k) => [...bad].some(([key, v]) => key.startsWith(it.id + "|") && v === k)) ?? "";
+ return `${esc(it.id)} ${it.group}
+
+level envelope (dB, ${num(m.envelopeCurve?.at(-1)?.t, 1)} s)
${plot(m.envelopeCurve, { ymin: -90, ymax: 6, color: "#6fb1c7" })}
+spectral centroid (log Hz)
${plot(m.centroidCurve, { log: true, ymin: Math.log2(20), ymax: Math.log2(16000), color: "#c7a46f" })}
+${esc(it.origin ?? "")}
`;
+ });
+ const g = report.gate;
+ return `aqua-feel gallery
+
+aqua-feel gallery
+Nothing here was heard. Every number is a measurement of an offline-rendered buffer (faustwasm, mono channel 0, ${report.sampleRate} Hz, libfaust ${esc(report.libfaust)}). Metrics are evidence for a listener to check, not verdicts; the WAVs are there so a person can press play. Gate: ${g?.pass ? "pass" : "fail"} (${g?.failures.length ?? 0} failing, ${g?.waived.length ?? 0} waived, ${g?.warnings.length ?? 0} warnings).
+${cards.join("\n")}
`;
+}
diff --git a/tools/aqua-feel/gate.js b/tools/aqua-feel/gate.js
new file mode 100644
index 0000000..2015d94
--- /dev/null
+++ b/tools/aqua-feel/gate.js
@@ -0,0 +1,64 @@
+// Threshold gate over a report. Rules come from thresholds.json: { group: all|patch|ding, id?: glob, metric, min?, max?,
+// equals?, level?: fail|warn, why }. A waiver { id, metric, reason, followUp, max?|min? } turns a known failure into a
+// printed 'waived' line, but only up to the bound it states: a numeric rule needs a waiver bound on the same side
+// (max for a max rule, min for a min rule) and the value must stay inside it, so a waived item cannot get worse unseen.
+// A waiver that matches nothing failing, and a rule with an `id` that matches no item, are failures on a full run, so
+// waivers get deleted when the thing they excuse is fixed and a catalog rename cannot silently disarm a guard.
+// Thresholds are tripwires for a listener to check, not a judgement of the sound.
+const glob = (pattern) => new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
+const get = (m, path) => path.split(".").reduce((o, k) => o?.[k], m);
+const fmt = (v) => (typeof v === "number" ? +v.toFixed(2) : v);
+
+function violation(rule, value) {
+ if (value === undefined || value === null || Number.isNaN(value)) return "no value";
+ if (rule.equals !== undefined && value !== rule.equals) return `${value} (want ${rule.equals})`;
+ if (rule.max !== undefined && !(value <= rule.max)) return `${fmt(value)} (max ${rule.max})`;
+ if (rule.min !== undefined && !(value >= rule.min)) return `${fmt(value)} (min ${rule.min})`;
+ return null;
+}
+
+// Does waiver `w` cover this violation? Boolean/equals rules: any waiver for the metric. Numeric rules: the waiver must
+// state a bound on the violated side and the value must lie within it.
+function covers(w, rule, value) {
+ if (rule.equals !== undefined) return true;
+ if (typeof value !== "number" || Number.isNaN(value)) return false;
+ if (rule.max !== undefined && !(value <= rule.max)) return typeof w.max === "number" && value <= w.max;
+ if (rule.min !== undefined && !(value >= rule.min)) return typeof w.min === "number" && value >= w.min;
+ return false;
+}
+
+export function gate(report, thresholds, { partial = false } = {}) {
+ const failures = [], warnings = [], waived = [], hits = new Set(), armed = new Set();
+ const waivers = thresholds.waivers ?? [];
+ for (const it of report.items) {
+ if (it.error || it.metrics?.broken) { failures.push({ id: it.id, metric: "render", detail: it.error ?? `${it.metrics.nonFinite} non-finite samples`, why: "item did not render cleanly" }); continue; }
+ const silent = it.metrics?.silent === true;
+ if (silent) failures.push({ id: it.id, metric: "silent", detail: "peak under -70 dBFS", why: "an item that renders nothing" });
+ thresholds.rules.forEach((rule, ri) => {
+ if (rule.group !== "all" && rule.group !== it.group) return;
+ if (rule.id && !glob(rule.id).test(it.id)) return;
+ armed.add(ri);
+ if (silent) return; // reported once above; its other metrics do not exist
+ const value = get(it.metrics, rule.metric), detail = violation(rule, value);
+ if (!detail) return;
+ const entry = { id: it.id, metric: rule.metric, detail, why: rule.why };
+ if (rule.level === "warn") { warnings.push(entry); return; }
+ const w = waivers.findIndex((x) => x.metric === rule.metric && glob(x.id).test(it.id) && covers(x, rule, value));
+ if (w >= 0) { hits.add(w); waived.push({ ...entry, reason: waivers[w].reason, followUp: waivers[w].followUp }); }
+ else failures.push(entry);
+ });
+ }
+ if (!partial) {
+ thresholds.rules.forEach((r, i) => { if (r.id && !armed.has(i)) failures.push({ id: r.id, metric: r.metric, detail: "rule matches no item", why: "disarmed guard: the item was renamed or dropped" }); });
+ waivers.forEach((w, i) => { if (!hits.has(i)) failures.push({ id: w.id, metric: w.metric, detail: "waiver matches nothing failing within its bound", why: "stale waiver: delete it (or the item got worse than the bound)" }); });
+ }
+ return { pass: failures.length === 0, items: report.items.length, partial, failures, warnings, waived };
+}
+
+export function formatGate(g) {
+ const line = (e) => ` ${e.id} ${e.metric}: ${e.detail} [${e.why}]${e.reason ? `\n waived: ${e.reason}${e.followUp ? ` (${e.followUp})` : ""}` : ""}`;
+ const out = [`gate: ${g.pass ? "PASS" : "FAIL"} (${g.items} items${g.partial ? ", partial run: id-rule and waiver bookkeeping skipped" : ""}, ${g.failures.length} failing, ${g.waived.length} waived, ${g.warnings.length} warnings)`];
+ for (const [title, list] of [["FAILING", g.failures], ["WAIVED", g.waived], ["WARNINGS", g.warnings]]) if (list.length) out.push(title, ...list.map(line));
+ out.push("", "Thresholds are tripwires for a listener to check. Passing means nothing was heard to be wrong; it does not mean it sounds right.");
+ return out.join("\n");
+}
diff --git a/tools/aqua-feel/lowered-dings/dings.json b/tools/aqua-feel/lowered-dings/dings.json
new file mode 100644
index 0000000..bdb3748
--- /dev/null
+++ b/tools/aqua-feel/lowered-dings/dings.json
@@ -0,0 +1,184 @@
+{
+ "instruments": [
+ {
+ "id": "glass-chime",
+ "rootFrequency": 261.6256,
+ "dsp": "glass-chime.dsp",
+ "controlPath": "/ding/frequency",
+ "warnings": []
+ },
+ {
+ "id": "muted-pluck",
+ "rootFrequency": 261.6256,
+ "dsp": "muted-pluck.dsp",
+ "controlPath": "/ding/frequency",
+ "warnings": []
+ },
+ {
+ "id": "soft-triangle",
+ "rootFrequency": 261.6256,
+ "dsp": "soft-triangle.dsp",
+ "controlPath": "/ding/frequency",
+ "warnings": []
+ },
+ {
+ "id": "warm-bell",
+ "rootFrequency": 261.6256,
+ "dsp": "warm-bell.dsp",
+ "controlPath": "/ding/frequency",
+ "warnings": []
+ }
+ ],
+ "events": [
+ {
+ "id": "input.required",
+ "meaning": "Input requested",
+ "notes": [
+ {
+ "semitones": 12,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 5,
+ "delayMilliseconds": 260,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "resource.limit",
+ "meaning": "Resource is holding",
+ "notes": [
+ {
+ "semitones": 0,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 0,
+ "delayMilliseconds": 280,
+ "gain": 1
+ },
+ {
+ "semitones": 0,
+ "delayMilliseconds": 560,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "session.end",
+ "meaning": "Session closure",
+ "notes": [
+ {
+ "semitones": 7,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 0,
+ "delayMilliseconds": 240,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "session.start",
+ "meaning": "Opening welcome",
+ "notes": [
+ {
+ "semitones": 0,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 7,
+ "delayMilliseconds": 220,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "task.acknowledge",
+ "meaning": "Quiet ongoing acknowledgement",
+ "notes": [
+ {
+ "semitones": 7,
+ "delayMilliseconds": 0,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "task.complete",
+ "meaning": "Satisfying closure",
+ "notes": [
+ {
+ "semitones": 7,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 12,
+ "delayMilliseconds": 220,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "task.error",
+ "meaning": "Gentle failure notice",
+ "notes": [
+ {
+ "semitones": 7,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 4,
+ "delayMilliseconds": 240,
+ "gain": 1
+ },
+ {
+ "semitones": 0,
+ "delayMilliseconds": 480,
+ "gain": 1
+ }
+ ]
+ },
+ {
+ "id": "task.progress",
+ "meaning": "Faint ignorable progress",
+ "notes": [
+ {
+ "semitones": -5,
+ "delayMilliseconds": 0,
+ "gain": 0.45
+ }
+ ]
+ },
+ {
+ "id": "user.spam",
+ "meaning": "Curt rate warning",
+ "notes": [
+ {
+ "semitones": 9,
+ "delayMilliseconds": 0,
+ "gain": 1
+ },
+ {
+ "semitones": 9,
+ "delayMilliseconds": 190,
+ "gain": 1
+ },
+ {
+ "semitones": 9,
+ "delayMilliseconds": 380,
+ "gain": 1
+ }
+ ]
+ }
+ ],
+ "noteSeconds": 1.5
+}
\ No newline at end of file
diff --git a/tools/aqua-feel/lowered-dings/glass-chime.dsp b/tools/aqua-feel/lowered-dings/glass-chime.dsp
new file mode 100644
index 0000000..16d49a0
--- /dev/null
+++ b/tools/aqua-feel/lowered-dings/glass-chime.dsp
@@ -0,0 +1,56 @@
+import("stdfaust.lib");
+declare name "dings_glass_chime";
+declare options "[nvoices:1]";
+
+time = ba.time / ma.SR;
+age = time;
+clip01(x) = min(1.0, max(0.0, x));
+wrap01(x) = x - floor(x);
+slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));
+softclip(x) = ma.tanh(x * 1.35);
+fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;
+release_start(a,d,g) = max(g, a + d);
+oneshot_adsr_at(t,a,d,s,r,g) = select2(t < a, select2(t < a + d, select2(t < release_start(a,d,g), select2(t < release_start(a,d,g) + r, 0.0, s * (1.0 - (t - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((t - a) / max(0.0001, d))), t / max(0.0001, a));
+oneshot_adsr(a,d,s,r,g) = oneshot_adsr_at(age,a,d,s,r,g);
+seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));
+bez(t,a,b,c,d) = (1.0 - t) * (1.0 - t) * (1.0 - t) * a + 3.0 * (1.0 - t) * (1.0 - t) * t * b + 3.0 * (1.0 - t) * t * t * c + t * t * t * d;
+seg_bez(t,t0,d,a,b,c,e) = bez(clip01((t - t0) / max(0.0001, d)), a, b, c, e);
+smooth01(x) = clip01(x) * clip01(x) * (3.0 - 2.0 * clip01(x));
+seg_smooth(t,t0,d,a,b) = a + (b - a) * smooth01((t - t0) / max(0.0001, d));
+seg_exp(t,t0,d,a,b) = exp(log(max(0.00001, a)) + (log(max(0.00001, b)) - log(max(0.00001, a))) * clip01((t - t0) / max(0.0001, d)));
+seg_curve(c,t,t0,d,a,b) = select2(c < 0.5, seg_exp(t,t0,d,a,b), seg(t,t0,d,a,b));
+rl_release_start(r1,r2,r3,g) = max(g, r1 + r2 + r3);
+rl4_env_from_at(t,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = select2(t < r1, select2(t < r1 + r2, select2(t < r1 + r2 + r3, select2(t < rl_release_start(r1,r2,r3,g), select2(t < rl_release_start(r1,r2,r3,g) + r4, l4, seg_curve(c4, t, rl_release_start(r1,r2,r3,g), r4, l3, l4)), l3), seg_curve(c3, t, r1 + r2, r3, l2, l3)), seg_curve(c2, t, r1, r2, l1, l2)), seg_curve(c1, t, 0, r1, s0, l1));
+rl4_env_at(t,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(t,0.0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env_from(s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(age,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env(r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_at(age,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));
+lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);
+lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);
+lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));
+
+patch_param_0 = hslider("/ding/frequency", 261.6256, 40, 4000, 0.001) : si.smoo;
+
+patch_mod_gain = 0.0;
+patch_mod_pitch = 0.0;
+patch_mod_duty = 0.0;
+patch_mod_lpf = 0.0;
+patch_mod_hpf = 0.0;
+patch_mod_noise = 0.0;
+patch_mod_drive = 0.0;
+patch_mod_fold = 0.0;
+patch_mod_formant_mix = 0.0;
+patch_mod_fm_index = 0.0;
+
+voice_0_age = age;
+voice_0_freq = ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0);
+voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 4.12))) * (max(0.0, 4.1 + patch_mod_fm_index + 0.0) * exp(-voice_0_age / max(0.28, 0.0001))) / ma.PI));
+voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0));
+voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0);
+voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0);
+voice_0_filtered = voice_0_folded : fi.lowpass(1, max(20.0, clip01(1 * (1.0 + 0 * voice_0_age * 1.8) + patch_mod_lpf + 0.0) * 18000.0)) : fi.highpass(1, max(5.0, (clip01(0.04 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * (clip01(0.04 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * 7000.0));
+voice_0_phased = voice_0_filtered;
+voice_0_formants = voice_0_phased;
+voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.001, 0, 1, 0.85, 0.025) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.12);
+
+process = softclip((voice_0) * 1);
diff --git a/tools/aqua-feel/lowered-dings/muted-pluck.dsp b/tools/aqua-feel/lowered-dings/muted-pluck.dsp
new file mode 100644
index 0000000..0fc3e02
--- /dev/null
+++ b/tools/aqua-feel/lowered-dings/muted-pluck.dsp
@@ -0,0 +1,56 @@
+import("stdfaust.lib");
+declare name "dings_muted_pluck";
+declare options "[nvoices:1]";
+
+time = ba.time / ma.SR;
+age = time;
+clip01(x) = min(1.0, max(0.0, x));
+wrap01(x) = x - floor(x);
+slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));
+softclip(x) = ma.tanh(x * 1.35);
+fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;
+release_start(a,d,g) = max(g, a + d);
+oneshot_adsr_at(t,a,d,s,r,g) = select2(t < a, select2(t < a + d, select2(t < release_start(a,d,g), select2(t < release_start(a,d,g) + r, 0.0, s * (1.0 - (t - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((t - a) / max(0.0001, d))), t / max(0.0001, a));
+oneshot_adsr(a,d,s,r,g) = oneshot_adsr_at(age,a,d,s,r,g);
+seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));
+bez(t,a,b,c,d) = (1.0 - t) * (1.0 - t) * (1.0 - t) * a + 3.0 * (1.0 - t) * (1.0 - t) * t * b + 3.0 * (1.0 - t) * t * t * c + t * t * t * d;
+seg_bez(t,t0,d,a,b,c,e) = bez(clip01((t - t0) / max(0.0001, d)), a, b, c, e);
+smooth01(x) = clip01(x) * clip01(x) * (3.0 - 2.0 * clip01(x));
+seg_smooth(t,t0,d,a,b) = a + (b - a) * smooth01((t - t0) / max(0.0001, d));
+seg_exp(t,t0,d,a,b) = exp(log(max(0.00001, a)) + (log(max(0.00001, b)) - log(max(0.00001, a))) * clip01((t - t0) / max(0.0001, d)));
+seg_curve(c,t,t0,d,a,b) = select2(c < 0.5, seg_exp(t,t0,d,a,b), seg(t,t0,d,a,b));
+rl_release_start(r1,r2,r3,g) = max(g, r1 + r2 + r3);
+rl4_env_from_at(t,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = select2(t < r1, select2(t < r1 + r2, select2(t < r1 + r2 + r3, select2(t < rl_release_start(r1,r2,r3,g), select2(t < rl_release_start(r1,r2,r3,g) + r4, l4, seg_curve(c4, t, rl_release_start(r1,r2,r3,g), r4, l3, l4)), l3), seg_curve(c3, t, r1 + r2, r3, l2, l3)), seg_curve(c2, t, r1, r2, l1, l2)), seg_curve(c1, t, 0, r1, s0, l1));
+rl4_env_at(t,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(t,0.0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env_from(s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(age,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env(r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_at(age,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));
+lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);
+lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);
+lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));
+
+patch_param_0 = hslider("/ding/frequency", 261.6256, 40, 4000, 0.001) : si.smoo;
+
+patch_mod_gain = 0.0;
+patch_mod_pitch = 0.0;
+patch_mod_duty = 0.0;
+patch_mod_lpf = 0.0;
+patch_mod_hpf = 0.0;
+patch_mod_noise = 0.0;
+patch_mod_drive = 0.0;
+patch_mod_fold = 0.0;
+patch_mod_formant_mix = 0.0;
+patch_mod_fm_index = 0.0;
+
+voice_0_age = age;
+voice_0_freq = ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0);
+voice_0_osc = 1.0 - 4.0 * abs(wrap01(os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 0.5);
+voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0));
+voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0.05 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.05 + patch_mod_drive + 0.0) * 12.0);
+voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0);
+voice_0_filtered = voice_0_folded : fi.lowpass(1, max(20.0, clip01(0.58 * (1.0 + 0 * voice_0_age * 1.8) + patch_mod_lpf + 0.0) * 18000.0)) : fi.highpass(1, max(5.0, (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * 7000.0));
+voice_0_phased = voice_0_filtered;
+voice_0_formants = voice_0_phased;
+voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.001, 0, 1, 0.34, 0.018) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.13);
+
+process = softclip((voice_0) * 1);
diff --git a/tools/aqua-feel/lowered-dings/soft-triangle.dsp b/tools/aqua-feel/lowered-dings/soft-triangle.dsp
new file mode 100644
index 0000000..154b917
--- /dev/null
+++ b/tools/aqua-feel/lowered-dings/soft-triangle.dsp
@@ -0,0 +1,56 @@
+import("stdfaust.lib");
+declare name "dings_soft_triangle";
+declare options "[nvoices:1]";
+
+time = ba.time / ma.SR;
+age = time;
+clip01(x) = min(1.0, max(0.0, x));
+wrap01(x) = x - floor(x);
+slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));
+softclip(x) = ma.tanh(x * 1.35);
+fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;
+release_start(a,d,g) = max(g, a + d);
+oneshot_adsr_at(t,a,d,s,r,g) = select2(t < a, select2(t < a + d, select2(t < release_start(a,d,g), select2(t < release_start(a,d,g) + r, 0.0, s * (1.0 - (t - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((t - a) / max(0.0001, d))), t / max(0.0001, a));
+oneshot_adsr(a,d,s,r,g) = oneshot_adsr_at(age,a,d,s,r,g);
+seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));
+bez(t,a,b,c,d) = (1.0 - t) * (1.0 - t) * (1.0 - t) * a + 3.0 * (1.0 - t) * (1.0 - t) * t * b + 3.0 * (1.0 - t) * t * t * c + t * t * t * d;
+seg_bez(t,t0,d,a,b,c,e) = bez(clip01((t - t0) / max(0.0001, d)), a, b, c, e);
+smooth01(x) = clip01(x) * clip01(x) * (3.0 - 2.0 * clip01(x));
+seg_smooth(t,t0,d,a,b) = a + (b - a) * smooth01((t - t0) / max(0.0001, d));
+seg_exp(t,t0,d,a,b) = exp(log(max(0.00001, a)) + (log(max(0.00001, b)) - log(max(0.00001, a))) * clip01((t - t0) / max(0.0001, d)));
+seg_curve(c,t,t0,d,a,b) = select2(c < 0.5, seg_exp(t,t0,d,a,b), seg(t,t0,d,a,b));
+rl_release_start(r1,r2,r3,g) = max(g, r1 + r2 + r3);
+rl4_env_from_at(t,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = select2(t < r1, select2(t < r1 + r2, select2(t < r1 + r2 + r3, select2(t < rl_release_start(r1,r2,r3,g), select2(t < rl_release_start(r1,r2,r3,g) + r4, l4, seg_curve(c4, t, rl_release_start(r1,r2,r3,g), r4, l3, l4)), l3), seg_curve(c3, t, r1 + r2, r3, l2, l3)), seg_curve(c2, t, r1, r2, l1, l2)), seg_curve(c1, t, 0, r1, s0, l1));
+rl4_env_at(t,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(t,0.0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env_from(s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(age,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env(r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_at(age,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));
+lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);
+lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);
+lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));
+
+patch_param_0 = hslider("/ding/frequency", 261.6256, 40, 4000, 0.001) : si.smoo;
+
+patch_mod_gain = 0.0;
+patch_mod_pitch = 0.0;
+patch_mod_duty = 0.0;
+patch_mod_lpf = 0.0;
+patch_mod_hpf = 0.0;
+patch_mod_noise = 0.0;
+patch_mod_drive = 0.0;
+patch_mod_fold = 0.0;
+patch_mod_formant_mix = 0.0;
+patch_mod_fm_index = 0.0;
+
+voice_0_age = age;
+voice_0_freq = ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0);
+voice_0_osc = 1.0 - 4.0 * abs(wrap01(os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 0.5);
+voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0));
+voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0);
+voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0);
+voice_0_filtered = voice_0_folded : fi.lowpass(1, max(20.0, clip01(0.72 * (1.0 + 0 * voice_0_age * 1.8) + patch_mod_lpf + 0.0) * 18000.0)) : fi.highpass(1, max(5.0, (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * 7000.0));
+voice_0_phased = voice_0_filtered;
+voice_0_formants = voice_0_phased;
+voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.006, 0, 1, 0.9, 0.04) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.14);
+
+process = softclip((voice_0) * 1);
diff --git a/tools/aqua-feel/lowered-dings/warm-bell.dsp b/tools/aqua-feel/lowered-dings/warm-bell.dsp
new file mode 100644
index 0000000..dd8fc08
--- /dev/null
+++ b/tools/aqua-feel/lowered-dings/warm-bell.dsp
@@ -0,0 +1,56 @@
+import("stdfaust.lib");
+declare name "dings_warm_bell";
+declare options "[nvoices:1]";
+
+time = ba.time / ma.SR;
+age = time;
+clip01(x) = min(1.0, max(0.0, x));
+wrap01(x) = x - floor(x);
+slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));
+softclip(x) = ma.tanh(x * 1.35);
+fold(x) = 2.0 * abs(2.0 * (x / 4.0 - floor(x / 4.0)) - 1.0) - 1.0;
+release_start(a,d,g) = max(g, a + d);
+oneshot_adsr_at(t,a,d,s,r,g) = select2(t < a, select2(t < a + d, select2(t < release_start(a,d,g), select2(t < release_start(a,d,g) + r, 0.0, s * (1.0 - (t - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((t - a) / max(0.0001, d))), t / max(0.0001, a));
+oneshot_adsr(a,d,s,r,g) = oneshot_adsr_at(age,a,d,s,r,g);
+seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));
+bez(t,a,b,c,d) = (1.0 - t) * (1.0 - t) * (1.0 - t) * a + 3.0 * (1.0 - t) * (1.0 - t) * t * b + 3.0 * (1.0 - t) * t * t * c + t * t * t * d;
+seg_bez(t,t0,d,a,b,c,e) = bez(clip01((t - t0) / max(0.0001, d)), a, b, c, e);
+smooth01(x) = clip01(x) * clip01(x) * (3.0 - 2.0 * clip01(x));
+seg_smooth(t,t0,d,a,b) = a + (b - a) * smooth01((t - t0) / max(0.0001, d));
+seg_exp(t,t0,d,a,b) = exp(log(max(0.00001, a)) + (log(max(0.00001, b)) - log(max(0.00001, a))) * clip01((t - t0) / max(0.0001, d)));
+seg_curve(c,t,t0,d,a,b) = select2(c < 0.5, seg_exp(t,t0,d,a,b), seg(t,t0,d,a,b));
+rl_release_start(r1,r2,r3,g) = max(g, r1 + r2 + r3);
+rl4_env_from_at(t,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = select2(t < r1, select2(t < r1 + r2, select2(t < r1 + r2 + r3, select2(t < rl_release_start(r1,r2,r3,g), select2(t < rl_release_start(r1,r2,r3,g) + r4, l4, seg_curve(c4, t, rl_release_start(r1,r2,r3,g), r4, l3, l4)), l3), seg_curve(c3, t, r1 + r2, r3, l2, l3)), seg_curve(c2, t, r1, r2, l1, l2)), seg_curve(c1, t, 0, r1, s0, l1));
+rl4_env_at(t,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(t,0.0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env_from(s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_from_at(age,s0,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+rl4_env(r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g) = rl4_env_at(age,r1,l1,c1,r2,l2,c2,r3,l3,c3,r4,l4,c4,g);
+lfo_sin(hz, phase) = sin(2.0 * ma.PI * (age * hz + phase));
+lfo_tri(hz, phase) = 1.0 - 4.0 * abs((age * hz + phase - floor(age * hz + phase)) - 0.5);
+lfo_sq(hz, phase) = select2((age * hz + phase - floor(age * hz + phase)) < 0.5, -1.0, 1.0);
+lfo_hold(hz, phase) = no.noise : ba.latch(os.oscrs(hz));
+
+patch_param_0 = hslider("/ding/frequency", 261.6256, 40, 4000, 0.001) : si.smoo;
+
+patch_mod_gain = 0.0;
+patch_mod_pitch = 0.0;
+patch_mod_duty = 0.0;
+patch_mod_lpf = 0.0;
+patch_mod_hpf = 0.0;
+patch_mod_noise = 0.0;
+patch_mod_drive = 0.0;
+patch_mod_fold = 0.0;
+patch_mod_formant_mix = 0.0;
+patch_mod_fm_index = 0.0;
+
+voice_0_age = age;
+voice_0_freq = ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0);
+voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, patch_param_0 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 3.01))) * (max(0.0, 2.6 + patch_mod_fm_index + 0.0) * exp(-voice_0_age / max(0.42, 0.0001))) / ma.PI));
+voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0));
+voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0);
+voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0);
+voice_0_filtered = voice_0_folded : fi.lowpass(1, max(20.0, clip01(0.86 * (1.0 + 0 * voice_0_age * 1.8) + patch_mod_lpf + 0.0) * 18000.0)) : fi.highpass(1, max(5.0, (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * (clip01(0 * (1.0 + 0 * voice_0_age * 2.0) + patch_mod_hpf + 0.0)) * 7000.0));
+voice_0_phased = voice_0_filtered;
+voice_0_formants = voice_0_phased;
+voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.004, 0, 1, 1.15, 0.035) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.17);
+
+process = softclip((voice_0) * 1);
diff --git a/tools/aqua-feel/metrics.js b/tools/aqua-feel/metrics.js
new file mode 100644
index 0000000..75beea0
--- /dev/null
+++ b/tools/aqua-feel/metrics.js
@@ -0,0 +1,265 @@
+// Pure signal metrics for aqua-feel. Dependency-free; every function takes mono Float32Array/number[] at SR.
+// These are measurements of a rendered buffer. Nothing here hears anything, and no number is a verdict.
+export const SR = 48000;
+export const SILENT_PEAK = 10 ** (-70 / 20); // a buffer whose peak is below -70 dBFS counts as silent
+const db = (x) => (x > 0 ? 20 * Math.log10(x) : -Infinity);
+const pdb = (x) => (x > 0 ? 10 * Math.log10(x) : -Infinity);
+const median = (xs) => { const s = Float64Array.from(xs).sort(); return s.length ? (s.length % 2 ? s[s.length >> 1] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : NaN; };
+const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN);
+const sd = (xs) => (xs.length < 2 ? 0 : Math.sqrt(xs.reduce((a, x) => a + (x - mean(xs)) ** 2, 0) / (xs.length - 1)));
+const quantile = (xs, q) => { const s = Float64Array.from(xs).sort(); return s.length ? s[Math.min(s.length - 1, Math.floor(q * s.length))] : NaN; };
+
+// ---- levels ------------------------------------------------------------------------------------------------------
+export function samplePeak(x) { let p = 0; for (const v of x) { const a = Math.abs(v); if (a > p) p = a; } return p; }
+
+// True peak: 4x oversampled with a Hann-windowed sinc (BS.1770 Annex 2 style, 48 taps). Returns linear peak.
+export function truePeak(x) {
+ const T = 12, phases = [0.25, 0.5, 0.75];
+ const taps = phases.map((ph) => Array.from({ length: 2 * T }, (_, i) => {
+ const k = i - T + 1, t = k - ph, s = Math.abs(t) < 1e-12 ? 1 : Math.sin(Math.PI * t) / (Math.PI * t);
+ return s * (0.5 + 0.5 * Math.cos((Math.PI * t) / T));
+ }));
+ let peak = samplePeak(x);
+ for (let n = 0; n < x.length; n++) {
+ for (let p = 0; p < 3; p++) {
+ let acc = 0;
+ for (let i = 0; i < 2 * T; i++) { const j = n + i - T + 1; if (j >= 0 && j < x.length) acc += x[j] * taps[p][i]; }
+ if (Math.abs(acc) > peak) peak = Math.abs(acc);
+ }
+ }
+ return peak;
+}
+
+// Over = would clip at a +/-1.0 converter. Flat tops = runs of >=3 consecutive samples at >=0.999 (a clipped waveform's signature).
+export function clipping(x) {
+ let over = 0, flat = 0, run = 0;
+ for (const v of x) {
+ const a = Math.abs(v);
+ if (a > 1) over++;
+ if (a >= 0.999) { run++; if (run === 3) flat++; } else run = 0;
+ }
+ return { overSamples: over, overFraction: x.length ? over / x.length : 0, flatTopRuns: flat };
+}
+
+const K1 = { b: [1.53512485958697, -2.69169618940638, 1.19839281085285], a: [1, -1.69065929318241, 0.73248077421585] };
+const K2 = { b: [1, -2, 1], a: [1, -1.99004745483398, 0.99007225036621] };
+function biquad(x, { b, a }) {
+ const y = new Float64Array(x.length); let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
+ for (let n = 0; n < x.length; n++) {
+ const v = b[0] * x[n] + b[1] * x1 + b[2] * x2 - a[1] * y1 - a[2] * y2;
+ x2 = x1; x1 = x[n]; y2 = y1; y1 = v; y[n] = v;
+ }
+ return y;
+}
+// ITU-R BS.1770-4 integrated loudness, mono, 48 kHz coefficients, 400 ms blocks at 75% overlap, -70 LUFS absolute gate,
+// -10 LU relative gate. A buffer shorter than 400 ms is zero-padded to one block. Returns LUFS, or null when silent.
+export function integratedLoudness(x) {
+ const k = biquad(biquad(x, K1), K2), B = 19200, H = 4800;
+ const n = Math.max(1, Math.ceil(Math.max(0, k.length - B) / H) + 1), ms = [];
+ for (let i = 0; i < n; i++) {
+ let s = 0; for (let j = i * H; j < i * H + B && j < k.length; j++) s += k[j] * k[j];
+ ms.push(s / B);
+ }
+ const lufs = (e) => -0.691 + pdb(e);
+ const pass1 = ms.filter((e) => lufs(e) > -70);
+ if (!pass1.length) return null;
+ const rel = lufs(mean(pass1)) - 10, pass2 = pass1.filter((e) => lufs(e) > rel);
+ return pass2.length ? lufs(mean(pass2)) : null;
+}
+
+// ---- time structure ----------------------------------------------------------------------------------------------
+const HOP = 8; // envelope hop in samples (0.167 ms)
+// Peak-hold envelope: max |x| per hop, held with a 30 ms exponential release so the zero crossings of a low tone do not
+// read as dips (a plain per-hop max does: a 261 Hz sine "falls" 100% twice per cycle). The attack stays instantaneous.
+const RELEASE_S = 0.03;
+export function envelope(x) {
+ const env = new Float64Array(Math.ceil(x.length / HOP)), hold = Math.exp(-HOP / SR / RELEASE_S);
+ for (let i = 0; i < x.length; i++) { const a = Math.abs(x[i]); if (a > env[(i / HOP) | 0]) env[(i / HOP) | 0] = a; }
+ for (let i = 1; i < env.length; i++) env[i] = Math.max(env[i], env[i - 1] * hold);
+ return env;
+}
+const maxIndex = (a) => { let k = 0; for (let i = 1; i < a.length; i++) if (a[i] > a[k]) k = i; return k; };
+const hopMs = (HOP / SR) * 1000;
+
+// Silent = peak under -70 dBFS. Onset: first sample at or above max(-40 dB re peak, -70 dBFS). Attack is measured inside
+// the first 150 ms after the onset (shorter than the 180 ms minimum gap between Ding notes, so a later, louder note cannot
+// stretch it): 10% -> 90% of the envelope's maximum within that window. If that maximum falls in the last tenth of the
+// window the sound is still swelling and attackMs is only a lower bound (attackWindowLimited). The envelope hop is
+// 0.167 ms, so an attack faster than that is reported as one hop (resolutionLimited). activeEnd = last envelope hop
+// within 50 dB of the peak.
+const ATTACK_WINDOW_S = 0.15;
+export function timing(x) {
+ const peak = samplePeak(x);
+ if (!(peak >= SILENT_PEAK)) return { silent: true };
+ const thr = Math.max(peak * 0.01, SILENT_PEAK);
+ let onsetSample = 0; while (onsetSample < x.length && Math.abs(x[onsetSample]) < thr) onsetSample++;
+ const env = envelope(x), start = Math.floor(onsetSample / HOP), end = Math.min(env.length - 1, start + Math.round(ATTACK_WINDOW_S * SR / HOP));
+ let top = start; for (let i = start; i <= end; i++) if (env[i] > env[top]) top = i;
+ const first = env[top], at = (f) => { let i = start; while (i < top && env[i] < first * f) i++; return i; };
+ const i10 = at(0.1), i90 = at(0.9);
+ const dt = Math.max(hopMs, (i90 - i10) * hopMs), floor = peak * 10 ** (-50 / 20);
+ let last = env.length - 1; while (last > 0 && env[last] < floor) last--;
+ return {
+ silent: false, onsetMs: (onsetSample / SR) * 1000, attackMs: dt, attackSlopeDbPerMs: db(0.9 / 0.1) / dt,
+ resolutionLimited: i90 - i10 <= 1, attackWindowLimited: top - start > 0.9 * (end - start) && end - start > 10,
+ activeStartSample: onsetSample, activeEndSample: Math.min(x.length, (last + 1) * HOP),
+ peakAtMs: (maxIndex(env) * HOP / SR) * 1000, firstPeak: first, firstPeakAtMs: top * hopMs,
+ };
+}
+
+export function crest(x, t) {
+ const seg = x.subarray ? x.subarray(t.activeStartSample, t.activeEndSample) : x.slice(t.activeStartSample, t.activeEndSample);
+ let s = 0; for (const v of seg) s += v * v;
+ const rms = Math.sqrt(s / Math.max(1, seg.length)), peak = samplePeak(seg);
+ return { rms, rmsDb: db(rms), crestDb: db(peak / rms) };
+}
+
+// ---- spectrum ----------------------------------------------------------------------------------------------------
+export function fft(re, im) {
+ const n = re.length;
+ for (let i = 1, j = 0; i < n; i++) {
+ let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit;
+ if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
+ }
+ for (let len = 2; len <= n; len <<= 1) {
+ const ang = (-2 * Math.PI) / len, wr = Math.cos(ang), wi = Math.sin(ang);
+ for (let i = 0; i < n; i += len) {
+ let cr = 1, ci = 0;
+ for (let k = 0; k < len / 2; k++) {
+ const a = i + k, b = a + len / 2, tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
+ re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
+ [cr, ci] = [cr * wr - ci * wi, cr * wi + ci * wr];
+ }
+ }
+ }
+}
+
+const N = 2048, SHOP = 512;
+const hanns = new Map();
+const hann = (n) => hanns.get(n) ?? (hanns.set(n, Float64Array.from({ length: n }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n))), hanns.get(n));
+export function stft(x, N = 2048, SHOP = 512) {
+ const frames = [], HANN = hann(N);
+ for (let s = 0; s + N <= Math.max(N, x.length); s += SHOP) {
+ const re = new Float64Array(N), im = new Float64Array(N);
+ for (let i = 0; i < N; i++) re[i] = (x[s + i] ?? 0) * HANN[i];
+ fft(re, im);
+ const mag = new Float64Array(N / 2); let e = 0;
+ for (let k = 0; k < N / 2; k++) { mag[k] = Math.hypot(re[k], im[k]); e += mag[k] * mag[k]; }
+ frames.push({ t: (s + N / 2) / SR, mag, energy: e, n: N });
+ }
+ return frames;
+}
+
+// Tone vs noise, per time-frequency cell. A cell (bin k, frame t) is TONAL when (1) bin k is a local maximum at least
+// 12 dB (power x16) above the median of its +/-12 neighbours in frame t, (2) that holds for a run of at least 4
+// consecutive frames, and (3) over that run the level is temporally smooth: the median absolute second difference of
+// the dB level (max over k-1..k+1, so slow drift stays inside) is under 6 dB. A steady or exponentially decaying partial
+// passes; a noise-excited resonance (a rattle, a vocal formant) passes (1) but fails (3), because noise gives each
+// frame a Rayleigh-random level (about 9 dB median second difference). Tonal cells take +/-2 bins either side (the Hann
+// main lobe). Everything else is noise. Limits: a gliding or strongly vibrato partial moves bins and reads as noise;
+// with under 5 frames only (1) applies.
+// A-weighting as a power factor per FFT bin (IEC 61672 curve, 0 dB at 1 kHz).
+const aWeightPower = (hz) => {
+ const f2 = hz * hz, ra = (12194 ** 2 * f2 * f2) / ((f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2));
+ return (ra * 1.2589) ** 2;
+};
+const prominent = (p, k) => {
+ const nb = []; for (let j = Math.max(1, k - 12); j <= Math.min(p.length - 1, k + 12); j++) if (j !== k) nb.push(p[j]);
+ return p[k] >= p[k - 1] && p[k] >= p[k + 1] && p[k] >= 16 * median(nb);
+};
+function toneNoiseSpan(frames) {
+ const bins = frames[0].mag.length, T = frames.length, P = frames.map((f) => Float64Array.from(f.mag, (m) => m * m));
+ const tonal = P.map(() => new Uint8Array(bins)), prom = P.map((p) => { const r = new Uint8Array(bins); for (let k = 2; k < bins - 2; k++) r[k] = prominent(p, k) ? 1 : 0; return r; });
+ const mark = (t, k) => { for (let j = -2; j <= 2; j++) tonal[t][k + j] = 1; };
+ for (let k = 2; k < bins - 2; k++) {
+ for (let t = 0; t < T;) {
+ if (!prom[t][k]) { t++; continue; }
+ let e = t; while (e + 1 < T && prom[e + 1][k]) e++;
+ if (T < 5) { for (let i = t; i <= e; i++) mark(i, k); }
+ else if (e - t + 1 >= 4) {
+ const lv = []; for (let i = t; i <= e; i++) lv.push(20 * Math.log10(Math.max(frames[i].mag[k - 1], frames[i].mag[k], frames[i].mag[k + 1], 1e-12)));
+ const d2 = []; for (let i = 1; i < lv.length - 1; i++) d2.push(Math.abs(lv[i + 1] - 2 * lv[i] + lv[i - 1]));
+ if (median(d2) < 6) for (let i = t; i <= e; i++) mark(i, k);
+ }
+ t = e + 1;
+ }
+ }
+ const aw = Float64Array.from({ length: bins }, (_, k) => aWeightPower((k * SR) / (2 * bins)));
+ let tone = 0, noise = 0, toneA = 0, noiseA = 0;
+ for (let t = 0; t < T; t++) for (let k = 1; k < bins; k++) { const v = P[t][k]; if (tonal[t][k]) { tone += v; toneA += v * aw[k]; } else { noise += v; noiseA += v * aw[k]; } }
+ return { tone, noise, toneA, noiseA };
+}
+
+// Noise-to-tone ratio in dB over the active span (energy-weighted across frames), flat and A-weighted. Flat power is
+// dominated by whatever is loudest and lowest (a 35 Hz rumble hides a 1 kHz hiss); the A-weighted ratio is closer to what
+// a listener could notice, which is a reason to look at it, not proof of what they notice. Uses an 8192-point window
+// (5.9 Hz bins) when the active span is 1 s or longer, so a 35 Hz rumble resolves into lines; shorter sounds use 2048
+// (23 Hz bins). Compare it between renders of the same sound, not across sounds of different lengths. Clamped to +/-60 dB.
+export function noiseToTone(x, t) {
+ const spanS = (t.activeEndSample - t.activeStartSample) / SR, n = spanS >= 1 ? 8192 : 2048;
+ const frames = stft(x.slice(t.activeStartSample, t.activeEndSample), n, n / 4), maxE = frames.reduce((m, f) => Math.max(m, f.energy), 0);
+ const live = frames.filter((f) => f.energy >= maxE * 1e-5), r = toneNoiseSpan(live);
+ const ratio = (nz, tn) => Math.max(-60, Math.min(60, pdb(nz) - pdb(tn || 1e-30)));
+ return { db: ratio(r.noise, r.tone), dbA: ratio(r.noiseA, r.toneA), window: n };
+}
+
+const downsample = (pts, n) => {
+ if (pts.length <= n) return pts;
+ return Array.from({ length: n }, (_, i) => {
+ const a = Math.floor((i * pts.length) / n), b = Math.max(a + 1, Math.floor(((i + 1) * pts.length) / n)), seg = pts.slice(a, b);
+ return { t: mean(seg.map((s) => s.t)), v: mean(seg.map((s) => s.v)) };
+ });
+};
+
+// Centroid and flux over time, noise-to-tone ratio, and the low-motion flag, over frames within 50 dB of the loudest
+// frame and inside the active span. Flux is the half-wave-rectified L2 change between consecutive unit-norm magnitude
+// spectra, so it tracks timbre change and not loudness change.
+export function spectral(x, t, curvePoints = 48) {
+ const all = stft(x), maxE = all.reduce((m, f) => Math.max(m, f.energy), 0), t0 = t.activeStartSample / SR, t1 = t.activeEndSample / SR;
+ const idx = all.map((f, i) => (f.energy >= maxE * 1e-5 && f.t >= t0 - N / 2 / SR && f.t <= t1 + N / 2 / SR ? i : -1)).filter((i) => i >= 0);
+ if (idx.length < 2) return { frames: idx.length, centroidMedianHz: NaN, centroidCurve: [], fluxCurve: [], fluxMean: 0, fluxP95: 0, centroidSpreadOct: 0, envRangeDb: 0, motion: 0, lowMotion: false, lowMotionReason: "fewer than 2 active frames" };
+ const cent = [], flux = [], hz = (k) => (k * SR) / N;
+ let prev = null;
+ for (const i of idx) {
+ const f = all[i]; let num = 0, den = 0;
+ for (let k = 1; k < f.mag.length; k++) { num += hz(k) * f.mag[k]; den += f.mag[k]; }
+ cent.push({ t: f.t, v: den > 0 ? num / den : 0 });
+ const norm = Math.sqrt(f.energy) || 1;
+ if (prev) { let s = 0; for (let k = 1; k < f.mag.length; k++) { const d = Math.max(0, f.mag[k] / norm - prev[k]); s += d * d; } flux.push({ t: f.t, v: Math.sqrt(s) }); }
+ prev = Float64Array.from(f.mag, (m) => m / norm);
+ }
+ const cs = cent.map((c) => Math.log2(Math.max(c.v, 20))), fl = flux.slice(3).map((f) => f.v), fe = idx.map((i) => pdb(all[i].energy));
+ const centroidSpreadOct = sd(cs), envRangeDb = fe.reduce((a, b) => Math.max(a, b), -Infinity) - fe.reduce((a, b) => Math.min(a, b), Infinity), fluxMean = mean(fl.length ? fl : [0]);
+ // Low motion = the sound stays put: timbre barely moves AND level barely moves, over a span long enough to matter.
+ // A decaying bell has a large level range, so it is not flagged; a held drone or a pink wash is. Flux is reported
+ // but not used: stationary noise has high flux (periodogram scatter) while going nowhere.
+ const spanS = t1 - t0, still = centroidSpreadOct < 0.08 && envRangeDb < 6;
+ return {
+ frames: idx.length, centroidMedianHz: median(cent.map((c) => c.v)), centroidCurve: downsample(cent, curvePoints), fluxCurve: downsample(flux, curvePoints),
+ fluxMean, fluxP95: quantile(fl.length ? fl : [0], 0.95),
+ centroidSpreadOct, envRangeDb, motion: Math.min(1, centroidSpreadOct / 0.5 + envRangeDb / 30 + fluxMean / 0.5),
+ lowMotion: spanS >= 1 && still, lowMotionReason: spanS < 1 ? "active span under 1 s: not judged" : still ? `centroid spread ${centroidSpreadOct.toFixed(3)} oct, level range ${envRangeDb.toFixed(1)} dB` : "",
+ };
+}
+
+// Peak level in dB per bucket (about `n` buckets across the whole buffer), floored at -90 dB, for the gallery's envelope plot.
+export function envelopeCurve(x, n = 96) {
+ const env = envelope(x), per = Math.max(1, Math.ceil(env.length / n)), out = [];
+ for (let i = 0; i < env.length; i += per) { let m = 0; for (let j = i; j < Math.min(env.length, i + per); j++) m = Math.max(m, env[j]); out.push({ t: ((i + per / 2) * HOP) / SR, v: Math.max(-90, db(m)) }); }
+ return out;
+}
+
+// ---- one buffer -> one record ------------------------------------------------------------------------------------
+export function measure(x, { curvePoints = 48 } = {}) {
+ const peak = samplePeak(x), nonFinite = x.reduce((n, v) => n + (Number.isFinite(v) ? 0 : 1), 0);
+ if (nonFinite) return { nonFinite, silent: false, broken: true };
+ const t = timing(x);
+ if (t.silent) return { silent: true, nonFinite: 0, samplePeak: peak };
+ const tp = truePeak(x), c = crest(x, t), sp = spectral(x, t, curvePoints), clip = clipping(x), nt = noiseToTone(x, t);
+ return {
+ silent: false, nonFinite: 0, samplePeak: peak, samplePeakDb: db(peak), truePeak: tp, truePeakDb: db(tp), ...clip,
+ loudnessLufs: integratedLoudness(x), rms: c.rms, rmsDb: c.rmsDb, crestDb: c.crestDb,
+ onsetMs: t.onsetMs, attackMs: t.attackMs, attackSlopeDbPerMs: t.attackSlopeDbPerMs, attackResolutionLimited: t.resolutionLimited, attackWindowLimited: t.attackWindowLimited, peakAtMs: t.peakAtMs,
+ activeMs: ((t.activeEndSample - t.activeStartSample) / SR) * 1000, noiseToToneDb: nt.db, noiseToToneADb: nt.dbA, noiseToToneWindow: nt.window, envelopeCurve: envelopeCurve(x), ...sp,
+ };
+}
diff --git a/tools/aqua-feel/package.json b/tools/aqua-feel/package.json
new file mode 100644
index 0000000..9015ff1
--- /dev/null
+++ b/tools/aqua-feel/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "aqua-feel",
+ "private": true,
+ "type": "module",
+ "description": "Headless feel meter for AquaSynth: renders every shipped patch and Ding offline and measures them. Measures; does not listen.",
+ "scripts": { "test": "node test.mjs", "feel": "node feel.mjs" }
+}
diff --git a/tools/aqua-feel/render.js b/tools/aqua-feel/render.js
new file mode 100644
index 0000000..91cdb11
--- /dev/null
+++ b/tools/aqua-feel/render.js
@@ -0,0 +1,70 @@
+// Offline render through the same faustwasm runtime the playground and the listening trial load (not the daemon's
+// native libfaust path: same Faust source, same compiler version family, but a different host; see README).
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { SR } from "./metrics.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+export const playground = resolve(here, "../aqua-playground");
+
+export async function loadRuntime() {
+ const fw = join(playground, "node_modules/@grame/faustwasm");
+ const mod = await import(pathToFileURL(join(fw, "dist/esm/index.js")).href);
+ const libfaust = new mod.LibFaust(await mod.instantiateFaustModuleFromFile(join(fw, "libfaust-wasm/libfaust-wasm.js")));
+ return { mod, libfaust, compiler: new mod.FaustCompiler(libfaust), version: libfaust.version(), cache: new Map() };
+}
+
+// Compiled generator per DSP text (a Ding event reuses one compile for all its notes).
+export async function compile(rt, name, source) {
+ if (rt.cache.has(source)) return rt.cache.get(source);
+ const g = new rt.mod.FaustMonoDspGenerator();
+ if (!(await g.compile(rt.compiler, name.replace(/[^A-Za-z0-9_]/g, "_"), source, "-ftz 2"))) throw new Error(`compile ${name}: ${rt.compiler.getErrorMessage()}`);
+ const controls = [];
+ const walk = (items) => { for (const it of items ?? []) { if (it.items) walk(it.items); else if (it.address) controls.push({ label: it.label, address: it.address }); } };
+ walk(JSON.parse(g.factory.json).ui);
+ const entry = { g, controls };
+ rt.cache.set(source, entry);
+ return entry;
+}
+
+// Render `seconds` of one generator with named controls; returns channel 0 and the channel count.
+// Multi-channel output is reported, not mixed, so a metric always describes a buffer someone can hear.
+export async function renderOnce(entry, params, seconds) {
+ const p = await entry.g.createOfflineProcessor(SR, 512);
+ for (const [label, value] of Object.entries(params ?? {})) {
+ const bare = (t) => t.replace(/^\//, ""), c = entry.controls.find((x) => bare(x.label) === bare(label) || x.address === label);
+ if (!c) throw new Error(`no control "${label}" (has ${entry.controls.map((x) => x.label).join(", ") || "none"})`);
+ p.setParamValue(c.address, value);
+ }
+ const out = p.render(null, Math.round(SR * seconds));
+ return { samples: out[0], channels: out.length };
+}
+
+// A Ding event is the instrument's note rendered per DingNote at root * 2^(semitones/12), scaled by commandGain * note.gain
+// and mixed at the note's delay: DingsPlaybackHost's recipe, minus the audio device.
+export async function renderDing(rt, instrument, instrumentSource, event, { noteSeconds, commandGain }) {
+ const entry = await compile(rt, `ding_${instrument.id}`, instrumentSource);
+ const last = Math.max(...event.notes.map((n) => n.delayMilliseconds));
+ const mix = new Float32Array(Math.round(SR * (noteSeconds + last / 1000)));
+ for (const n of event.notes) {
+ const hz = instrument.rootFrequency * 2 ** (n.semitones / 12);
+ const { samples } = await renderOnce(entry, { [instrument.controlPath]: hz }, noteSeconds);
+ const off = Math.round((n.delayMilliseconds / 1000) * SR), g = commandGain * n.gain;
+ for (let i = 0; i < samples.length && off + i < mix.length; i++) mix[off + i] += samples[i] * g;
+ }
+ return { samples: mix, channels: 1 };
+}
+
+// 32-bit float mono WAV, so overs above 1.0 survive to disk instead of being clamped on the way out.
+export function wavFloat32(samples) {
+ const dataBytes = samples.length * 4, b = Buffer.alloc(44 + dataBytes);
+ b.write("RIFF", 0); b.writeUInt32LE(36 + dataBytes, 4); b.write("WAVEfmt ", 8); b.writeUInt32LE(16, 16);
+ b.writeUInt16LE(3, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(32, 34);
+ b.write("data", 36); b.writeUInt32LE(dataBytes, 40);
+ for (let i = 0; i < samples.length; i++) b.writeFloatLE(samples[i], 44 + i * 4);
+ return b;
+}
+export function writeWav(path, samples) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, wavFloat32(samples)); }
+
+export const readText = (p) => readFileSync(p, "utf8");
diff --git a/tools/aqua-feel/report.js b/tools/aqua-feel/report.js
new file mode 100644
index 0000000..020b0d5
--- /dev/null
+++ b/tools/aqua-feel/report.js
@@ -0,0 +1,46 @@
+// Before/after table and the listening-trial correlation.
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { mean, spearman, permutationP } from "../aqua-trial/analyze.mjs";
+import { variants, SECONDS } from "../aqua-trial/logic.js";
+import { measure } from "./metrics.js";
+import { loadRuntime, compile, renderOnce } from "./render.js";
+
+export const COMPARE_METRICS = ["samplePeak", "truePeakDb", "overSamples", "loudnessLufs", "crestDb", "centroidMedianHz", "noiseToToneDb", "noiseToToneADb", "onsetMs", "attackMs", "lowMotion"];
+const f = (v) => (typeof v === "number" ? (Number.isInteger(v) ? String(v) : v.toFixed(2)) : String(v));
+
+export function compareTable(before, after, only) {
+ const b = new Map(before.items.map((i) => [i.id, i])), out = [];
+ for (const a of after.items) {
+ if ((only && !a.id.includes(only)) || !b.has(a.id) || !a.metrics || !b.get(a.id).metrics) continue;
+ out.push(`${a.id}`);
+ for (const k of COMPARE_METRICS) {
+ const x = b.get(a.id).metrics[k], y = a.metrics[k];
+ if (x === undefined) continue;
+ const d = typeof x === "number" && typeof y === "number" ? (y - x >= 0 ? "+" : "") + (y - x).toFixed(2) : x === y ? "" : "changed";
+ out.push(` ${k.padEnd(18)} ${f(x).padStart(10)} -> ${f(y).padStart(10)} ${d}`);
+ }
+ }
+ return out.length ? out.join("\n") : "no common items";
+}
+
+// Joins trial exports (aqua-trial) to feel metrics by rendering the same four variants, then asks the trial's own question
+// of each metric: does it rank-correlate with the ratings (|rho| >= 0.5 and p < 0.05, fixed before any data)? With few
+// listeners this is deliberately hard to meet; "no evidence" is the expected answer, and it is not evidence of absence.
+export function correlate(exports, trialDir) {
+ return correlateAsync(exports, trialDir);
+}
+async function correlateAsync(exports, trialDir) {
+ const rt = await loadRuntime(), entry = await compile(rt, "thruster_trial", readFileSync(join(trialDir, "thruster-trial.dsp"), "utf8"));
+ const metricsBy = {};
+ for (const v of variants()) metricsBy[v.id] = measure((await renderOnce(entry, v.params, SECONDS)).samples);
+ const records = exports.flatMap((e) => e.records);
+ const lines = [`${exports.length} listener export(s), ${records.length} ratings; variants rendered here at ${SECONDS} s`, "", "metric rho p n -> predicts?"];
+ for (const k of ["noiseToToneDb", "noiseToToneADb", "loudnessLufs", "samplePeak", "rms", "centroidMedianHz", "fluxMean", "crestDb"]) {
+ const xs = records.map((r) => metricsBy[r.variant][k]), ys = records.map((r) => r.rating), rho = spearman(xs, ys), p = permutationP(xs, ys, rho);
+ lines.push(`${k.padEnd(18)} ${Number.isFinite(rho) ? rho.toFixed(2).padStart(6) : " n/a"} ${Number.isFinite(p) ? p.toFixed(3).padStart(6) : " n/a"} ${String(xs.length).padStart(3)} -> ${Number.isFinite(rho) && Math.abs(rho) >= 0.5 && p < 0.05 ? "PREDICTS" : "no evidence it predicts"}`);
+ }
+ lines.push("", `mean rating by variant: ${variants().map((v) => `${v.id} ${mean(records.filter((r) => r.variant === v.id).map((r) => r.rating)).toFixed(2)}`).join(", ")}`);
+ lines.push("Four variants are four distinct metric values, so every rank correlation here is really a statement about the variant ordering. Nothing was heard by this script.");
+ return lines.join("\n");
+}
diff --git a/tools/aqua-feel/test.mjs b/tools/aqua-feel/test.mjs
new file mode 100644
index 0000000..50e140d
--- /dev/null
+++ b/tools/aqua-feel/test.mjs
@@ -0,0 +1,190 @@
+// Checks of the meter itself on synthetic signals with known answers, the gate's waiver bookkeeping, the WAV writer, the
+// gallery, and the catalog. It renders no patch; `node feel.mjs run` does that. Nothing here hears anything.
+// Usage: node test.mjs
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { SR, measure, truePeak, samplePeak, integratedLoudness, clipping, fft, timing } from "./metrics.js";
+import { gate } from "./gate.js";
+import { gallery } from "./gallery.js";
+import { wavFloat32 } from "./render.js";
+import { patchItems, dingItems } from "./catalog.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+let n = 0;
+const t = (name, fn) => { fn(); n++; console.log(`ok ${name}`); };
+const sine = (hz, amp, sec, ph = 0) => Float32Array.from({ length: Math.round(SR * sec) }, (_, i) => amp * Math.sin(2 * Math.PI * hz * i / SR + ph));
+let seed = 1; const rand = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 2 ** 32 - 0.5; };
+const noise = (amp, sec) => Float32Array.from({ length: Math.round(SR * sec) }, () => amp * 2 * rand());
+const decay = (x, tau) => x.map((v, i) => v * Math.exp(-i / SR / tau));
+const near = (a, b, tol, msg) => assert.ok(Math.abs(a - b) <= tol, `${msg}: ${a} vs ${b} (+/-${tol})`);
+
+t("fft: a bin-centred cosine lands in its bin", () => {
+ const re = Float64Array.from({ length: 256 }, (_, i) => Math.cos(2 * Math.PI * 16 * i / 256)), im = new Float64Array(256);
+ fft(re, im); near(Math.hypot(re[16], im[16]), 128, 1e-9, "bin 16"); near(Math.hypot(re[17], im[17]), 0, 1e-9, "bin 17");
+});
+
+t("levels: a 0.5 sine reads peak 0.5, rms -9.03 dB, crest 3.01 dB, loudness -9.7 LUFS (BS.1770)", () => {
+ const m = measure(sine(440, 0.5, 2));
+ near(m.samplePeak, 0.5, 1e-6, "peak"); near(m.rmsDb, -9.03, 0.05, "rms"); near(m.crestDb, 3.01, 0.05, "crest"); near(m.loudnessLufs, -9.7, 0.2, "LUFS");
+});
+
+t("loudness: -6 dB is -6 LU; silence is null", () => {
+ near(integratedLoudness(sine(1000, 0.5, 2)) - integratedLoudness(sine(1000, 0.25, 2)), 6.02, 0.01, "6 dB step");
+ assert.equal(integratedLoudness(new Float32Array(SR)), null);
+});
+
+t("loudness: gating drops the silent tail (the naive 3 s mean would read 7.8 dB lower); block-edge dilution stays under 2 dB", () => {
+ const x = new Float32Array(SR * 3); x.set(sine(1000, 0.5, 0.5));
+ near(integratedLoudness(x), integratedLoudness(sine(1000, 0.5, 0.5)), 2, "gated vs bare");
+});
+
+t("true peak: a tone sampled between its crests exceeds the sample peak; a sample-aligned one does not", () => {
+ const x = sine(12000, 0.9, 0.1, Math.PI / 4); // samples sit 45 degrees off the crests
+ assert.ok(samplePeak(x) < 0.7 && truePeak(x) > 0.85 && truePeak(x) < 0.95, `${samplePeak(x)} ${truePeak(x)}`);
+ const y = sine(1000, 0.5, 0.1); near(truePeak(y), 0.5, 0.01, "oversampling must not invent peaks on a slow tone");
+});
+
+t("clipping: counts samples over 1.0 and flat-top runs, ignores a clean 0.99", () => {
+ const x = sine(100, 2, 0.1).map((v) => Math.max(-1.2, Math.min(1.2, v)));
+ const c = clipping(x); assert.ok(c.overSamples > 100 && c.flatTopRuns > 0);
+ const y = clipping(sine(100, 0.99, 0.1)); assert.equal(y.overSamples, 0); assert.equal(y.flatTopRuns, 0);
+});
+
+t("timing: onset latency is where the sound starts, attack is the first-note rise", () => {
+ const x = new Float32Array(SR); x.set(sine(1000, 0.5, 0.5).map((v, i) => v * Math.min(1, i / (0.01 * SR))), Math.round(0.02 * SR));
+ const tm = timing(x); near(tm.onsetMs, 20 + 1, 1.5, "onset"); near(tm.attackMs, 8, 1.5, "10-90% of a 10 ms ramp is 8 ms");
+});
+
+t("timing: a two-note event reports the first note's attack, even when a louder note follows 200 ms later before the first has decayed", () => {
+ const x = new Float32Array(SR * 2);
+ x.set(decay(sine(500, 0.3, 0.6), 0.5)); const b = decay(sine(800, 0.9, 0.5), 0.1); for (let i = 0; i < b.length; i++) x[Math.round(0.2 * SR) + i] += b[i];
+ assert.ok(timing(x).attackMs < 3, `${timing(x).attackMs}`);
+});
+
+t("timing: a swell longer than the 150 ms attack window is flagged window-limited", () => {
+ assert.equal(timing(sine(220, 0.5, 2).map((v, i) => v * i / (2 * SR))).attackWindowLimited, true);
+ assert.equal(timing(decay(sine(220, 0.5, 1), 0.2)).attackWindowLimited, false);
+});
+
+t("attack: a low tone's ramp is not mistaken for its zero crossings (261 Hz, 30 ms ramp; 220 Hz, 2 s swell)", () => {
+ const ramp = (hz, ms) => { const x = sine(hz, 0.5, 1); return x.map((v, i) => v * Math.min(1, i / (ms / 1000 * SR))); };
+ for (const hz of [130, 261.6, 523, 1000]) near(timing(ramp(hz, 30)).attackMs, 24, 3, `${hz} Hz, 30 ms ramp`);
+ assert.ok(timing(sine(220, 0.5, 2).map((v, i) => v * i / (2 * SR))).attackMs > 100, "a 2 s swell is not a click");
+});
+
+t("silent: a -80 dBFS tone is silent, a -60 dBFS tone is not", () => {
+ assert.equal(measure(sine(1000, 1e-4, 1)).silent, true); assert.equal(measure(sine(1000, 1e-3, 1)).silent, false);
+});
+
+t("noise-to-tone: a sine reads far tonal, white noise far noisy, and the A-weighted ratio sees a quiet 1 kHz hiss under a loud 40 Hz tone", () => {
+ assert.ok(measure(sine(440, 0.5, 2)).noiseToToneDb < -30);
+ assert.ok(measure(noise(0.3, 2)).noiseToToneDb > 20);
+ const low = sine(40, 0.5, 3), hiss = noise(0.02, 3), x = low.map((v, i) => v + hiss[i]), m = measure(x);
+ assert.ok(m.noiseToToneADb > m.noiseToToneDb + 8, `flat ${m.noiseToToneDb}, A-weighted ${m.noiseToToneADb}`);
+});
+
+t("noise-to-tone: a noise-excited resonance is not called a tone, a steady sine is (temporal smoothness)", () => {
+ // two-pole resonator at 600 Hz, Q~25, fed with noise: spectrally narrow but randomly modulated
+ const w = 2 * Math.PI * 600 / SR, r = 1 - w / 50, a1 = -2 * r * Math.cos(w), a2 = r * r, e = noise(1, 3), y = new Float32Array(e.length);
+ for (let i = 2; i < y.length; i++) y[i] = e[i] - a1 * y[i - 1] - a2 * y[i - 2];
+ const peak = samplePeak(y); const ring = y.map((v) => (0.4 * v) / peak);
+ assert.ok(measure(ring).noiseToToneDb > measure(sine(600, 0.4, 3)).noiseToToneDb + 25, "ringing noise must read noisier than a sine");
+});
+
+t("low motion: a held tone and held noise are flagged; a decaying bell is not; under 1 s is not judged", () => {
+ assert.equal(measure(sine(440, 0.5, 2)).lowMotion, true);
+ assert.equal(measure(noise(0.3, 2)).lowMotion, true);
+ assert.equal(measure(decay(sine(880, 0.5, 2), 0.25)).lowMotion, false);
+ assert.equal(measure(sine(440, 0.5, 0.6)).lowMotion, false);
+});
+
+t("centroid: follows a sweep upward", () => {
+ const x = Float32Array.from({ length: SR * 2 }, (_, i) => 0.5 * Math.sin(2 * Math.PI * (200 * i / SR + 1000 * (i / SR) ** 2 / 2)));
+ const c = measure(x).centroidCurve; assert.ok(c.at(-2).v > c[1].v * 3, `${c[1].v} -> ${c.at(-2).v}`);
+});
+
+t("silence and non-finite: reported, not crashed on", () => {
+ assert.equal(measure(new Float32Array(SR)).silent, true);
+ const x = sine(440, 0.5, 0.1); x[10] = NaN; const m = measure(x); assert.equal(m.broken, true); assert.equal(m.nonFinite, 1);
+});
+
+const item = (id, group, metrics) => ({ id, group, metrics: { nonFinite: 0, silent: false, overSamples: 0, truePeakDb: -6, lowMotion: false, ...metrics } });
+const rules = [{ group: "all", metric: "overSamples", equals: 0, why: "clips" }, { group: "ding", metric: "onsetMs", max: 5, why: "late" }, { group: "all", metric: "truePeakDb", max: -1, level: "warn", why: "headroom" }];
+
+t("gate: failing rule fails; group scoping respected; warn does not fail", () => {
+ const g = gate({ items: [item("a", "patch", { overSamples: 3, onsetMs: 50 }), item("b", "ding", { onsetMs: 50 }), item("c", "patch", { truePeakDb: -0.5 })] }, { rules });
+ assert.equal(g.pass, false); assert.deepEqual(g.failures.map((f) => `${f.id}:${f.metric}`), ["a:overSamples", "b:onsetMs"]); assert.equal(g.warnings.length, 1);
+});
+
+t("gate: a waiver downgrades a failure, a stale waiver fails, partial runs skip the stale check", () => {
+ const rep = { items: [item("a", "patch", { overSamples: 3 }), item("c", "patch", {})] };
+ const waivers = [{ id: "a", metric: "overSamples", reason: "r" }];
+ assert.equal(gate(rep, { rules, waivers }).pass, true); assert.equal(gate(rep, { rules, waivers }).waived.length, 1);
+ const stale = gate(rep, { rules, waivers: [...waivers, { id: "c", metric: "overSamples", reason: "r" }] });
+ assert.equal(stale.pass, false); assert.match(stale.failures[0].why, /stale waiver/);
+ assert.equal(gate(rep, { rules, waivers: [{ id: "c", metric: "overSamples", reason: "r" }] }, { partial: true }).pass, false); // a still fails
+});
+
+t("gate: a waiver covers only up to its stated bound; a numeric waiver without a bound covers nothing", () => {
+ const r = [{ group: "all", metric: "truePeakDb", max: 0, why: "tp" }];
+ const mk = (tp) => ({ items: [item("g", "patch", { truePeakDb: tp })] });
+ const w = [{ id: "g", metric: "truePeakDb", max: 1, reason: "r" }];
+ assert.equal(gate(mk(0.9), { rules: r, waivers: w }).pass, true);
+ assert.equal(gate(mk(6), { rules: r, waivers: w }).pass, false);
+ assert.equal(gate(mk(0.9), { rules: r, waivers: [{ id: "g", metric: "truePeakDb", reason: "r" }] }).pass, false);
+ assert.equal(gate(mk(null), { rules: r, waivers: w }).pass, false);
+});
+
+t("gate: a rule whose id matches no item fails a full run (disarmed guard) and is skipped on a partial run; a missing metric fails", () => {
+ const th = { rules: [{ group: "patch", id: "thruster@rpm600", metric: "truePeakDb", max: 0, why: "w" }] };
+ const rep = { items: [item("thr@rpm600", "patch", {})] };
+ assert.equal(gate(rep, th).pass, false); assert.match(gate(rep, th).failures[0].detail, /matches no item/);
+ assert.equal(gate(rep, th, { partial: true }).pass, true);
+ assert.equal(gate({ items: [item("a", "patch", {})] }, { rules: [{ group: "all", metric: "lowMotion", equals: false, why: "w" }] }).pass, true);
+ const gone = item("a", "patch", {}); delete gone.metrics.lowMotion;
+ assert.equal(gate({ items: [gone] }, { rules: [{ group: "all", metric: "lowMotion", equals: false, why: "w" }] }).pass, false);
+});
+
+t("gate: a silent item fails once and does not also trip every other rule", () => {
+ const g = gate({ items: [{ id: "s", group: "patch", metrics: { silent: true, nonFinite: 0, samplePeak: 0 } }] }, { rules: [{ group: "patch", metric: "onsetMs", max: 5, why: "w" }] });
+ assert.equal(g.failures.length, 1); assert.equal(g.failures[0].metric, "silent");
+});
+
+t("gate: a render error, NaN output, or glob rule id is handled", () => {
+ const g = gate({ items: [{ id: "x", group: "patch", error: "boom" }, { id: "y", group: "patch", metrics: { broken: true, nonFinite: 2 } }] }, { rules });
+ assert.equal(g.failures.length, 2);
+ const g2 = gate({ items: [item("thruster@rpm300", "patch", { onsetMs: 1, ntr: 9 })] }, { rules: [{ group: "patch", id: "thruster@*", metric: "ntr", max: 5, why: "w" }] });
+ assert.equal(g2.pass, false);
+});
+
+t("thresholds.json: every rule names a metric and a why; every waiver names a reason and a follow-up", () => {
+ const th = JSON.parse(readFileSync(join(here, "thresholds.json"), "utf8"));
+ for (const r of th.rules) { assert.ok(r.metric && r.why && ["all", "patch", "ding"].includes(r.group), JSON.stringify(r)); assert.ok(["min", "max", "equals"].some((k) => k in r)); }
+ for (const w of th.waivers) assert.ok(w.id && w.metric && w.reason && /^aqua-body:follow_up:/.test(w.followUp), JSON.stringify(w));
+ for (const w of th.waivers) {
+ const rules = th.rules.filter((r) => r.metric === w.metric && (r.group === "all" || r.group === "patch"));
+ assert.ok(rules.every((r) => r.equals !== undefined || "max" in w || "min" in w), `numeric waiver without a bound: ${JSON.stringify(w)}`);
+ }
+});
+
+t("wav: 32-bit float mono header, length, and samples above 1.0 survive", () => {
+ const b = wavFloat32(Float32Array.from([0, 2.5, -1.5]));
+ assert.equal(b.toString("ascii", 0, 4), "RIFF"); assert.equal(b.readUInt16LE(20), 3); assert.equal(b.readUInt16LE(34), 32); assert.equal(b.readUInt32LE(24), SR);
+ assert.equal(b.readUInt32LE(40), 12); assert.equal(b.readFloatLE(48), 2.5); assert.equal(b.length, 44 + 12);
+});
+
+t("catalog: the playground patches with the thruster fanned to four rpm, and 9 events on 4 instruments (36 Dings)", () => {
+ const p = patchItems(), d = dingItems();
+ assert.equal(p.filter((i) => i.id.startsWith("thruster")).length, 4); assert.ok(p.length >= 41 && p.every((i) => i.source.includes("process")));
+ assert.equal(d.length, 36); assert.ok(new Set(d.map((i) => i.id)).size === 36);
+});
+
+t("gallery: says on its face that nothing was heard, escapes ids, marks failing metrics", () => {
+ const rep = { sampleRate: SR, libfaust: "x", gate: { pass: false, failures: [{ id: "a