From eb35ca545dd99bf5cdf7befc8e13731e68473555 Mon Sep 17 00:00:00 2001 From: Aqua Date: Thu, 5 Nov 2026 15:31:35 +0000 Subject: [PATCH 1/2] Add aqua-feel: headless feel meter and gate; fix PRC SYNTH1 clipping and thruster low-rpm hiss tools/aqua-feel renders every playground patch (thruster at four rpm) and every Ding event on every instrument offline through faustwasm, measures onset latency, attack, crest, true peak and clipping, BS.1770 loudness, centroid/flux over time, noise-to-tone ratio (flat and A-weighted) and a low-motion flag, writes report.json plus a static gallery, and gates against thresholds.json (waivers must cite a follow-up and go stale). Nothing is heard; metrics are evidence, not verdicts. Uses it: PRC SYNTH1 calibrated peaked at 2.10 (18,633 overs); shipped patch gain 1.4 to 0.45, peak 0.67. Thruster low-rpm A-weighted noise-to-tone 20.7/23.0 dB at rpm 300/600 down to 13.1/5.0 by cutting the idle rasp and rattle floors and opening the rumble low-pass at low load. Trial copy mirrored; its README notes the narrower sweep. AquaLower gains --dings to lower DingCatalog instruments and events. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + docs/renders/aqua-feel-2026-11-05.md | 46 ++++ .../public-domain/prc-synth1-calibrated.aqua | 4 +- patches/thruster/thruster.dsp | 9 +- tools/AquaLower/AquaLower.csproj | 1 + tools/AquaLower/Program.cs | 20 ++ tools/aqua-feel/README.md | 70 +++++ tools/aqua-feel/catalog.js | 27 ++ tools/aqua-feel/feel.mjs | 66 +++++ tools/aqua-feel/gallery.js | 51 ++++ tools/aqua-feel/gate.js | 44 +++ tools/aqua-feel/lowered-dings/dings.json | 184 +++++++++++++ tools/aqua-feel/lowered-dings/glass-chime.dsp | 56 ++++ tools/aqua-feel/lowered-dings/muted-pluck.dsp | 56 ++++ .../aqua-feel/lowered-dings/soft-triangle.dsp | 56 ++++ tools/aqua-feel/lowered-dings/warm-bell.dsp | 56 ++++ tools/aqua-feel/metrics.js | 256 ++++++++++++++++++ tools/aqua-feel/package.json | 7 + tools/aqua-feel/render.js | 70 +++++ tools/aqua-feel/report.js | 46 ++++ tools/aqua-feel/test.mjs | 146 ++++++++++ tools/aqua-feel/thresholds.json | 152 +++++++++++ tools/aqua-playground/README.md | 2 +- ...x7-public-domain-prc-synth1-calibrated.dsp | 2 +- tools/aqua-playground/lowered/thruster.dsp | 9 +- tools/aqua-trial/README.md | 11 +- tools/aqua-trial/thruster-trial.dsp | 9 +- 27 files changed, 1438 insertions(+), 19 deletions(-) create mode 100644 docs/renders/aqua-feel-2026-11-05.md create mode 100644 tools/aqua-feel/README.md create mode 100644 tools/aqua-feel/catalog.js create mode 100644 tools/aqua-feel/feel.mjs create mode 100644 tools/aqua-feel/gallery.js create mode 100644 tools/aqua-feel/gate.js create mode 100644 tools/aqua-feel/lowered-dings/dings.json create mode 100644 tools/aqua-feel/lowered-dings/glass-chime.dsp create mode 100644 tools/aqua-feel/lowered-dings/muted-pluck.dsp create mode 100644 tools/aqua-feel/lowered-dings/soft-triangle.dsp create mode 100644 tools/aqua-feel/lowered-dings/warm-bell.dsp create mode 100644 tools/aqua-feel/metrics.js create mode 100644 tools/aqua-feel/package.json create mode 100644 tools/aqua-feel/render.js create mode 100644 tools/aqua-feel/report.js create mode 100644 tools/aqua-feel/test.mjs create mode 100644 tools/aqua-feel/thresholds.json diff --git a/.gitignore b/.gitignore index 92db1ab..0a9a6b8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ NuGet.Local.Config _tmp_*.txt *.user *.suo +tools/aqua-feel/out/ diff --git a/docs/renders/aqua-feel-2026-11-05.md b/docs/renders/aqua-feel-2026-11-05.md new file mode 100644 index 0000000..58e0bf2 --- /dev/null +++ b/docs/renders/aqua-feel-2026-11-05.md @@ -0,0 +1,46 @@ +# aqua-feel first run, 2026-11-05 + +Nothing here was heard. These are metrics from offline renders (faustwasm, mono, 48 kHz, 3 s per patch); they are evidence, +not verdicts. `tools/aqua-feel/README.md` says what each number is and where it breaks. + +Before: `patches/` at `origin/main` d8c625c under the same rules: the gate fails on 3 items (PRC SYNTH1: overs, true peak, +loudness; sfxr golf: true peak, loudness, low motion; sfxr explosion: low motion). After: PRC SYNTH1 gain trimmed 1.4 to +0.45 (a uniform gain: crest and centroid are unchanged), and `thruster.dsp` mix edited (below). Gate after: pass, 4 waivers +(golf x3, explosion x1), 1 warning (golf, +0.9 dBTP). + +## prc-synth1-calibrated + + samplePeak 2.10 -> 0.67 -1.42 + truePeakDb 6.44 -> -3.41 -9.86 + overSamples 18633 -> 0 -18633.00 + loudnessLufs -0.67 -> -10.53 -9.86 + crestDb 6.67 -> 6.67 +0.00 + centroidMedianHz 794.44 -> 794.44 +0.00 + noiseToToneDb -17.18 -> -17.18 +0.00 + noiseToToneADb -19.14 -> -19.14 +0.00 + onsetMs 0.13 -> 0.13 +0.00 + attackMs 0.33 -> 0.33 +0.00 + lowMotion false -> false + +The shipped file's `patch gain=1.4` is the constant the parity probe in `Dx7ReferenceParityTests` writes; the probe asserts +RMS ratio against Dexed, so the test is untouched and still builds its own script at 1.4. Only the shipped library patch +carries the trim. Whether 0.45 is the right level for a listener is unchecked. + +## thruster + +Edits to `patches/thruster/thruster.dsp` (mirrored in `tools/aqua-trial/thruster-trial.dsp`): rasp gain `0.3 + 1.5*load` to +`0.05 + 1.75*load`; rattle floor `0.1 + 0.9*load^2` to `0.015 + 0.985*load^2`; rumble low-pass `60 + 2.5*shaft` to +`60 + 2.5*shaft + 140*(1-load)`. At load 1 (rpm 6000) every term equals its old value (rasp 1.8, rattle 1, low-pass 60 + 2.5*shaft). +Decomposition at rpm 300 before the edit (A-weighted noise-to-tone, dB): full mix +21, rasp removed +20.5, rattle removed ++13.0, both removed +12.5. The rattle modes, not the rasp, were most of the audible-band noise at low rpm; with both gone a +floor of +12.5 dB remains (the pitch-wandering saw smears its harmonics, which the line detector counts as noise). + + rpm A-weighted noise-to-tone dB (before -> after) loudness LUFS centroid Hz + 300 20.7 -> 13.1 -29.5 -> -30.2 2388 -> 752 + 600 23.0 -> 5.0 -28.0 -> -28.7 2531 -> 1228 + 1200 20.1 -> 12.7 -25.4 -> -26.1 2913 -> 2114 + 4800 10.3 -> 9.7 -17.3 -> -17.4 4987 -> 4914 + +The centroid falls a lot at low rpm: the sound is darker as well as less noisy. Whether that is "more tone" to a listener or +"muffled" is exactly what this meter cannot say. The listening trial (`tools/aqua-trial`) now sweeps a narrower noise range at +rpm 600 (see its README). diff --git a/patches/dx7/public-domain/prc-synth1-calibrated.aqua b/patches/dx7/public-domain/prc-synth1-calibrated.aqua index 78bbe7d..05b5ce2 100644 --- a/patches/dx7/public-domain/prc-synth1-calibrated.aqua +++ b/patches/dx7/public-domain/prc-synth1-calibrated.aqua @@ -2,9 +2,11 @@ # Family: dx7/public-domain # Source: analog1.syx voice 17 parity probe, generated from calibrated DX7 lowering # Notes: Hard DX7 parity target that forced applied EG, summed route, feedback, detune, and COM calibration. +# Headroom: patch gain is 0.45, not the 1.4 the parity probe in Dx7ReferenceParityTests uses. At 1.4 this graph peaked at 2.10 +# (18,633 samples past full scale in a 3 s render, found by tools/aqua-feel); 0.45 keeps the same signal at 0.675, about -3.4 dBFS. patch - gain=1.4 + gain=0.45 soft_clip=false opgraph diff --git a/patches/thruster/thruster.dsp b/patches/thruster/thruster.dsp index 9260b6a..c36a8ae 100644 --- a/patches/thruster/thruster.dsp +++ b/patches/thruster/thruster.dsp @@ -13,17 +13,18 @@ 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); +// 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)); // 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 +// 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 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); +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); -mix = rumble * (0.5 + 0.5 * load) + rasp * (0.3 + 1.5 * load) + rattle; +mix = rumble * (0.5 + 0.5 * load) + rasp * (0.05 + 1.75 * 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/AquaLower/AquaLower.csproj b/tools/AquaLower/AquaLower.csproj index 6c55fc0..fcd7f18 100644 --- a/tools/AquaLower/AquaLower.csproj +++ b/tools/AquaLower/AquaLower.csproj @@ -10,6 +10,7 @@ + diff --git a/tools/AquaLower/Program.cs b/tools/AquaLower/Program.cs index 6f0b113..e2d4948 100644 --- a/tools/AquaLower/Program.cs +++ b/tools/AquaLower/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using AquaSynth.Dings; using AquaSynth.Dsl; // Lowers .aqua patches to Faust source for the static playground (tools/aqua-playground). @@ -6,6 +7,25 @@ using AquaSynth.Dsl; // A .aqua entry goes through FaustEmitter.EmitScript, the same lowering the daemon uses; a .dsp entry // (the thruster) is already Faust and is copied verbatim. Writes /.dsp, and // catalog.json for the browser (the one non-CultNet xeno at this boundary). +// usage: AquaLower --dings : lowers each DingCatalog instrument to /.dsp and writes dings.json +// (instruments with their root frequency, events with their notes) so aqua-feel can mix an event the way DingsPlaybackHost does. +if (args.Length == 2 && args[0] == "--dings") +{ + var dingDir = Path.GetFullPath(args[1]); + Directory.CreateDirectory(dingDir); + var instruments = new List(); + foreach (var ins in DingCatalog.Instruments.Values.OrderBy(i => i.Id, StringComparer.Ordinal)) + { + var export = FaustEmitter.EmitScript(ins.Script, new FaustExportOptions("dings_" + ins.Id.Replace('-', '_'))); + File.WriteAllText(Path.Combine(dingDir, ins.Id + ".dsp"), export.Source); + instruments.Add(new { id = ins.Id, rootFrequency = ins.RootFrequency, dsp = ins.Id + ".dsp", controlPath = "/ding/frequency", warnings = export.Warnings }); + Console.WriteLine($"{ins.Id}: {export.Source.Length} chars, {export.Warnings.Count} warnings"); + } + var events = DingCatalog.Events.Values.OrderBy(e => e.Id, StringComparer.Ordinal) + .Select(e => new { id = e.Id, meaning = e.Meaning, notes = e.Notes.Select(n => new { semitones = n.Semitones, delayMilliseconds = n.DelayMilliseconds, gain = n.Gain }) }); + File.WriteAllText(Path.Combine(dingDir, "dings.json"), JsonSerializer.Serialize(new { instruments, events, noteSeconds = 1.5 }, new JsonSerializerOptions { WriteIndented = true })); + return 0; +} if (args.Length < 3) { Console.Error.WriteLine("usage: AquaLower = ..."); 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..0526140 --- /dev/null +++ b/tools/aqua-feel/README.md @@ -0,0 +1,70 @@ +# 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 # 20 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; --out DIR; --no-gate +node feel.mjs gate out/report.json # re-gate a saved report +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`. + +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` | 10% to 90% of the FIRST peak (running max until the envelope falls 3 dB) on a 0.17 ms envelope | A faster attack reads as one hop. Meaningless on sustained noise: the "first peak" is just the first 3 dB dip | +| `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, gated | A mono sound with no reference level; compare items, do not read it as a delivered loudness | +| `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 | +| `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 waiver that no longer matches +a failing rule is itself a failure, so it is deleted when the thing it excuses is fixed. 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..70bf702 --- /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] re-gate an existing report +// 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); + 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 report = readJson(args[0]), g = gate(report, readJson(args[1] ?? join(here, "thresholds.json"))); + 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..fcb21c6 --- /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%, first peak)", "attackMs", 2], ["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" })} +${rows}

${esc(it.origin ?? "")}

`; + }); + const g = report.gate; + return `aqua-feel gallery + +

aqua-feel gallery

+ +
${cards.join("\n")}
`; +} diff --git a/tools/aqua-feel/gate.js b/tools/aqua-feel/gate.js new file mode 100644 index 0000000..f172bba --- /dev/null +++ b/tools/aqua-feel/gate.js @@ -0,0 +1,44 @@ +// 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 } turns a known failure into a printed +// 'waived' line, and a waiver that no longer matches a failing rule is itself a failure, so waivers get deleted when +// the thing they excuse is fixed. 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); + +function violation(rule, value) { + if (value === undefined || value === null || Number.isNaN(value)) return rule.min !== undefined || rule.max !== undefined ? "no value" : null; + 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; +} +const fmt = (v) => (typeof v === "number" ? +v.toFixed(2) : v); + +export function gate(report, thresholds, { partial = false } = {}) { + const failures = [], warnings = [], waived = [], hits = 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; } + for (const rule of thresholds.rules) { + if (rule.group !== "all" && rule.group !== it.group) continue; + if (rule.id && !glob(rule.id).test(it.id)) continue; + const detail = violation(rule, get(it.metrics, rule.metric)); + if (!detail) continue; + const entry = { id: it.id, metric: rule.metric, detail, why: rule.why }; + const w = waivers.findIndex((x) => x.metric === rule.metric && glob(x.id).test(it.id)); + if (rule.level === "warn") warnings.push(entry); + else if (w >= 0) { hits.add(w); waived.push({ ...entry, reason: waivers[w].reason, followUp: waivers[w].followUp }); } + else failures.push(entry); + } + } + if (!partial) waivers.forEach((w, i) => { if (!hits.has(i)) failures.push({ id: w.id, metric: w.metric, detail: "waiver matches nothing failing", why: "stale waiver: delete it" }); }); + return { pass: failures.length === 0, items: report.items.length, 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.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..4e4766a --- /dev/null +++ b/tools/aqua-feel/metrics.js @@ -0,0 +1,256 @@ +// 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; +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) +export function envelope(x) { + const env = new Float64Array(Math.ceil(x.length / HOP)); + 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; } + return env; +} +const hopMs = (HOP / SR) * 1000; + +// Onset: first sample at or above max(-40 dB re peak, -70 dBFS). Attack is measured on the FIRST peak: the running +// envelope maximum from the onset until the envelope falls 3 dB below it (so a two-note Ding reports its first note's +// attack, not a ramp to the second note's peak). 10% -> 90% of that first peak. 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. +export function timing(x) { + const peak = samplePeak(x); + if (!(peak > 0)) return { silent: true }; + const thr = Math.max(peak * 0.01, 10 ** (-70 / 20)); + let onsetSample = 0; while (onsetSample < x.length && Math.abs(x[onsetSample]) < thr) onsetSample++; + const env = envelope(x), start = Math.floor(onsetSample / HOP); + let top = start, end = start; + for (let i = start; i < env.length; i++) { if (env[i] > env[top]) top = i; if (env[i] < env[top] * 0.708) break; end = i; } + const first = env[top], at = (f) => { let i = start; while (i < end && 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, activeStartSample: onsetSample, activeEndSample: Math.min(x.length, (last + 1) * HOP), + peakAtMs: (env.indexOf(Math.max(...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 = Math.max(...frames.map((f) => f.energy)); + 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 = Math.max(...all.map((f) => f.energy)), 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 = Math.max(...fe) - Math.min(...fe), 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: 0 }; + 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, 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..526c94f --- /dev/null +++ b/tools/aqua-feel/test.mjs @@ -0,0 +1,146 @@ +// 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, not the ramp to the louder second note", () => { + const x = new Float32Array(SR * 2); + x.set(decay(sine(500, 0.3, 0.3), 0.05)); const b = decay(sine(800, 0.9, 0.5), 0.1); for (let i = 0; i < b.length; i++) x[Math.round(0.4 * SR) + i] += b[i]; + assert.ok(timing(x).attackMs < 3, `${timing(x).attackMs}`); +}); + +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 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)); +}); + +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 Date: Thu, 5 Nov 2026 15:42:13 +0000 Subject: [PATCH 2/2] aqua-feel: review fixes (attack on low tones, disarmed guards, bounded waivers, drift test) - attack: peak-hold envelope and a 150 ms window; a 261 Hz tone no longer reads its first half-cycle as the attack (warm-bell 0.67 ms -> 3.8 ms, declared 4 ms). - gate: a rule whose id matches no item fails a full run; waivers carry a bound and cover only up to it; a missing metric fails its rule; silent items fail once; --only runs gate as partial; silent threshold is -70 dBFS. - DingCatalogTests: lowered Dings fed to the meter must match the catalog. - README states NTR window/Q dependence, short-buffer loudness padding and the assumed Ding command gain; faust-toolchain-boundary notes the v5 manifest predates the mix change. Co-Authored-By: Claude Sonnet 5.5 --- docs/faust-toolchain-boundary.md | 4 +- tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs | 34 +++++++++++ tools/aqua-feel/README.md | 21 ++++--- tools/aqua-feel/feel.mjs | 6 +- tools/aqua-feel/gallery.js | 2 +- tools/aqua-feel/gate.js | 56 +++++++++++++------ tools/aqua-feel/metrics.js | 45 +++++++++------ tools/aqua-feel/test.mjs | 48 +++++++++++++++- tools/aqua-feel/thresholds.json | 16 +++--- 9 files changed, 173 insertions(+), 59 deletions(-) diff --git a/docs/faust-toolchain-boundary.md b/docs/faust-toolchain-boundary.md index 5027d99..ee46cae 100644 --- a/docs/faust-toolchain-boundary.md +++ b/docs/faust-toolchain-boundary.md @@ -108,7 +108,9 @@ home whose `lib/` has `libfaust.so` and whose `share/faust` has both. listen. `docs/renders/thruster-v5-manifest.json` is its output for `patches/thruster/thruster.dsp` at PR #3 head `c6561da`, 4 s per case. The render is deterministic (two reruns byte-identical); the 2026-10-31 sweep file is a 6 s -render, which is why its hash differs from a 4 s sweep. +render, which is why its hash differs from a 4 s sweep. That manifest describes the mix at `c6561da`; +`patches/thruster/thruster.dsp` changed on 2026-11-05 (lower idle noise floors, rumble low-pass opens at low load; see +`docs/renders/aqua-feel-2026-11-05.md`), so its `dspSha256` and WAV hashes no longer match the repo's patch. ## Invariants diff --git a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs index 4c45a01..5e6f9a6 100644 --- a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs +++ b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AquaSynth.Dings; namespace AquaSynth.Dsl.Tests; @@ -32,4 +33,37 @@ public sealed class DingCatalogTests foreach (var pair in dingEvent.Notes.Zip(dingEvent.Notes.Skip(1))) Assert.InRange(pair.Second.DelayMilliseconds - pair.First.DelayMilliseconds, 180, 320); } + + // tools/aqua-feel measures the lowered files in tools/aqua-feel/lowered-dings; regenerate them with + // `dotnet run --project tools/AquaLower -- --dings tools/aqua-feel/lowered-dings` when the catalog changes. + [Fact] + public void LoweredDingsFedToTheFeelMeterMatchTheCatalog() + { + var dir = Path.Combine(RepositoryRoot(), "tools", "aqua-feel", "lowered-dings"); + foreach (var instrument in DingCatalog.Instruments.Values) + { + var expected = FaustEmitter.EmitScript(instrument.Script, new FaustExportOptions("dings_" + instrument.Id.Replace('-', '_'))).Source; + Assert.Equal(expected, File.ReadAllText(Path.Combine(dir, instrument.Id + ".dsp"))); + } + + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "dings.json"))); + var instruments = doc.RootElement.GetProperty("instruments").EnumerateArray().ToDictionary(e => e.GetProperty("id").GetString()!); + Assert.Equal(DingCatalog.Instruments.Keys.Order(), instruments.Keys.Order()); + foreach (var instrument in DingCatalog.Instruments.Values) + Assert.Equal(instrument.RootFrequency, instruments[instrument.Id].GetProperty("rootFrequency").GetSingle()); + var events = doc.RootElement.GetProperty("events").EnumerateArray().ToDictionary(e => e.GetProperty("id").GetString()!); + Assert.Equal(DingCatalog.Events.Keys.Order(), events.Keys.Order()); + foreach (var ev in DingCatalog.Events.Values) + { + var notes = events[ev.Id].GetProperty("notes").EnumerateArray().Select(n => (n.GetProperty("semitones").GetDouble(), n.GetProperty("delayMilliseconds").GetInt32(), n.GetProperty("gain").GetSingle())).ToArray(); + Assert.Equal(ev.Notes.Select(n => (n.Semitones, n.DelayMilliseconds, n.Gain)).ToArray(), notes); + } + } + + private static string RepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("AquaSynth.sln not found above the test binaries."); + } } diff --git a/tools/aqua-feel/README.md b/tools/aqua-feel/README.md index 0526140..5d7d8e4 100644 --- a/tools/aqua-feel/README.md +++ b/tools/aqua-feel/README.md @@ -11,10 +11,10 @@ The WAVs and the gallery exist so a person can press play. ``` (cd ../aqua-playground && npm ci) # faustwasm runtime, once -node test.mjs # 20 checks of the meter on synthetic signals; renders no patch +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; --out DIR; --no-gate -node feel.mjs gate out/report.json # re-gate a saved report +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 ``` @@ -29,7 +29,8 @@ non-zero on any unwaived failure or stale waiver. No CI runner exists in this re `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`. + `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 @@ -40,19 +41,21 @@ nobody has diffed the two. | 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` | 10% to 90% of the FIRST peak (running max until the envelope falls 3 dB) on a 0.17 ms envelope | A faster attack reads as one hop. Meaningless on sustained noise: the "first peak" is just the first 3 dB dip | +| `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, gated | A mono sound with no reference level; compare items, do not read it as a delivered loudness | +| `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 | +| `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 waiver that no longer matches -a failing rule is itself a failure, so it is deleted when the thing it excuses is fixed. The numbers were chosen after looking +`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 diff --git a/tools/aqua-feel/feel.mjs b/tools/aqua-feel/feel.mjs index 70bf702..f5d220c 100644 --- a/tools/aqua-feel/feel.mjs +++ b/tools/aqua-feel/feel.mjs @@ -3,7 +3,7 @@ // 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] re-gate an existing report +// 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"; @@ -45,7 +45,7 @@ async function run() { } 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); + 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)); @@ -56,7 +56,7 @@ async function run() { const cmds = { run, gate() { - const report = readJson(args[0]), g = gate(report, readJson(args[1] ?? join(here, "thresholds.json"))); + 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)); }, diff --git a/tools/aqua-feel/gallery.js b/tools/aqua-feel/gallery.js index fcb21c6..b800729 100644 --- a/tools/aqua-feel/gallery.js +++ b/tools/aqua-feel/gallery.js @@ -13,7 +13,7 @@ function plot(points, { w = 260, h = 56, ymin, ymax, log = false, color }) { } const ROWS = [ - ["onset ms", "onsetMs", 2], ["attack ms (10→90%, first peak)", "attackMs", 2], ["attack slope dB/ms", "attackSlopeDbPerMs", 1], + ["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], diff --git a/tools/aqua-feel/gate.js b/tools/aqua-feel/gate.js index f172bba..2015d94 100644 --- a/tools/aqua-feel/gate.js +++ b/tools/aqua-feel/gate.js @@ -1,43 +1,63 @@ // 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 } turns a known failure into a printed -// 'waived' line, and a waiver that no longer matches a failing rule is itself a failure, so waivers get deleted when -// the thing they excuse is fixed. Thresholds are tripwires for a listener to check, not a judgement of the sound. +// 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 rule.min !== undefined || rule.max !== undefined ? "no value" : null; + 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; } -const fmt = (v) => (typeof v === "number" ? +v.toFixed(2) : v); + +// 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(); + 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; } - for (const rule of thresholds.rules) { - if (rule.group !== "all" && rule.group !== it.group) continue; - if (rule.id && !glob(rule.id).test(it.id)) continue; - const detail = violation(rule, get(it.metrics, rule.metric)); - if (!detail) 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 }; - const w = waivers.findIndex((x) => x.metric === rule.metric && glob(x.id).test(it.id)); - if (rule.level === "warn") warnings.push(entry); - else if (w >= 0) { hits.add(w); waived.push({ ...entry, reason: waivers[w].reason, followUp: waivers[w].followUp }); } + 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) waivers.forEach((w, i) => { if (!hits.has(i)) failures.push({ id: w.id, metric: w.metric, detail: "waiver matches nothing failing", why: "stale waiver: delete it" }); }); - return { pass: failures.length === 0, items: report.items.length, failures, warnings, waived }; + 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.failures.length} failing, ${g.waived.length} waived, ${g.warnings.length} warnings)`]; + 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/metrics.js b/tools/aqua-feel/metrics.js index 4e4766a..75beea0 100644 --- a/tools/aqua-feel/metrics.js +++ b/tools/aqua-feel/metrics.js @@ -1,6 +1,7 @@ // 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; }; @@ -68,33 +69,41 @@ export function integratedLoudness(x) { // ---- 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)); + 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; -// Onset: first sample at or above max(-40 dB re peak, -70 dBFS). Attack is measured on the FIRST peak: the running -// envelope maximum from the onset until the envelope falls 3 dB below it (so a two-note Ding reports its first note's -// attack, not a ramp to the second note's peak). 10% -> 90% of that first peak. 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. +// 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 > 0)) return { silent: true }; - const thr = Math.max(peak * 0.01, 10 ** (-70 / 20)); + 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); - let top = start, end = start; - for (let i = start; i < env.length; i++) { if (env[i] > env[top]) top = i; if (env[i] < env[top] * 0.708) break; end = i; } - const first = env[top], at = (f) => { let i = start; while (i < end && env[i] < first * f) i++; return i; }; + 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, activeStartSample: onsetSample, activeEndSample: Math.min(x.length, (last + 1) * HOP), - peakAtMs: (env.indexOf(Math.max(...env)) * HOP / SR) * 1000, firstPeak: first, firstPeakAtMs: top * hopMs, + 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, }; } @@ -188,7 +197,7 @@ function toneNoiseSpan(frames) { // (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 = Math.max(...frames.map((f) => f.energy)); + 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 }; @@ -206,7 +215,7 @@ const downsample = (pts, n) => { // 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 = Math.max(...all.map((f) => f.energy)), t0 = t.activeStartSample / SR, t1 = t.activeEndSample / SR; + 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; @@ -220,7 +229,7 @@ export function spectral(x, t, curvePoints = 48) { 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 = Math.max(...fe) - Math.min(...fe), fluxMean = mean(fl.length ? fl : [0]); + 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. @@ -245,12 +254,12 @@ 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: 0 }; + 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, peakAtMs: t.peakAtMs, + 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/test.mjs b/tools/aqua-feel/test.mjs index 526c94f..50e140d 100644 --- a/tools/aqua-feel/test.mjs +++ b/tools/aqua-feel/test.mjs @@ -57,12 +57,27 @@ t("timing: onset latency is where the sound starts, attack is the first-note ris 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, not the ramp to the louder second note", () => { +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.3), 0.05)); const b = decay(sine(800, 0.9, 0.5), 0.1); for (let i = 0; i < b.length; i++) x[Math.round(0.4 * SR) + i] += b[i]; + 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); @@ -112,6 +127,31 @@ t("gate: a waiver downgrades a failure, a stale waiver fails, partial runs skip 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); @@ -123,6 +163,10 @@ t("thresholds.json: every rule names a metric and a why; every waiver names a re 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", () => { diff --git a/tools/aqua-feel/thresholds.json b/tools/aqua-feel/thresholds.json index d0a5ffc..239e8d7 100644 --- a/tools/aqua-feel/thresholds.json +++ b/tools/aqua-feel/thresholds.json @@ -55,7 +55,7 @@ "group": "patch", "metric": "onsetMs", "max": 25, - "why": "first audible sample later than 25 ms after note-on" + "why": "first sample within 40 dB of the peak (and above -70 dBFS) later than 25 ms after note-on" }, { "group": "ding", @@ -74,7 +74,7 @@ "group": "ding", "metric": "onsetMs", "max": 5, - "why": "a notification that starts audibly late" + "why": "a notification whose first sample within 40 dB of the peak comes later than 5 ms after note-on" }, { "group": "ding", @@ -99,21 +99,21 @@ "id": "thruster@rpm300", "metric": "noiseToToneADb", "max": 16, - "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.7 dB; the cap sits 3 dB over the 13.1 dB measured after it, so the hiss cannot creep back unnoticed" + "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.7 dB; the cap sits 2-3 dB over the 13.1 dB measured after it, so the hiss cannot creep back unnoticed" }, { "group": "patch", "id": "thruster@rpm600", "metric": "noiseToToneADb", "max": 8, - "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 23.0 dB; the cap sits 3 dB over the 5.0 dB measured after it, so the hiss cannot creep back unnoticed" + "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 23.0 dB; the cap sits 2-3 dB over the 5.0 dB measured after it, so the hiss cannot creep back unnoticed" }, { "group": "patch", "id": "thruster@rpm1200", "metric": "noiseToToneADb", "max": 15, - "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.1 dB; the cap sits 3 dB over the 12.7 dB measured after it, so the hiss cannot creep back unnoticed" + "why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.1 dB; the cap sits 2-3 dB over the 12.7 dB measured after it, so the hiss cannot creep back unnoticed" }, { "group": "patch", @@ -128,13 +128,15 @@ "id": "sfxr-classic-classic-sfxr-abstract-golf", "metric": "truePeakDb", "reason": "renders 0.99 sample peak and +0.9 dBTP inter-sample", - "followUp": "aqua-body:follow_up:sfxr-golf-hot" + "followUp": "aqua-body:follow_up:sfxr-golf-hot", + "max": 1.0 }, { "id": "sfxr-classic-classic-sfxr-abstract-golf", "metric": "loudnessLufs", "reason": "-5.3 LUFS integrated over the 3 s render", - "followUp": "aqua-body:follow_up:sfxr-golf-hot" + "followUp": "aqua-body:follow_up:sfxr-golf-hot", + "max": -5.0 }, { "id": "sfxr-classic-classic-sfxr-abstract-golf", -- 2.47.3