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/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/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/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/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..5d7d8e4 --- /dev/null +++ b/tools/aqua-feel/README.md @@ -0,0 +1,73 @@ +# aqua-feel + +Headless feel meter for AquaSynth. It renders every shipped patch and every Ding offline, measures the buffers, writes +JSON and a static HTML gallery, and gates the numbers against thresholds. + +**Nothing here was heard.** Every number is a measurement of an offline-rendered buffer. Metrics are evidence for a +listener to check, not verdicts: a render can pass every threshold and sound wrong, and fail one and sound fine. +The WAVs and the gallery exist so a person can press play. + +## Run + +``` +(cd ../aqua-playground && npm ci) # faustwasm runtime, once +node test.mjs # 26 checks of the meter on synthetic signals; renders no patch +node feel.mjs run # render 81 items (~15 s), write out/{report.json,index.html,wav/}, gate; exit 1 on failure +node feel.mjs run --only thruster # a subset (gate skips id-rule and waiver bookkeeping); --out DIR; --no-gate +node feel.mjs gate out/report.json # re-gate a saved report (--partial for a subset) +node feel.mjs compare before.json after.json [--only SUBSTR] # before/after table +node feel.mjs correlate trial-export.json ... # feel metrics vs aqua-trial listener ratings +``` + +Open `out/index.html` in a browser (it references `wav/` relatively). The gate is the CI step: `node feel.mjs run` exits +non-zero on any unwaived failure or stale waiver. No CI runner exists in this repo yet; nothing runs it automatically. + +## What it renders + +- Every patch in `tools/aqua-playground/lowered/` (41 `.aqua` files lowered by `tools/AquaLower`, plus the hand-written + `thruster.dsp` at rpm 300 / 600 / 1200 / 4800), 3 s each at 48 kHz, no gate or trigger: the sound the patch makes when started. + `advanced/additive-pad` and `advanced/electro-drop-demo` lower to ~1.5 MB of Faust each and are not in the playground, so not here. +- Every `DingCatalog` event on every instrument (9 x 4 = 36), mixed the way `DingsPlaybackHost` does it: each note rendered at + `root * 2^(semitones/12)` for 1.5 s, scaled by command gain 0.6 and the note gain, placed at its delay. + `lowered-dings/` is `dotnet run --project tools/AquaLower -- --dings tools/aqua-feel/lowered-dings`; `DingCatalogTests.LoweredDingsFedToTheFeelMeterMatchTheCatalog` fails when it drifts from `DingCatalog.cs`. + The command gain 0.6 is an assumption (`DingsPlaybackHost` takes it from the caller as 0..1), so the Ding loudness window depends on it. + +Render path: the same faustwasm offline processor the playground and the listening trial use (libfaust 2.90.x, `-ftz 2`), +mono channel 0. It is **not** the daemon's native libfaust path (`-vec`); the same Faust source should render alike, but +nobody has diffed the two. + +## What it measures (per item, `report.json` `items[].metrics`) + +| metric | meaning | limits | +|---|---|---| +| `onsetMs` | first sample at or above max(-40 dB re peak, -70 dBFS), from note-on | Offline render has no device latency; this is the patch's own lead-in only | +| `attackMs`, `attackSlopeDbPerMs`, `attackWindowLimited` | 10% to 90% of the envelope maximum inside the first 150 ms after onset, on a 0.17 ms peak-hold envelope (30 ms release, so a low tone's zero crossings are not read as dips) | A faster attack reads as one hop. A swell longer than 150 ms reports a lower bound and sets `attackWindowLimited`. The envelope is a peak follower, so a tone below ~30 Hz would still ripple | +| `samplePeak`, `truePeakDb`, `overSamples`, `flatTopRuns` | peak, 4x-oversampled true peak, samples beyond 1.0, runs of 3+ samples at 0.999 | WAVs are float32 so overs survive to disk | +| `loudnessLufs` | BS.1770-4 integrated, mono, K-weighted (48 kHz coefficients; renders are fixed at 48 kHz), gated | A mono sound with no reference level; compare items, do not read it as a delivered loudness. A buffer under 400 ms is zero-padded to one block and reads low (0.1 s of full 1 kHz sine: -9.0, true -3.0); every shipped item is 1.5 s or longer | +| `crestDb`, `rmsDb` | over the active span (onset to last frame within 50 dB of peak) | | +| `centroidMedianHz`, `centroidCurve`, `fluxMean`, `fluxCurve` | STFT 2048/512 over active frames; flux is the change between unit-norm spectra | Flux on stationary noise is high; it is reported, not gated | +| `noiseToToneDb`, `noiseToToneADb` | energy not in stable spectral lines vs energy in them, flat and A-weighted | A heuristic split (see `metrics.js`): steady or decaying partials read tonal, noise-excited resonances read noise, gliding partials read noise. 8192-point window for spans of 1 s or more, else 2048, so compare the same sound across edits, not different sounds. It is window- and Q-dependent: white noise through a Q20 band-pass at 1 kHz reads as noise over 3 s (8192 window) but partly tonal over 0.5 s (2048 window), and a Q100 resonance reads tonal. The thruster rpm caps are fit to this heuristic at its 8192 window | +| `lowMotion` | span of 1 s or more with centroid spread under 0.08 octave and level range under 6 dB | The song-swarm collapse shape (docs/music-agent-orchestration-lessons.md): a held drone or a wash. A legitimate sustained drone is flagged too | + +## The gate + +`thresholds.json`: rules scoped to `all`, `patch`, `ding` or an id glob, each with a `why`; `level: warn` prints without failing. +`waivers` turn a known failure into a printed line and must name a reason and a follow-up id; a numeric waiver carries a bound +(`max`/`min`) and covers the item only up to it, so a waived item cannot get worse unseen. A waiver that no longer matches a +failing rule, and a rule with an `id` that matches no item (a renamed or dropped guard), are failures on a full run, so waivers +are deleted when the thing they excuse is fixed and a rename cannot silently disarm a cap. A missing metric fails its rule. The numbers were chosen after looking +at the shipped renders. They are tripwires, not taste. + +Current waivers: `sfxr-classic-classic-sfxr-abstract-golf` (+0.9 dBTP, -5.3 LUFS, held noise) and `sfxr-explosion` (3 s of +steady noise without decay). Nobody has listened to either to see if the number is the sound. + +## Reuse + +`report.js` imports `mean`, `spearman` and `permutationP` from `../aqua-trial/analyze.mjs` and the variants from +`../aqua-trial/logic.js`: `correlate` renders the trial's four thruster variants, measures them, and asks the trial's own +question (|rho| >= 0.5 and p < 0.05) of each metric against real ratings once listeners export some. With four variants +every correlation is really about the variant ordering. + +## Recorded first run + +`docs/renders/aqua-feel-2026-11-05.md`: before/after numbers for the PRC SYNTH1 and thruster edits. diff --git a/tools/aqua-feel/catalog.js b/tools/aqua-feel/catalog.js new file mode 100644 index 0000000..76ba31a --- /dev/null +++ b/tools/aqua-feel/catalog.js @@ -0,0 +1,27 @@ +// What aqua-feel renders: every playground patch, the thruster at four rpm, and every Ding event on every instrument. +import { join } from "node:path"; +import { readText, playground } from "./render.js"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const THRUSTER_RPM = [300, 600, 1200, 4800]; +export const PATCH_SECONDS = 3, DING_COMMAND_GAIN = 0.6; + +export function patchItems() { + const dir = join(playground, "lowered"), cat = JSON.parse(readText(join(dir, "catalog.json"))), items = []; + for (const e of cat) { + const source = readText(join(dir, e.dsp)); + if (e.name === "thruster") for (const rpm of THRUSTER_RPM) items.push({ id: `thruster@rpm${rpm}`, group: "patch", origin: e.origin, source, params: { rpm }, seconds: PATCH_SECONDS }); + else items.push({ id: e.name, group: "patch", origin: e.origin, source, params: {}, seconds: PATCH_SECONDS }); + } + return items; +} + +export function dingItems() { + const dir = join(here, "lowered-dings"), d = JSON.parse(readText(join(dir, "dings.json"))), items = []; + for (const ins of d.instruments) for (const ev of d.events) + items.push({ id: `ding/${ev.id}/${ins.id}`, group: "ding", origin: `DingCatalog ${ev.id} on ${ins.id}`, ding: { instrument: ins, event: ev, source: readText(join(dir, ins.dsp)), noteSeconds: d.noteSeconds, commandGain: DING_COMMAND_GAIN } }); + return items; +} +export const allItems = () => [...patchItems(), ...dingItems()]; diff --git a/tools/aqua-feel/feel.mjs b/tools/aqua-feel/feel.mjs new file mode 100644 index 0000000..f5d220c --- /dev/null +++ b/tools/aqua-feel/feel.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// aqua-feel: headless feel meter. Renders every shipped patch and Ding offline, measures the buffers, writes +// report.json plus a static gallery, and gates the numbers against thresholds.json. +// NOTHING HERE HEARS ANYTHING. These are measurements of rendered buffers; a number is evidence, never a verdict. +// Usage: node feel.mjs run [--out DIR] [--only SUBSTR] [--no-gate] render, measure, write report + gallery, gate +// node feel.mjs gate REPORT.json [THRESHOLDS.json] [--partial] re-gate a report (--partial: a subset; skips id-rule and waiver bookkeeping) +// node feel.mjs compare BEFORE.json AFTER.json [--only SUBSTR] before/after table +// node feel.mjs correlate TRIAL_EXPORT.json ... feel metrics vs listening-trial ratings +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { measure, SR } from "./metrics.js"; +import { loadRuntime, compile, renderOnce, renderDing, writeWav } from "./render.js"; +import { allItems } from "./catalog.js"; +import { gate, formatGate } from "./gate.js"; +import { gallery } from "./gallery.js"; +import { compareTable, correlate } from "./report.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const CAVEAT = "Nothing here was heard. These are measurements of offline-rendered buffers (faustwasm, mono channel 0); metrics are evidence for a listener to check, not verdicts."; +const args = process.argv.slice(2), cmd = args.shift(); +const flag = (n) => { const i = args.indexOf(n); if (i < 0) return undefined; return args.splice(i, i + 2 > args.length ? 1 : 2).pop(); }; +const bool = (n) => { const i = args.indexOf(n); if (i < 0) return false; args.splice(i, 1); return true; }; +const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); + +async function run() { + const out = resolve(flag("--out") ?? join(here, "out")), only = flag("--only"), noGate = bool("--no-gate"); + const items = allItems().filter((it) => !only || it.id.includes(only)); + if (!items.length) { console.error(`no item matches ${only}`); process.exit(2); } + const rt = await loadRuntime(); + console.log(`libfaust ${rt.version}; ${items.length} items; ${SR} Hz`); + const records = []; + for (const it of items) { + const t0 = Date.now(); + try { + const r = it.ding ? await renderDing(rt, it.ding.instrument, it.ding.source, it.ding.event, it.ding) : await renderOnce(await compile(rt, it.id, it.source), it.params, it.seconds); + const wav = `wav/${it.id.replace(/[^A-Za-z0-9._@-]+/g, "_")}.wav`; + writeWav(join(out, wav), r.samples); + records.push({ id: it.id, group: it.group, origin: it.origin, params: it.params ?? {}, seconds: r.samples.length / SR, channels: r.channels, wav, metrics: measure(r.samples) }); + console.log(`ok ${it.id} (${Date.now() - t0} ms)`); + } catch (e) { + records.push({ id: it.id, group: it.group, origin: it.origin, error: String(e.message ?? e) }); + console.log(`FAIL ${it.id}: ${e.message}`); + } + } + const thresholds = readJson(join(here, "thresholds.json")); + const report = { schema: "aqua-feel.report.v1", caveat: CAVEAT, libfaust: rt.version, sampleRate: SR, items: records }; + report.gate = gate(report, thresholds, { partial: Boolean(only) }); + mkdirSync(out, { recursive: true }); + writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 1)); + writeFileSync(join(out, "index.html"), gallery(report)); + console.log(`\nwrote ${join(out, "report.json")} and index.html\n${CAVEAT}\n\n${formatGate(report.gate)}`); + process.exit(!noGate && !report.gate.pass ? 1 : 0); +} + +const cmds = { + run, + gate() { + const partial = bool("--partial"), report = readJson(args[0]), g = gate(report, readJson(args[1] ?? join(here, "thresholds.json")), { partial }); + console.log(formatGate(g)); process.exit(g.pass ? 0 : 1); + }, + compare() { const only = flag("--only"); console.log(compareTable(readJson(args[0]), readJson(args[1]), only)); }, + async correlate() { console.log(await correlate(args.map(readJson), join(here, "../aqua-trial"))); }, +}; +if (!cmds[cmd]) { console.error(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 9).join("\n")); process.exit(2); } +await cmds[cmd](); diff --git a/tools/aqua-feel/gallery.js b/tools/aqua-feel/gallery.js new file mode 100644 index 0000000..b800729 --- /dev/null +++ b/tools/aqua-feel/gallery.js @@ -0,0 +1,51 @@ +// Static HTML gallery from a report: no scripts, no network. Each card has the WAV (native audio controls, for a human +// to press), an SVG level envelope, an SVG spectral-centroid curve, and the metric table with gate failures marked. +// The page says on its face that nothing was heard. +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); +const num = (v, d = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(d) : "–"); + +function plot(points, { w = 260, h = 56, ymin, ymax, log = false, color }) { + if (!points?.length) return ``; + const tmax = points[points.length - 1].t || 1, ys = points.map((p) => (log ? Math.log2(Math.max(p.v, 20)) : p.v)); + const lo = ymin ?? Math.min(...ys), hi = ymax ?? Math.max(...ys), span = hi - lo || 1; + const path = points.map((p, i) => `${i ? "L" : "M"}${((p.t / tmax) * (w - 2) + 1).toFixed(1)},${(h - 2 - ((ys[i] - lo) / span) * (h - 4)).toFixed(1)}`).join(""); + return ``; +} + +const ROWS = [ + ["onset ms", "onsetMs", 2], ["attack ms (10→90% in first 150 ms)", "attackMs", 2], ["attack window-limited", "attackWindowLimited", 0], ["attack slope dB/ms", "attackSlopeDbPerMs", 1], + ["sample peak", "samplePeak", 3], ["true peak dBTP", "truePeakDb", 1], ["over-1.0 samples", "overSamples", 0], ["flat-top runs", "flatTopRuns", 0], + ["loudness LUFS", "loudnessLufs", 1], ["crest dB", "crestDb", 1], ["centroid median Hz", "centroidMedianHz", 0], ["flux mean", "fluxMean", 3], + ["noise-to-tone dB (flat)", "noiseToToneDb", 1], ["noise-to-tone dB (A-weighted)", "noiseToToneADb", 1], ["level range dB", "envRangeDb", 1], ["centroid spread (oct)", "centroidSpreadOct", 2], + ["active ms", "activeMs", 0], ["low motion", "lowMotion", 0], +]; + +export function gallery(report) { + const bad = new Map(); + for (const [kind, list] of [["fail", report.gate?.failures ?? []], ["waived", report.gate?.waived ?? []], ["warn", report.gate?.warnings ?? []]]) + for (const e of list) { const k = `${e.id}|${e.metric}`; if (!bad.has(k) || kind === "fail") bad.set(k, kind); } + const cards = report.items.map((it) => { + const m = it.metrics; + if (!m) return `

${esc(it.id)}

did not render: ${esc(it.error ?? "unknown")}

`; + const rows = ROWS.map(([label, key, d]) => { + const v = m[key], cls = bad.get(`${it.id}|${key}`) ?? "", shown = typeof v === "boolean" ? String(v) : num(v, d); + return `${label}${shown}${cls ? ` ${cls}` : ""}`; + }).join(""); + const worst = ["fail", "waived", "warn"].find((k) => [...bad].some(([key, v]) => key.startsWith(it.id + "|") && v === k)) ?? ""; + return `

${esc(it.id)} ${it.group}

+ +

level envelope (dB, ${num(m.envelopeCurve?.at(-1)?.t, 1)} s)

${plot(m.envelopeCurve, { ymin: -90, ymax: 6, color: "#6fb1c7" })} +

spectral centroid (log Hz)

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