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",