aqua-feel: headless feel meter + gate; fix PRC SYNTH1 clipping and thruster low-rpm hiss #8
29 changed files with 1553 additions and 20 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ NuGet.Local.Config
|
|||
_tmp_*.txt
|
||||
*.user
|
||||
*.suo
|
||||
tools/aqua-feel/out/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
46
docs/renders/aqua-feel-2026-11-05.md
Normal file
46
docs/renders/aqua-feel-2026-11-05.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AquaSynth.Core\AquaSynth.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\AquaSynth.Dings\AquaSynth.Dings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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 <outDir>/<name>.dsp, and
|
||||
// catalog.json for the browser (the one non-CultNet xeno at this boundary).
|
||||
// usage: AquaLower --dings <outDir>: lowers each DingCatalog instrument to <outDir>/<id>.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<object>();
|
||||
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 <outDir> <patchesRoot> <name>=<path> ..."); return 2; }
|
||||
var outDir = Path.GetFullPath(args[0]);
|
||||
var root = Path.GetFullPath(args[1]);
|
||||
|
|
|
|||
73
tools/aqua-feel/README.md
Normal file
73
tools/aqua-feel/README.md
Normal file
|
|
@ -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.
|
||||
27
tools/aqua-feel/catalog.js
Normal file
27
tools/aqua-feel/catalog.js
Normal file
|
|
@ -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()];
|
||||
66
tools/aqua-feel/feel.mjs
Normal file
66
tools/aqua-feel/feel.mjs
Normal file
|
|
@ -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]();
|
||||
51
tools/aqua-feel/gallery.js
Normal file
51
tools/aqua-feel/gallery.js
Normal file
|
|
@ -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 `<svg width="${w}" height="${h}"></svg>`;
|
||||
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 `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img"><rect width="${w}" height="${h}" fill="#10161c"/><path d="${path}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||||
}
|
||||
|
||||
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 `<section class="card fail"><h3>${esc(it.id)}</h3><p>did not render: ${esc(it.error ?? "unknown")}</p></section>`;
|
||||
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 `<tr class="${cls}"><th>${label}</th><td>${shown}${cls ? ` <b>${cls}</b>` : ""}</td></tr>`;
|
||||
}).join("");
|
||||
const worst = ["fail", "waived", "warn"].find((k) => [...bad].some(([key, v]) => key.startsWith(it.id + "|") && v === k)) ?? "";
|
||||
return `<section class="card ${worst}" id="${esc(it.id)}"><h3>${esc(it.id)} <small>${it.group}</small></h3>
|
||||
<audio controls preload="none" src="${esc(it.wav)}"></audio>
|
||||
<p class="cap">level envelope (dB, ${num(m.envelopeCurve?.at(-1)?.t, 1)} s)</p>${plot(m.envelopeCurve, { ymin: -90, ymax: 6, color: "#6fb1c7" })}
|
||||
<p class="cap">spectral centroid (log Hz)</p>${plot(m.centroidCurve, { log: true, ymin: Math.log2(20), ymax: Math.log2(16000), color: "#c7a46f" })}
|
||||
<table>${rows}</table><p class="cap">${esc(it.origin ?? "")}</p></section>`;
|
||||
});
|
||||
const g = report.gate;
|
||||
return `<!doctype html><meta charset="utf-8"><title>aqua-feel gallery</title>
|
||||
<style>body{font:14px/1.4 system-ui,sans-serif;background:#0b0f13;color:#d8dee4;margin:1.5rem}h1{font-size:1.3rem}h3{margin:.2rem 0;font-size:.95rem}small{color:#7d8a96;font-weight:400}
|
||||
.banner{border:1px solid #c7a46f;padding:.7rem 1rem;margin:1rem 0;color:#f0e2c4}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
|
||||
.card{background:#141b22;border:1px solid #232d37;padding:10px}.card.fail{border-color:#d4574a}.card.waived{border-color:#c7a46f}.card.warn{border-color:#8a7f4a}
|
||||
table{border-collapse:collapse;width:100%;margin-top:6px}th{text-align:left;font-weight:400;color:#8c99a5;padding:1px 6px 1px 0}td{padding:1px 0}tr.fail td{color:#ff8a7d}tr.waived td{color:#e0c07f}tr.warn td{color:#c9be85}
|
||||
.cap{margin:6px 0 2px;color:#7d8a96;font-size:.75rem}audio{width:100%;height:30px}b{font-size:.7rem;text-transform:uppercase}</style>
|
||||
<h1>aqua-feel gallery</h1>
|
||||
<div class="banner"><b>Nothing here was heard.</b> Every number is a measurement of an offline-rendered buffer (faustwasm, mono channel 0, ${report.sampleRate} Hz, libfaust ${esc(report.libfaust)}). Metrics are evidence for a listener to check, not verdicts; the WAVs are there so a person can press play. Gate: <b>${g?.pass ? "pass" : "fail"}</b> (${g?.failures.length ?? 0} failing, ${g?.waived.length ?? 0} waived, ${g?.warnings.length ?? 0} warnings).</div>
|
||||
<div class="grid">${cards.join("\n")}</div>`;
|
||||
}
|
||||
64
tools/aqua-feel/gate.js
Normal file
64
tools/aqua-feel/gate.js
Normal file
|
|
@ -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");
|
||||
}
|
||||
184
tools/aqua-feel/lowered-dings/dings.json
Normal file
184
tools/aqua-feel/lowered-dings/dings.json
Normal file
|
|
@ -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
|
||||
}
|
||||
56
tools/aqua-feel/lowered-dings/glass-chime.dsp
Normal file
56
tools/aqua-feel/lowered-dings/glass-chime.dsp
Normal file
|
|
@ -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);
|
||||
56
tools/aqua-feel/lowered-dings/muted-pluck.dsp
Normal file
56
tools/aqua-feel/lowered-dings/muted-pluck.dsp
Normal file
|
|
@ -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);
|
||||
56
tools/aqua-feel/lowered-dings/soft-triangle.dsp
Normal file
56
tools/aqua-feel/lowered-dings/soft-triangle.dsp
Normal file
|
|
@ -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);
|
||||
56
tools/aqua-feel/lowered-dings/warm-bell.dsp
Normal file
56
tools/aqua-feel/lowered-dings/warm-bell.dsp
Normal file
|
|
@ -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);
|
||||
265
tools/aqua-feel/metrics.js
Normal file
265
tools/aqua-feel/metrics.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
7
tools/aqua-feel/package.json
Normal file
7
tools/aqua-feel/package.json
Normal file
|
|
@ -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" }
|
||||
}
|
||||
70
tools/aqua-feel/render.js
Normal file
70
tools/aqua-feel/render.js
Normal file
|
|
@ -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");
|
||||
46
tools/aqua-feel/report.js
Normal file
46
tools/aqua-feel/report.js
Normal file
|
|
@ -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");
|
||||
}
|
||||
190
tools/aqua-feel/test.mjs
Normal file
190
tools/aqua-feel/test.mjs
Normal file
|
|
@ -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<b", metric: "overSamples" }], waived: [], warnings: [] }, items: [{ id: "a<b", group: "patch", wav: "wav/a.wav", metrics: { ...measure(sine(440, 0.5, 0.3)), overSamples: 9 } }, { id: "z", group: "ding", error: "no" }] };
|
||||
const html = gallery(rep);
|
||||
assert.match(html, /Nothing here was heard/); assert.ok(!html.includes("a<b")); assert.match(html, /class="fail"/); assert.match(html, /did not render/);
|
||||
});
|
||||
|
||||
console.log(`${n} passed`);
|
||||
154
tools/aqua-feel/thresholds.json
Normal file
154
tools/aqua-feel/thresholds.json
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
{
|
||||
"schema": "aqua-feel.thresholds.v1",
|
||||
"note": "Regression tripwires, not taste. A threshold here says 'a render this far out is worth a listener's ear before it ships'; none says a sound is good. Every number was chosen after looking at the shipped renders (see README), and every waiver names the reason and a follow-up.",
|
||||
"rules": [
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "nonFinite",
|
||||
"equals": 0,
|
||||
"why": "NaN or Inf in a render"
|
||||
},
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "silent",
|
||||
"equals": false,
|
||||
"why": "an item that renders nothing"
|
||||
},
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "overSamples",
|
||||
"equals": 0,
|
||||
"why": "samples beyond +/-1.0 clip at a converter"
|
||||
},
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "truePeakDb",
|
||||
"max": 0,
|
||||
"why": "inter-sample peak above 0 dBTP"
|
||||
},
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "truePeakDb",
|
||||
"max": -1,
|
||||
"level": "warn",
|
||||
"why": "under 1 dB of true-peak headroom"
|
||||
},
|
||||
{
|
||||
"group": "all",
|
||||
"metric": "lowMotion",
|
||||
"equals": false,
|
||||
"why": "a span of 1 s or more that goes nowhere in level or timbre (the song-swarm collapse shape)"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"metric": "loudnessLufs",
|
||||
"max": -6,
|
||||
"why": "integrated loudness within 6 LU of full scale"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"metric": "loudnessLufs",
|
||||
"min": -55,
|
||||
"why": "nearly inaudible next to the rest of the set"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"metric": "onsetMs",
|
||||
"max": 25,
|
||||
"why": "first sample within 40 dB of the peak (and above -70 dBFS) later than 25 ms after note-on"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "loudnessLufs",
|
||||
"max": -12,
|
||||
"min": -40,
|
||||
"why": "a notification louder than -12 or quieter than -40 LUFS"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "truePeakDb",
|
||||
"max": -6,
|
||||
"why": "a notification within 6 dB of full scale"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "onsetMs",
|
||||
"max": 5,
|
||||
"why": "a notification whose first sample within 40 dB of the peak comes later than 5 ms after note-on"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "attackMs",
|
||||
"max": 12,
|
||||
"why": "first-note attack slower than 12 ms (DingCatalog notes attack in 1-6 ms)"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "crestDb",
|
||||
"max": 15,
|
||||
"why": "spiky next to its own body"
|
||||
},
|
||||
{
|
||||
"group": "ding",
|
||||
"metric": "noiseToToneADb",
|
||||
"max": 10,
|
||||
"why": "DingCatalog forbids broadband noise; A-weighted noise should clearly not dominate tone (heuristic split: gliding partials read as noise, so 10 not 0)"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"id": "thruster@rpm300",
|
||||
"metric": "noiseToToneADb",
|
||||
"max": 16,
|
||||
"why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.7 dB; the cap sits 2-3 dB over the 13.1 dB measured after it, so the hiss cannot creep back unnoticed"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"id": "thruster@rpm600",
|
||||
"metric": "noiseToToneADb",
|
||||
"max": 8,
|
||||
"why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 23.0 dB; the cap sits 2-3 dB over the 5.0 dB measured after it, so the hiss cannot creep back unnoticed"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"id": "thruster@rpm1200",
|
||||
"metric": "noiseToToneADb",
|
||||
"max": 15,
|
||||
"why": "thruster low-rpm hiss: before the 2026-11-05 mix change this was 20.1 dB; the cap sits 2-3 dB over the 12.7 dB measured after it, so the hiss cannot creep back unnoticed"
|
||||
},
|
||||
{
|
||||
"group": "patch",
|
||||
"id": "dx7-public-domain-prc-synth1-calibrated",
|
||||
"metric": "samplePeak",
|
||||
"max": 0.9,
|
||||
"why": "PRC SYNTH1 peaked at 2.10 before the 2026-11-05 gain trim to 0.67; this holds it under 0.9"
|
||||
}
|
||||
],
|
||||
"waivers": [
|
||||
{
|
||||
"id": "sfxr-classic-classic-sfxr-abstract-golf",
|
||||
"metric": "truePeakDb",
|
||||
"reason": "renders 0.99 sample peak and +0.9 dBTP inter-sample",
|
||||
"followUp": "aqua-body:follow_up:sfxr-golf-hot",
|
||||
"max": 1.0
|
||||
},
|
||||
{
|
||||
"id": "sfxr-classic-classic-sfxr-abstract-golf",
|
||||
"metric": "loudnessLufs",
|
||||
"reason": "-5.3 LUFS integrated over the 3 s render",
|
||||
"followUp": "aqua-body:follow_up:sfxr-golf-hot",
|
||||
"max": -5.0
|
||||
},
|
||||
{
|
||||
"id": "sfxr-classic-classic-sfxr-abstract-golf",
|
||||
"metric": "lowMotion",
|
||||
"reason": "3 s of steady bright noise, 4 dB level range; may be longer than the render window",
|
||||
"followUp": "aqua-body:follow_up:sfxr-golf-hot"
|
||||
},
|
||||
{
|
||||
"id": "sfxr-explosion",
|
||||
"metric": "lowMotion",
|
||||
"reason": "3 s of steady noise, 1 dB level range, no decay inside the render window; an explosion that does not decay is worth a listener's check",
|
||||
"followUp": "aqua-body:follow_up:sfxr-explosion-static"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ move the sliders the DSP itself declares, press Play.
|
|||
**Status: unheard, and never run in a browser.** `node test.mjs` is the only check that has run: it
|
||||
compiles each patch to wasm in Node and renders it offline (non-silent, finite). No patch has been listened to;
|
||||
the page itself, its AudioWorklet path, sliders and layout have not been opened in any browser. One patch
|
||||
is known to clip: `dx7-public-domain-prc-synth1-calibrated` peaks at 2.10. Browser verification is open as
|
||||
clipped until 2026-11-05: `dx7-public-domain-prc-synth1-calibrated` peaked at 2.10 (patch gain trimmed 1.4 → 0.45, now 0.67; `tools/aqua-feel` measures it). Browser verification is open as
|
||||
`aqua-body:follow_up:playground-browser-verify`.
|
||||
|
||||
**What is live:** 42 patches (`catalog.json`): the hand-written Faust `thruster` (with its `rpm`
|
||||
|
|
|
|||
|
|
@ -49,4 +49,4 @@ opgraph_0_op_1 = sin(2.0 * ma.PI * (os.phasor(1.0, opgraph_0_freq * max(0.0, 0.5
|
|||
opgraph_0_op_3 = sin(2.0 * ma.PI * (os.phasor(1.0, opgraph_0_freq * max(0.0, 0.499655)) + (opgraph_0_op_4 * 6 + opgraph_0_op_5 * 6) / ma.PI)) * rl4_env_at(age, 0.001451, 0.984375, 0, 0.296962, 1, 1, 0.424308, 1, 1, 0.549977, 0, 1, 0.85) * 3;
|
||||
opgraph_0 = (opgraph_0_op_1 + opgraph_0_op_3) * 0.25;
|
||||
|
||||
process = (opgraph_0) * 1.4;
|
||||
process = (opgraph_0) * 0.45;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ user agent, sample rate); sending it is the listener's act.
|
|||
|
||||
## Measured finding before any listening
|
||||
|
||||
Peak and rms hardly move across the sweep (48 kHz: rms 0.0734 / 0.0712 / 0.0706 / 0.0705; peak 0.189 / 0.161 /
|
||||
0.148 / 0.140). At rpm 600 the rumble carries nearly all the energy, so these four variants differ mostly in
|
||||
spectral texture, not level. That makes a peak/rms "prediction" of ratings less likely a priori, and the trial
|
||||
a real test of whether the texture difference is audible.
|
||||
Peak and rms hardly move across the sweep (48 kHz, current mix: rms 0.0725 / 0.0724 / 0.0723 / 0.0723; peak 0.160 / 0.151 /
|
||||
0.148 / 0.147). **The sweep got narrower on 2026-11-05:** `patches/thruster/thruster.dsp` (and this file, which must equal it
|
||||
at noise 100) was changed to cut the idle-level rasp and rattle floors and open the rumble low-pass at low load, after
|
||||
`tools/aqua-feel` measured the A-weighted noise-to-tone ratio at rpm 300/600 as +21/+23 dB. At rpm 600 the noise layers are now
|
||||
already small, so noise 100 vs 0 differs far less than it did in the original mix (rms 0.0734 / 0.0712 / 0.0706 / 0.0705, peak
|
||||
0.189 / 0.161 / 0.148 / 0.140). A trial of Deru's "dead radio" claim against the old mix needs that mix restored as a variant;
|
||||
open as `aqua-body:follow_up:trial-add-v5-variant`. Nothing here was heard, before or after the change.
|
||||
|
|
|
|||
|
|
@ -16,17 +16,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) + noise_amt * (rasp * (0.3 + 1.5 * load) + rattle);
|
||||
mix = rumble * (0.5 + 0.5 * load) + noise_amt * (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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue