diff --git a/.gitignore b/.gitignore index 92db1ab..0a9a6b8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ NuGet.Local.Config _tmp_*.txt *.user *.suo +tools/aqua-feel/out/ diff --git a/docs/faust-toolchain-boundary.md b/docs/faust-toolchain-boundary.md index 2a717a1..ee46cae 100644 --- a/docs/faust-toolchain-boundary.md +++ b/docs/faust-toolchain-boundary.md @@ -91,6 +91,27 @@ search; `FaustHome` then supplies `share/faust` if the prefix is not the file's grandparent. The loader prepends `lib` and `bin` to `PATH` using the platform path separator. +## Linux Native Toolchain + +Built and run on Debian 13 (2026-11-03). Faust `aa30fd7` (2.90.20), LLVM 19 +(`LLVM_CONFIG=/usr/lib/llvm-19/bin/llvm-config`, `INCLUDE_DYNAMIC=ON`, +`backends/most.cmake`), installed to a prefix; `make -j4`. The first link +failed with `cannot find -lPolly -lPollyISL -lzstd`; the install prefix that +resulted has `lib/libfaust.so` but `share/faust` holds only the architecture +files, not `stdfaust.lib` and the other `.lib` files (they live in the separate +faustlibraries repo). Compiling any patch that imports `stdfaust.lib` then +fails with `unable to open file stdfaust.lib`. Point `AQUASYNTH_FAUST_HOME` at a +home whose `lib/` has `libfaust.so` and whose `share/faust` has both. + +`tools/FaustRender` renders one `.dsp` with an `rpm` control to WAVs plus +`manifest.json` (hashes, peak, rms, non-finite flag). It measures; it does not +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. 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 - AquaSynth remains the authority for what a patch means. diff --git a/docs/faust-unity-player-scoping.md b/docs/faust-unity-player-scoping.md new file mode 100644 index 0000000..9313cb3 --- /dev/null +++ b/docs/faust-unity-player-scoping.md @@ -0,0 +1,316 @@ +# Faust DSP in a built Unity player (Windows + Linux): scoping + +Scoping only. No code changed. Follow-up `aqua-body:follow_up:chat-s3500-3500`, 2026-11-06. + +Question: how does Faust DSP (the thruster, `patches/thruster/thruster.dsp`, v5) reach a +built Unity player on Windows and Linux? (a) ahead-of-time (AOT) Faust to C++ per patch +inside a native plugin, or (b) runtime `libfaust`/LLVM inside the player. + +**Limitation:** AOT Faust-to-C++ fixes the patch set at build time. Player-authored or modded patches cannot run in a built player; live-edited patches run only in the editor (the developer-time lane of path (b)). + +Marks: **[V]** verified here by reading source or running a tool on 2026-11-06. +**[U]** unverified: from memory, or needs a Unity editor/player or a Windows host this run +did not have. Nothing here has been run inside Unity. + +## Recommendation + +Take (a) for shipped patches such as the thruster. Keep (b) as a developer-time lane only, if +at all. Confidence: about 80% on the choice, lower on the details marked [U]. + +The cost of (a): the patch set is fixed at build time. A built player cannot run +player-authored or modded patches; live-edited patches run only in the editor, through (b). + +- (a) fits the thruster: a fixed graph with ordinary controls, which + `docs/faust-toolchain-boundary.md` ("Parameter changes are not recompiles"; "Shipping + builds should prefer Faust-generated native targets") already says is the shipping shape. +- (b) buys live recompilation of `.aqua` text in the player. Nothing asks for that yet. It + costs a ~130 MB LLVM dependency [V: `libLLVM.so.19.1` is 129,673,080 bytes on this host], + the Faust library tree at runtime, and a JIT in the player. +- Faust already ships a Unity target that is path (a) with the bridge written + (`architecture/unity/`, `faust2unity`, section "What Faust ships"). Start from it and do + not hand-roll a plugin. + +## What Faust ships for Unity [V] + +Checked in the 2.90.20 source tree this host built (`faust-llvm-build-20261031/src/architecture/unity/`). + +| File | Role | +|---|---| +| `unity.cpp` (253 lines) | Unity **native audio SDK** effect plugin: `UnityGetAudioEffectDefinitions`, `ProcessCallback`, `SetFloatParameterCallback`. | +| `unityplugin.cpp` (307 lines) | Plain C exports (`Faust_setParameterValue` etc.) for P/Invoke. | +| `FaustPlugin_template.cs` | `MonoBehaviour` on an `AudioSource`; `OnAudioFilterRead` calls `ctx.process(...)`. | +| `FaustUtilities_template.cs`, `FaustPolyUtilities_template.cs` | `DllImport` glue, inspector UI, polyphonic variant. | + +So Faust provides both bridge shapes the question names. The `FaustPlugin_template.cs` +path is the **OnAudioFilterRead bridge**: Unity calls `OnAudioFilterRead(float[] buffer, int +numchannels)` on its audio thread and C# P/Invokes the native `process`. `unity.cpp` is the +**native audio SDK** shape: Unity's mixer calls `ProcessCallback` directly with no managed +frame in the audio path. + +`faust2unity` builds the native libraries and C# scripts and packs a `.unitypackage`; +platform scripts are `faust2winunity` (MinGW cross-compile: `-w32`/`-w64`) and +`faust2linuxunity` (`g++`, `-fPIC -shared`, `.so`) [V: scripts and README read; not run]. + +## Path (a): AOT Faust to C++ in a native plugin + +### Per-platform outputs + +| Platform | Output | Toolchain | Status | +|---|---|---|---| +| Windows x64 | `FaustPlugin_.dll` | `faust2winunity -64` uses MinGW (`x86_64-w64-mingw32-gcc`) [V: script]. An MSVC build of the generated C++ is plausible but not in Faust's scripts. | [U] runs in a Unity Windows player | +| Linux x86_64 | `libFaustPlugin_.so` | `g++ -fPIC -shared` (`faust2linuxunity`) [V: script] | [U] runs in a Unity Linux player | +| Inputs | `thruster.dsp` + `stdfaust.lib` tree | `faust` CLI with `-I` at faustlibraries | [V] | + +Generating the thruster as C++ works [V]: `faust -I faustlibraries-20261031 -a minimal.cpp +-single -ftz 2 -vec -lv 1 thruster.dsp` gave 1,105 lines (40,045 bytes) with Faust 2.90.20. +Those are the same flags `AquaSynthNativeCompiler.CompileSource` passes to the JIT +(`src/AquaSynth.Faust/NativeFaustRuntime.cs:530`). The AOT lane should pick its flags +deliberately, not inherit them: `-vec` is a choice that the measurement plan below should +test against scalar output. + +Windows and Linux builds need two different hosts or a cross-compiler. Starfire (Windows) +has Faust and a compiler only if someone installs them there [U]. + +### What is reusable from AquaSynth + +| Part | Reusable for (a)? | Evidence | +|---|---|---| +| `patches/thruster/thruster.dsp` | Yes, as is. It is hand-written Faust, "not a `.aqua` script" (file header). | [V] | +| `FaustExport` / `FaustEmitter` (`src/AquaSynth.Core/FaustExport.cs`, 2,558 lines) | Only for `.aqua` patches. The thruster does not go through it. For an `.aqua` patch it yields the `.dsp` source to feed `faust -lang cpp`; `DspSourceDirectory` already writes it (`NativeFaustRuntime.cs:628`). | [V] | +| `FaustCompiler.CompileAsync` (`src/AquaSynth.Faust/FaustCompiler.cs:56`) | Yes. Runs the `faust` CLI with `-lang Cpp`; `FaustTargetLanguage.Cpp` exists. This is the AOT generator step. | [V] | +| `NativeFaustRuntime.cs` (1,132 lines: `AquaSynthPatchCompiler`, `AquaSynthStreamingPatch`, `FaustNativeToolchain`) | **Not in the shipped player.** It is the libfaust loader and the managed host. Under (a) it stays on the build/authoring side: it renders reference audio and the manifest that the plugin's output is checked against. | [V] | +| `tools/FaustRender` + `docs/renders/thruster-v5-manifest.json` | Yes, as the golden reference: hashes, peak, RMS, `nonFinite` per rpm case, 48 kHz / 512-frame blocks. The native plugin should be diffed against this. The manifest hashes predate the 2026-11-05 edit to `thruster.dsp` (`docs/faust-toolchain-boundary.md`, "Linux Native Toolchain"), so re-render first. | [V] | +| `AquaSynthNativeManifest` (id, compile key, sample rate, I/O counts, Faust version) | Yes as the artifact provenance record. It has **no parameter table** (no min, max, default). That is a gap for crossing params (below). | [V] | + +Missing for (a), not in AquaSynth today: a build step that runs `faust2unity`-style +generation per patch and records the result as artifacts. That should be a script in +`tools/`, not a new library. Per the boundary doc, AquaSynth owns "generated +source/native artifact layout" and "parameter and bus manifests", and Aquarium/Unity owns +"artifact loading policy for its platform". + +### Hot-path hazards in the managed bridge (if OnAudioFilterRead + managed host) + +If the player used `AquaSynthStreamingPatch` directly (managed host, native `libfaust` +instance), these are in the audio callback [V: `NativeFaustRuntime.cs`]: + +- `ProcessBlock(float[][] ...)` (line 251) allocates `IntPtr[]` arrays and pins six + `GCHandle`s per call. Use the pointer overload (line 235) with preallocated pinned buffers + (what `tools/FaustRender/Program.cs:37-40` already does). +- `SetControls` does a string dictionary lookup per control per block and a `Marshal.WriteInt32` + (lines 841-869). It also throws `ArgumentException` for an unknown path (line 865): an + exception in an audio callback. Resolve zone pointers once, off-thread. +- The `FaustNativeToolchain` delegates are `Marshal.GetDelegateForFunctionPointer` thunks + (line 886), not function pointers; fine for control rate, avoidable per block. + +Under the AOT plugin, none of this exists: the cost moves to the plugin's `setParamValue` +and `compute`. + +### Native audio SDK vs OnAudioFilterRead + +| | Native audio SDK (`unity.cpp`) | `OnAudioFilterRead` bridge (`FaustPlugin_template.cs`) | +|---|---|---| +| Where it runs | Mixer effect, native audio thread, no managed code | Managed callback on the audio thread, then P/Invoke | +| Source/generator | Effect slot on a mixer group; `ProcessCallback` returns `ERR_UNSUPPORTED` if `inchannels != outchannels` (`unity.cpp:179`) [V] | Component on a GameObject with `AudioSource` | +| Params | `SetFloatParameterCallback`, set from mixer exposed params or `AudioMixer.SetFloat` [U] | `setParameter(int, float)` per patch component | +| Risk | Mixer setup per patch; 15-char parameter names (below) | GC pauses or managed allocation can overrun the callback if the managed side allocates [U] | +| Fit for thruster | Better on determinism; whether a generator-only (0-input) effect behaves well in a mixer slot is [U] | Matches Faust's template; many instances are easy (one component each) | + +The thruster has 0 inputs and 1 output. In both shapes a 1-channel Faust output goes to +the first interleaved channel only and zero-fills the rest (`interleavedWrite`, +`architecture/faust/dsp/dsp-tools.h:216-223`) [V]. A stereo player would hear left only +unless the patch is made to output two channels or the plugin is edited. + +## Path (b): runtime `libfaust`/LLVM in the player + +`AquaSynthPatchCompiler` already is this, host-side: `createCDSPFactoryFromString` via +`NativeLibrary.Load` (`NativeFaustRuntime.cs:793`), the C API delegates (727-737), instances +created and computed through `computeCDSPInstance`. + +Per-platform outputs: + +| Platform | Ship | Evidence | +|---|---|---| +| Linux x64 | `libfaust.so.2.90.20` (8.4 MB) + system `libLLVM.so.19.1` (129.7 MB) + `libz3.so.4`, `libffi.so.8`, `libedit.so.2`, `libzstd`, `libz` (all dynamic deps) + `share/faust/*.lib` | [V: `ldd`, `ls`, doc] | +| Windows x64 | `libfaust.dll` plus `share/faust` | [U] no Windows libfaust built or run by this run. `AquaSynthFaustToolchain.LibraryFileNames` looks for `libfaust.dll`/`faust.dll` (`NativeFaustRuntime.cs:654-657`); the doc says `C:\Program Files\Faust` is the default home. | + +Costs specific to AquaSynth's own history, from `docs/faust-toolchain-boundary.md`: +the Linux link first failed (`cannot find -lPolly -lPollyISL -lzstd`), and the prefix +`share/faust` lacked `stdfaust.lib` until the faustlibraries tree was added. The library +tree has to travel with the player (`AquaSynthFaustToolchain.Find` probes +`/Tools/Faust`, line 666). + +Beyond size: + +- A JIT needs executable memory. Desktop Windows/Linux allow it; iOS and consoles do not + [U]. A player that needs those later cannot have (b) as its only path. +- The compile step is multi-hundred-millisecond to seconds and must run on a worker thread + with a finished-DSP handoff (boundary doc, "Runtime Rule"). `AquaSynthNativeManifest.CompileMilliseconds` + records it [V: field exists; no number measured here]. +- Output from a JIT with `-vec` at optLevel `-1` is whatever LLVM emits on that machine. + It is not bit-identical across hosts [U], so the reproducibility invariant in the + boundary doc ("artifacts are reproducible from script, version, target, options") weakens. + +## Licensing if shipped + +Read from files in the tree this run built [V] unless marked. + +| Component | Licence | Ship implication | +|---|---|---| +| Faust compiler / `libfaust` 2.90.20 | LGPL-2.1-or-later (header of `COPYING.txt`: "GNU Lesser General Public License ... version 2.1") | Dynamic loading of an unmodified `libfaust.so/.dll` satisfies LGPL if the user can replace it. Ship licence text and source offer. [U] legal review. | +| Faust architecture files (`unity.cpp`) | LGPL-3.0-or-later **with exception**: you may create a larger work with the architecture section and distribute it under terms of your choice, **so long as the architecture section is not modified** | Use `unity.cpp` / `unityplugin.cpp` unmodified and the rest of the plugin has no copyleft. Editing them (e.g. to add param clamping) drops the exception for that file. Prefer clamping elsewhere (below). | +| Generated C++ from `faust` (the DSP section of the output) | Not governed by the compiler licence per Faust's licence note [U: from Faust FAQ, not checked here] | Verify before shipping. | +| `faustlibraries` (`stdfaust.lib`, used by the thruster via `os`, `fi`, `no`, `si`, `ba`, `ma`) | Per-function: `oscillators.lib`, `noises.lib`, `signals.lib`, `basics.lib`, `maths.lib` headers say LGPL (GRAME); `filters.lib` says "Each function in this library has its own license" (e.g. Julius O. Smith III) | Compiled into the generated code. Need a per-function licence inventory of the functions the thruster calls before shipping. Not done. | +| LLVM (path (b) only) | Apache-2.0 with LLVM exceptions [U: from memory, not in tree] | Permissive; ship notice. Also notices for z3 (MIT), libffi (MIT), libedit (BSD) [U]. | + +Net: (a) puts only unmodified-architecture-file and faustlibraries notices in the player. (b) +adds LGPL relinking obligations for `libfaust` and a third-party LLVM stack. + +## Crossing bounded parameters (rpm now; load/velocity/quality later) + +Today's thruster has one control: +`rpm = hslider("rpm", 1200, 300, 6000, 1) : si.smooth(ba.tau2pole(0.2))` +(`thruster.dsp` line 6). `load` is derived from it (`load = (rpm - 300) / 5700`). The +generated C++ declares it with `addHorizontalSlider("rpm", &fHslider0, 1.2e+03f, 3e+02f, 6e+03f, 1.0f)` +[V]. Later controls (load, velocity, quality) must each be declared the same way, with a +default, min and max. + +### What crosses and how + +- **AOT / `OnAudioFilterRead`**: C# calls `Faust_setParameterValue(ctx, index, float)` + (`FaustUtilities_template.cs:307`), which does `*fZone = v` with no clamp + (`APIUI.h` `setParamValue(int, FAUSTFLOAT)` = `*fItems[p].fZone = v`) [V]. Parameters are + addressed by integer index in declaration order. The `.cs` file prints min/max/init + (`getParameterMin/Max`). +- **AOT / native audio SDK**: `ComputeUnityParameters` fills Unity's + `UnityAudioParameterDefinition` with `min`, `max`, `defaultval` from the Faust UI + (`unity.cpp:116-118`) [V]. Unity then knows the range and the default; the inspector and + mixer use it. **Names are cut to 15 characters** (`strncpy(pdef[i].name, ..., 15)`, + `unity.cpp:112`) [V]. `rpm` fits; Aqua-style paths such as `/curves//depth` do not. + The AquaSynth path lengths need a unique-prefix rule or the control must be + addressed by index. +- **Runtime `libfaust` (b)**: `AquaSynthStreamingPatch.SetControls` writes the float's + bits to the zone pointer with `Marshal.WriteInt32` (`NativeFaustRuntime.cs:868`). No + range or default crosses; `FaustUiGlue.AddControl` discards `init, min, max, step` + (line 1028) [V]. + +Float writes are the only thing crossing. A single aligned 32-bit store is atomic on x86-64, +so no lock is needed for one scalar control [U: assumes the compiler does not tear; the +generated code reads each zone once per block, `float fSlow0 = static_cast(fHslider0)`, +`compute` line 430 in the generated file] [V for the read]. A control that moves within a +block takes effect at the next block, smoothed by `si.smooth` (rpm uses a 0.2 s time +constant). + +### Needed for several bounded params + +1. A parameter table per patch alongside the existing `AquaSynthNativeManifest`: + `{path, index, default, min, max}`. (b) can get it from `FaustUiGlue` if + `AddControl` stops discarding `init/min/max`; (a) can get it from the same + `faust -json` output or from the C++ UI callbacks at build time. +2. One clamp site (below), driven from that table, owned by AquaSynth, not by + Unity code. +3. A normalized-to-range mapping owned by AquaSynth for load/velocity/quality when the game + supplies 0..1, if that is the game's contract. `ControlSurfaceCatalog` already holds + normalized surfaces for `.aqua` (`hslider(path, DefaultNormalized, 0, 1, 0.001)`, + `FaustExport.cs:157`) [V]; the thruster has none. + +## Are param inputs clamped today? + +No, for the thruster path. Layer by layer [V]: + +| Layer | Clamp? | +|---|---| +| `thruster.dsp` | No. Slider min/max are UI metadata. The generated `compute` reads the zone raw (`fSlow0 = fHslider0`). An rpm of 20000 or -5 reaches `si.smooth` and every formula (`shaft = 28 + rpm*0.025`, `load = (rpm-300)/5700`, band centres, drive). | +| `FaustNativeToolchain.WriteControl` (`NativeFaustRuntime.cs:860-869`) | No. Only unknown-path rejection, and `NaN`/infinity written as-is. | +| `AquaSynthStreamingPatch.SetControls` / `ProcessBlock` | No. | +| `tools/FaustRender/Program.cs:51` | No; it only sweeps within its CLI arguments. | +| `NativeFaustRuntimeTests.cs` | No test for out-of-range, NaN, or infinity control values found (grep for clamp/throws/range: no hit). | +| `ControlSplineTimeline` (`src/AquaSynth.Core/ControlSplineTimeline.cs:167-231`) | Yes, but only normalized `[0,1]` for `.aqua` control splines. It is not in the thruster path. | +| Faust `APIUI::setParamValue` (Unity bridge) | No (`*fZone = v`). | +| Unity side | The mixer/inspector clamp to the declared `min/max` in the native SDK path [U]. A script calling `setParameter` or `SetFloat` directly is not clamped by Faust's glue [V]. | + +The final `tanh` and the 0.425 trim (`thruster.dsp` last line) bound the output. Peak at +rpm 4800 is 0.36 (`docs/renders/thruster-v5-manifest.json`) [V]. Whether out-of-range rpm stays +finite and bounded is **not measured**: the manifest covers 300..6000 only. Hence +`nonFinite` coverage in the plan below includes out-of-range and non-finite inputs. + +Recommended clamp site: in the AquaSynth-owned manifest/host layer (for (a), a small +wrapper in the managed script that Faust's unmodified architecture files sit under; for (b), +`WriteControl`), clamping to the declared min/max and rejecting NaN. Do not edit +`unity.cpp`/`APIUI.h` (loses the architecture exception). The DSP itself can also carry the +bound: `hslider(...) : max(300) : min(6000)` in the `.dsp` is the one place both +lanes would share. That is a patch edit and a design choice for the thruster's owner, not +decided here. + +## Callback-deadline measurement plan (rerunnable) + +Goal: show, per path and platform, that `compute` fits the audio callback with margin, +including at the worst rpm sweep, under CPU load. Nothing here has been run. + +**Deadline.** `D = frames / sampleRate`. Unity's block comes from `AudioSettings.GetDSPBufferSize` +(`FaustPlugin_template.cs` `getBufferSize`). Test 256, 512, 1024 frames at 44.1 k and +48 k (the render manifest uses 48 k / 512, i.e. D = 10.67 ms). Proposed pass line: +p99.9 under 25% of D, max under 50% of D (Aqua's ruling, 2026-11-07). The spike's +shared-host outliers (max 10.8-17.1 ms, 4-5 blocks over deadline) are **unexplained**: the spike +(`spikes/faust-aot-thruster/`) shows they are not per-block DSP cost (an empty timed busy-wait stalls +the same way), but not what causes them. Until a quiet or realtime host explains or removes them, the +max line has no verdict from this host. + +**What the spike measures, and what it does not.** It times a bare C loop calling the compiled +`process` back to back on Linux. That shows the patch is cheap (median 0.05 ms against 10.67 ms). It +does not show that a Unity player meets its deadline: the player's audio thread, the managed-to-native +transition, IL2CPP, mixer and other DSP sharing the callback, and Windows scheduling are all absent. + +**What to time.** + +1. `compute(count, ...)` alone, pure C++ (AOT): build `thruster.cpp` with Faust's + `architecture/bench.cpp` or a 30-line driver; `std::chrono::steady_clock` around each + `compute`; 200,000 blocks; discard the first 1,000. [V: `bench.cpp` exists in + `architecture/`; not run.] +2. The whole plugin callback: `ProcessCallback` or `OnAudioFilterRead` including + `interleavedWrite` and the parameter write; timing taken inside the plugin with a + preallocated ring of timestamps, no logging in the callback; dumped on unload. +3. Managed host (b): `FaustRender`-style loop over `AquaSynthStreamingPatch.ProcessBlock` + with the pointer overload, `Stopwatch.GetTimestamp` around each call, GC allocation count + (`GC.GetAllocatedBytesForCurrentThread`) asserted zero across the loop. + +**Inputs.** The rpm cases from the manifest (600, 4800, sweeps) plus 300 and 6000 steady, a +full 300->6000->300 ramp changing rpm every block, and the out-of-range set +(`-1e9`, `0`, `1e6`, `NaN`, `+Inf`) with finite-and-bounded output asserted. Add the +rpm-and-load product of any new controls. Include denormal check (`-ftz 2` build vs without). + +**Conditions.** Idle host and with the Starfire load other Minds create (record +`nvidia-smi` and `query user` before and after; she wants it saturated, but a GPU hang resets +her display, so the run is CPU-only); one thread pinned; power plan noted; Debug and Release +for managed pieces. Linux on Yggdrasil or this host, Windows on Starfire over `ssh starfire` +(not run, and it is Meta's shared machine, so one job at a time). + +**Variants to compare**, same inputs: AOT `-vec` vs scalar; AOT `-O2` vs `-O3 -march=x86-64-v2` +(avoid `-march=native`, which the player's CPU may not match); JIT optLevel `-1` vs 3 [U: flag +meaning]. First-callback and instantiation time recorded separately from steady state. + +**Output.** One JSON per run: host, OS, CPU model, Faust version, compile flags, block size, +sample rate, n, min/p50/p99/p99.9/max in microseconds, % of D, overrun count (`t > D`), peak, +rms, `nonFinite`, `dspSha256`. Same shape and location as `docs/renders/thruster-v5-manifest.json` +so the existing hashes can be compared. A script for it belongs in `tools/` next to +`FaustRender`. + +**In the real player.** Unity does not expose callback timing directly. Time inside +the plugin as above, and read `AudioSettings` underrun counters / Profiler `Audio` markers +[U: exact counter names] over a 10-minute run in a development build on each OS. + +## Unverified list, in one place + +- That a built player can run anything but the patches compiled into it: player-authored or + modded patches are out of reach by construction under (a); live edit needs the editor and (b). + +- That the generated Windows `.dll` and Linux `.so` load and play in a Unity Windows/Linux + player (no Unity available in this run). +- `faust2winunity` end-to-end on any host (MinGW not checked). +- Behaviour of a 0-input generator in a mixer effect slot; mixer parameter exposure. +- Generated-code licence position, LLVM/z3/libffi/libedit licences, and a per-function + inventory of `faustlibraries` licences for the thruster's calls. +- A Windows `libfaust.dll` build and its dependencies. +- Compile latency in milliseconds for the thruster (the field exists, no number taken). +- Out-of-range / non-finite rpm behaviour of the thruster output. +- Atomicity of the float zone write across a block boundary in a real plugin. +- Unity's DSP buffer size defaults and underrun counter names. diff --git a/docs/renders/aqua-feel-2026-11-05.md b/docs/renders/aqua-feel-2026-11-05.md new file mode 100644 index 0000000..020ba01 --- /dev/null +++ b/docs/renders/aqua-feel-2026-11-05.md @@ -0,0 +1,46 @@ +# aqua-feel first run, 2026-11-05 + +Nothing here was heard. These are metrics from offline renders (faustwasm, mono, 48 kHz, 3 s per patch); they are evidence, +not verdicts. `tools/aqua-feel/README.md` says what each number is and where it breaks. + +Before: `patches/` at `origin/main` d8c625c under the same rules: the gate fails on 3 items (PRC SYNTH1: overs, true peak, +loudness; sfxr golf: true peak, loudness, low motion; sfxr explosion: low motion). After: PRC SYNTH1 gain trimmed 1.4 to +0.45 (a uniform gain: crest and centroid are unchanged), and `thruster.dsp` mix edited (below). Gate after: pass, 4 waivers +(golf x3, explosion x1), 1 warning (golf, +0.9 dBTP). + +## prc-synth1-calibrated + + samplePeak 2.10 -> 0.67 -1.42 + truePeakDb 6.44 -> -3.41 -9.86 + overSamples 18633 -> 0 -18633.00 + loudnessLufs -0.67 -> -10.53 -9.86 + crestDb 6.67 -> 6.67 +0.00 + centroidMedianHz 794.44 -> 794.44 +0.00 + noiseToToneDb -17.18 -> -17.18 +0.00 + noiseToToneADb -19.14 -> -19.14 +0.00 + onsetMs 0.13 -> 0.13 +0.00 + attackMs 0.33 -> 4.50 (the metric changed to a 150 ms window with a held envelope during review; the patch did not) + lowMotion false -> false + +The shipped file's `patch gain=1.4` is the constant the parity probe in `Dx7ReferenceParityTests` writes; the probe asserts +RMS ratio against Dexed, so the test is untouched and still builds its own script at 1.4. Only the shipped library patch +carries the trim. Whether 0.45 is the right level for a listener is unchecked. + +## thruster + +Edits to `patches/thruster/thruster.dsp` (mirrored in `tools/aqua-trial/thruster-trial.dsp`): rasp gain `0.3 + 1.5*load` to +`0.05 + 1.75*load`; rattle floor `0.1 + 0.9*load^2` to `0.015 + 0.985*load^2`; rumble low-pass `60 + 2.5*shaft` to +`60 + 2.5*shaft + 140*(1-load)`. At load 1 (rpm 6000) every term equals its old value (rasp 1.8, rattle 1, low-pass 60 + 2.5*shaft). +Decomposition at rpm 300 before the edit (A-weighted noise-to-tone, dB): full mix +21, rasp removed +20.5, rattle removed ++13.0, both removed +12.5. The rattle modes, not the rasp, were most of the audible-band noise at low rpm; with both gone a +floor of +12.5 dB remains (the pitch-wandering saw smears its harmonics, which the line detector counts as noise). + + rpm A-weighted noise-to-tone dB (before -> after) loudness LUFS centroid Hz + 300 20.7 -> 13.1 -29.5 -> -30.2 2388 -> 752 + 600 23.0 -> 5.0 -28.0 -> -28.7 2531 -> 1228 + 1200 20.1 -> 12.7 -25.4 -> -26.1 2913 -> 2114 + 4800 10.3 -> 9.7 -17.3 -> -17.4 4987 -> 4914 + +The centroid falls a lot at low rpm: the sound is darker as well as less noisy. Whether that is "more tone" to a listener or +"muffled" is exactly what this meter cannot say. The listening trial (`tools/aqua-trial`) now sweeps a narrower noise range at +rpm 600 (see its README). diff --git a/docs/renders/aqua-feel-native-diff-2026-11-10.json b/docs/renders/aqua-feel-native-diff-2026-11-10.json new file mode 100644 index 0000000..35b0987 --- /dev/null +++ b/docs/renders/aqua-feel-native-diff-2026-11-10.json @@ -0,0 +1,278 @@ +{ + "libfaustWasm": "2.90.19", + "sampleRate": 48000, + "items": "core", + "smoother": "library", + "rows": [ + { + "id": "thruster@rpm300", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.026072267442941666, + "maxErrAt": 142707, + "rmsErr": 0.002385837227503057, + "relRmsErrDb": -28.770505442344437, + "wasm": { + "samplePeak": 0.14264661073684692, + "truePeakDb": -16.90120405295226, + "loudnessLufs": -30.226833288468896, + "centroidMedianHz": 751.9185607592701, + "overSamples": 0, + "rmsDb": -23.676437109698263, + "crestDb": 6.761666256511239, + "noiseToToneDb": -6.937988717513285, + "noiseToToneADb": 13.092512355240387, + "lowMotion": false + }, + "native": { + "samplePeak": 0.14242461323738098, + "truePeakDb": -16.915261179402926, + "loudnessLufs": -30.216031113774466, + "centroidMedianHz": 751.528437557621, + "overSamples": 0, + "rmsDb": -23.670746205832152, + "crestDb": 6.74244718138185, + "noiseToToneDb": -6.888378522251635, + "noiseToToneADb": 13.056342933565631, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "thruster@rpm600", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.049423644319176674, + "maxErrAt": 142945, + "rmsErr": 0.004812282166272209, + "relRmsErrDb": -23.51217894411906, + "wasm": { + "samplePeak": 0.15958738327026367, + "truePeakDb": -15.926835268189564, + "loudnessLufs": -28.69474712970973, + "centroidMedianHz": 1227.9568144573402, + "overSamples": 0, + "rmsDb": -22.840558133782523, + "crestDb": 6.900529208325618, + "noiseToToneDb": -0.7035305515542589, + "noiseToToneADb": 5.044756311984671, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1601162701845169, + "truePeakDb": -15.90057398970821, + "loudnessLufs": -28.709339901369557, + "centroidMedianHz": 1224.4350816353904, + "overSamples": 0, + "rmsDb": -22.846021449137904, + "crestDb": 6.934730747403392, + "noiseToToneDb": -0.6166079485198495, + "noiseToToneADb": 6.053873560512358, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "thruster@rpm1200", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.08972565084695816, + "maxErrAt": 143228, + "rmsErr": 0.012448641233006897, + "relRmsErrDb": -16.610480431926955, + "wasm": { + "samplePeak": 0.19161102175712585, + "truePeakDb": -14.343339234769212, + "loudnessLufs": -26.051033400616078, + "centroidMedianHz": 2114.222071076734, + "overSamples": 0, + "rmsDb": -21.48680915202533, + "crestDb": 7.1352188868162605, + "noiseToToneDb": -4.949059402813134, + "noiseToToneADb": 12.652391835948997, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1893547624349594, + "truePeakDb": -14.423961460001248, + "loudnessLufs": -26.05070520432609, + "centroidMedianHz": 2115.6363534197994, + "overSamples": 0, + "rmsDb": -21.484457233344667, + "crestDb": 7.029981882815694, + "noiseToToneDb": -4.965083035181173, + "noiseToToneADb": 12.716143920958068, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "thruster@rpm4800", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.2870774045586586, + "maxErrAt": 142979, + "rmsErr": 0.054912511748541046, + "relRmsErrDb": -8.246006212467824, + "wasm": { + "samplePeak": 0.36383503675460815, + "truePeakDb": -8.715719927350808, + "loudnessLufs": -17.418922989049733, + "centroidMedianHz": 4914.23686401765, + "overSamples": 0, + "rmsDb": -16.959964506391177, + "crestDb": 8.17805487903365, + "noiseToToneDb": -2.834032055202698, + "noiseToToneADb": 9.67560044209106, + "lowMotion": false + }, + "native": { + "samplePeak": 0.3622153401374817, + "truePeakDb": -8.742560302886037, + "loudnessLufs": -17.424886445264, + "centroidMedianHz": 4908.148280531137, + "overSamples": 0, + "rmsDb": -16.961323502311725, + "crestDb": 8.140660284709858, + "noiseToToneDb": -2.9540607279645457, + "noiseToToneADb": 9.40924191267517, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "sfxr-classic-classic-sfxr-abstract-golf", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000002171844244003296, + "maxErrAt": 117685, + "rmsErr": 9.912912629276112e-8, + "relRmsErrDb": -134.45251378934543, + "wasm": { + "samplePeak": 0.9895820021629333, + "truePeakDb": 0.9076630707842905, + "loudnessLufs": -5.327743141800993, + "centroidMedianHz": 8147.614236982103, + "overSamples": 0, + "rmsDb": -5.623460642702886, + "crestDb": 5.532496403571967, + "noiseToToneDb": -0.9960950126656059, + "noiseToToneADb": 7.586387572827178, + "lowMotion": true + }, + "native": { + "samplePeak": 0.9895820021629333, + "truePeakDb": 0.9076628030277624, + "loudnessLufs": -5.327743184793801, + "centroidMedianHz": 8147.614175634302, + "overSamples": 0, + "rmsDb": -5.623460696287697, + "crestDb": 5.5324964571567765, + "noiseToToneDb": -0.9960947920917818, + "noiseToToneADb": 7.5863877077205615, + "lowMotion": true + }, + "flagsWasm": [ + "waived truePeakDb", + "waived lowMotion", + "waived loudnessLufs", + "warn truePeakDb" + ], + "flagsNative": [ + "waived truePeakDb", + "waived lowMotion", + "waived loudnessLufs", + "warn truePeakDb" + ], + "violations": [] + }, + { + "id": "sfxr-explosion", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000002158805727958679, + "maxErrAt": 129292, + "rmsErr": 1.1522412234189382e-7, + "relRmsErrDb": -127.13848106038596, + "wasm": { + "samplePeak": 0.6732206344604492, + "truePeakDb": -3.0561513953602306, + "loudnessLufs": -8.634121016036506, + "centroidMedianHz": 9795.322296183533, + "overSamples": 0, + "rmsDb": -11.630620604104497, + "crestDb": 8.193768979977403, + "noiseToToneDb": 60, + "noiseToToneADb": 60, + "lowMotion": true + }, + "native": { + "samplePeak": 0.6732206344604492, + "truePeakDb": -3.0561512376276347, + "loudnessLufs": -8.634121064908708, + "centroidMedianHz": 9795.322310734966, + "overSamples": 0, + "rmsDb": -11.630620652864625, + "crestDb": 8.193769028737531, + "noiseToToneDb": 60, + "noiseToToneADb": 60, + "lowMotion": true + }, + "flagsWasm": [ + "waived lowMotion" + ], + "flagsNative": [ + "waived lowMotion" + ], + "violations": [] + }, + { + "id": "tract/vowel-a", + "lenWasm": 34560, + "lenNative": 34560, + "maxErr": 0.0001921914517879486, + "maxErrAt": 28187, + "rmsErr": 0.000046111049670587934, + "relRmsErrDb": -52.60441410619507, + "wasm": { + "samplePeak": 0.08378415554761887, + "truePeakDb": -21.518327105526673, + "loudnessLufs": -34.251568232392565, + "centroidMedianHz": 1937.1220888731689, + "overSamples": 0, + "rmsDb": -33.80275018649363, + "crestDb": 12.265988122620389, + "noiseToToneDb": -10.436050344670264, + "noiseToToneADb": -12.885220061403881, + "lowMotion": false + }, + "native": { + "samplePeak": 0.08390921354293823, + "truePeakDb": -21.50539262673788, + "loudnessLufs": -34.23471535099468, + "centroidMedianHz": 1937.2296872141594, + "overSamples": 0, + "rmsDb": -33.78684837329708, + "crestDb": 12.263041385142849, + "noiseToToneDb": -10.447393994101596, + "noiseToToneADb": -12.89873810612136, + "lowMotion": false + }, + "flagsWasm": [ + "FAIL onsetMs" + ], + "flagsNative": [ + "FAIL onsetMs" + ], + "violations": [] + } + ] +} \ No newline at end of file diff --git a/docs/renders/aqua-feel-native-diff-2026-11-10.md b/docs/renders/aqua-feel-native-diff-2026-11-10.md new file mode 100644 index 0000000..b3c6040 --- /dev/null +++ b/docs/renders/aqua-feel-native-diff-2026-11-10.md @@ -0,0 +1,118 @@ +# aqua-feel: faustwasm vs the daemon's native libfaust path + +**Nothing here was heard.** Numbers from offline buffers, same Faust source, 48 kHz, mono, 512-frame blocks, same params. + +- wasm side: `tools/aqua-feel/render.js` (faustwasm 0.20.0, libfaust 2.90.19 and the stdlib bundled with it, `-ftz 2`). +- native side: `tools/FaustNativeRender` over `AquaSynthPatchCompiler` (`NativeFaustRuntime.cs:530`: `-single -ftz 2 -vec -lv 1`), libfaust 2.90.20 + LLVM built in the Linux sandbox 2026-10-31, with the stdlib `faustlibraries-20261031` (master 3d8c6ef) from `AQUASYNTH_FAUST_HOME`. **The two sides compile against different standard-library text**, and that decides the thruster result below; "native" here means this sandbox's library, which the repo does not pin (`docs/faust-toolchain-boundary.md`: the host's `share/faust`). +- driver: `node tools/aqua-feel/native-diff.mjs --tract `; raw numbers in `aqua-feel-native-diff-2026-11-10.json`. +- tract voice: `vowel-a` Faust source from the 2026-11-08 render kit (0.72 s, default controls). Not in the repo (`--tract` takes a path), not an aqua-feel catalog item, so its `FAIL onsetMs` is the patch-group rule applied by this script, not a repo gate result. +- the script exits 1 on a render error, a length mismatch, or a violation of its `LIMITS` (see "Tolerance in `native-diff.mjs`" below); the verdicts here are the reader's. +- Nothing here was heard. + +Columns are wasm / native. + +| item | max abs err (at sample) | err vs signal RMS | sample peak | centroid Hz | LUFS | true peak dBTP | gate flags | +|---|---|---|---|---|---|---|---| +| thruster rpm300 | 2.6e-2 (142707) | -28.8 dB | .1426/.1424 | 751.9/751.5 | -30.227/-30.216 | -16.90/-16.92 | none / none | +| thruster rpm600 | 4.9e-2 | -23.5 dB | .1596/.1601 | 1228.0/1224.4 | -28.695/-28.709 | -15.93/-15.90 | none / none | +| thruster rpm1200 | 9.0e-2 | -16.6 dB | .1916/.1894 | 2114.2/2115.6 | -26.051/-26.051 | -14.34/-14.42 | none / none | +| thruster rpm4800 | 2.9e-1 | -8.2 dB | .3638/.3622 | 4914.2/4908.1 | -17.419/-17.425 | -8.72/-8.74 | none / none | +| sfxr classic abstract-golf | 2.2e-6 | -134.5 dB | .9896/.9896 | 8147.6/8147.6 | -5.328/-5.328 | +0.908/+0.908 | waived truePeakDb, lowMotion, loudnessLufs; warn truePeakDb / identical | +| sfxr explosion | 2.2e-6 | -127.1 dB | .6732/.6732 | 9795.3/9795.3 | -8.634/-8.634 | -3.06/-3.06 | waived lowMotion / identical | +| tract vowel-a | 1.9e-4 | -52.6 dB | .08378/.08391 | 1937.1/1937.2 | -34.252/-34.235 | -21.52/-21.51 | FAIL onsetMs / identical | + +Hiss caps (noiseToToneADb, wasm / native; cap): rpm300 13.09/13.06 (16), rpm600 5.04/6.05 (8), rpm1200 12.65/12.72 (15). rpm4800 9.68/9.41 (no cap). + +## Verdict + +**sfxr (two items): the paths agree** to float rounding (2e-6). These two patches use no library smoother (`smooth01` and a local `seg_smooth`), so the agreement says nothing about the library difference below. The gate flags are the script's `gate()` applied to each side's metrics for these two items only; they match. + +**Tract voice: agrees to -52.6 dB** (max 1.9e-4 on a 0.084 peak); metrics within 0.02 dB and 0.01 % centroid. The residual is the same library difference: the source calls `si.smoo` 101 times; with the old smoother form pinned on both sides the error is -61.2 dB, with the new form pinned on both it is 3.1e-7 (-109.3 dB). + +**Thruster: waveforms diverge, level and spectrum metrics stay close.** Relative RMS error -28.8 dB at rpm300 to -8.2 dB at rpm4800. At rpm4800 it grows over time: windowed -53, -33, -22, -15, -10 and -4 dB for 0-0.25, 0.25-0.5, 0.5-1, 1-1.5, 1.5-2 and 2-3 s. Differences in the metrics: LUFS 0.015 dB, true peak 0.081 dB, sample peak 1.2 %, centroid 0.3 %, crest 0.11 dB, hiss within 0.27 dB except rpm600 A-weighted, 1.0 dB higher native. + +### Cause (isolated by a separate Soul pass, 2026-11-10) + +The thruster smooths `rpm` with `si.smooth(ba.tau2pole(0.2))`, and every oscillator integrates that into phase. The two library texts define `si.smooth` differently: + +- the wasm bundle (libfaust 2.90.19, `/usr/share/faust`): `smooth(s) = *(1.0 - s) : + ~ *(s)`. In float32 it stalls: the rpm freezes at **4796.632** from sample ~72063 (1.5 s) for a target of 4800. A float32 simulation without fused multiply-add reproduces 4796.6318359375. +- the native overlay (faustlibraries master 3d8c6ef, `signals.lib:391`): `smooth = imp(3)`, an error-form recursion whose comment says it cannot stall. It reaches 4800.0001. + +Evidence that the library text, not `-vec`, `-lv`, `-double` or contraction, decides it: the `faust` CLI emits the same update expression for the same `si.smooth` source under scalar, `-vec -lv 0/1` and `-double`. With the old explicit form pinned in the DSP on both sides, native ends at 4797.6573 (LLVM does contract to FMA, which moves the stall point from 4796.632 to 4797.657, matching the FMA simulation) and wasm at 4796.6319. With the explicit `imp(3)` form pinned on both sides, thruster error falls from 2.61e-2 to 5.7e-7 (rpm300, -120.3 dB) and from 2.87e-1 to 1.13e-4 (rpm4800, -80.5 dB). The stall point is also hypersensitive: one ulp on the pole constant gives 4799.38 or 4798.57. + +The earlier version of this report said the cause was the float32 rounding of the smoother on a native path that "does not stall for an unisolated reason" and suggested `-vec`; that was wrong. The `wander` term is not the cause (removing it leaves rpm300 2.62e-2, rpm4800 2.89e-1). + +The isolation probes are scratch scripts in `/var/lib/mind-aqua-self/soul-nd-1` (not committed); the numbers above are not in the `.json`, which holds the seven main rows plus the tract. + +### What this does and does not invalidate + +- Level, true-peak, centroid, crest and hiss claims for the thruster hold on both libraries within the numbers above. The rpm600 hiss cap (8 dB) has 1.95 dB of headroom with the native library, not the 2.96 dB the threshold note assumes; still passes. +- Sample- or phase-level comparisons of the thruster (null tests, exact waveform diffs, sub-ms timing of the rotor tone) are specific to the library that rendered them. +- Which library a given daemon uses depends on its `share/faust`. A daemon shipping a release stdlib with the old `si.smooth` would stall like wasm; this sandbox's would not. The only Windows Faust install found (Starfire, 2.85.5) has the old form; see the section on the production stdlib below. +- **Not tested:** the other aqua-feel items that do not call `si.smooth`/`si.smoo` (the 81 items are 45 patch renders and 36 Ding event mixes; this report covers 12 sources plus the tract), the Ding event mixes (only one note per instrument is rendered below), block sizes other than 512, and any live (non-offline) host. + +## Toolchain + +dotnet 10.0.403 builds `FaustNativeRender` with `/var/lib/mind-aqua-self/env-review.sh`. Re-running the script reproduces the JSON byte for byte. + +## Which stdlib the production daemon compiles against (2026-11-10) + +The repo neither vendors nor pins faustlibraries (`.gitmodules` holds only zynaddsubfx and pink-trombone-mod). `NativeFaustRuntime.cs:530` passes `-I /share/faust`, and `AquaSynthFaustToolchain.Find` (`NativeFaustRuntime.cs:651`) takes `home` from `AQUASYNTH_FAUST_HOME`, then `AQUARIUM_FAUST_HOME`, then `Tools/Faust` beside the app, then `C:\Program Files\Faust` (Windows) or `/usr/local`, `/usr`. So the daemon compiles against whatever Faust install the host has. + +| host | Faust | `si.smooth` in its `signals.lib` | stalls (float32)? | +|---|---|---|---| +| Starfire `C:\Program Files\Faust` (read over ssh; faust.exe 2.85.5, `signals.lib` 2026-03-20, `smooth = smooth_imp(0)`, line 382) | 2.85.5 | `(1.0 - s) * x + s * y`, the same recursion as wasm's | yes, by the same mechanism | +| aqua-feel's wasm bundle (faustwasm 0.20.0, libfaust 2.90.19; embedded `signals.lib`) | 2.90.19 | `smooth(s) = *(1.0 - s) : + ~ *(s)` | yes: 4796.632 | +| this sandbox (`faustlibraries-20261031`, master 3d8c6ef, a one-commit shallow clone) | 2.90.20 | `smooth = imp(3)` with a comment on the stall | no | + +I read only that Starfire file's `smooth` definition; I did not render on Starfire. On the evidence, the Windows daemon uses the old form, so **aqua-feel's wasm numbers are the closer model of that host than this sandbox's native render is**; the sandbox's imp(3) library is not what any shipped host has. Whether the daemon runs on Starfire in production is not established here; it is the only Windows Faust install found. + +## The twelve sources that call `si.smooth` / `si.smoo` + +The earlier "eleven" was seven patches, three Ding instruments and the larynx graph; the catalog has seven patches besides the thruster **including** the larynx graph, and **four** Ding instruments (`muted-pluck` was missed). Rendered: the thruster at rpm 1200, those seven patches, and one note per instrument (261.6256 Hz, 1.5 s, not the mixed event). Three conditions, each the same Faust text on both sides: as shipped (each side's own stdlib), the old smoother pinned (`nd_smooth(s) = *(1.0 - s) : + ~ *(s)`), and imp(3) pinned. Pinning rewrites `si.smooth` and `si.smoo` in the source text; a smoother called inside another library function is not rewritten, which may account for the `dx7-algorithm-32` residual below. The tract voice rides along as a thirteenth row. Columns are native minus wasm. Raw rows: `aqua-feel-native-diff-smoother-{library,old,imp3}-2026-11-10.json`. + +| source | stdlib as shipped (wasm bundle vs sandbox): max err / rel RMS dB | old smoother pinned | imp3 pinned | ΔLUFS lib / old / imp3 | Δ true peak dB lib / old / imp3 | Δ centroid % lib / old / imp3 | +|---|---|---|---|---|---|---| +| thruster@rpm1200 | 9.0e-2 / -16.6 | 4.5e-2 / -21.2 | 7.1e-6 / -99.9 | 0.000 / 0.001 / -0.000 | -0.081 / -0.152 / 0.000 | 0.07 / -0.08 / 0.00 | +| advanced-alien-multivalve-graph | 2.9e-5 / -68.4 | 1.0e-5 / -72.5 | 7.9e-7 / -97.9 | -0.000 / -0.000 / -0.000 | -0.001 / -0.000 / -0.000 | -0.00 / 0.00 / 0.00 | +| advanced-bird-syrinx | 2.8e-3 / -27.3 | 1.6e-3 / -31.7 | 3.2e-6 / -88.3 | -0.001 / -0.001 / -0.000 | -0.000 / -0.000 / -0.000 | -0.00 / -0.00 / 0.00 | +| advanced-bird-syrinx-graph | 9.4e-4 / -38.4 | 2.7e-4 / -47.1 | 1.5e-5 / -75.4 | 0.000 / -0.000 / 0.000 | 0.000 / 0.000 / -0.000 | -0.01 / 0.00 / -0.00 | +| advanced-syrinx-voice | 1.3e-3 / -23.9 | 3.6e-4 / -34.6 | 2.3e-6 / -86.3 | 0.001 / 0.000 / 0.000 | 0.003 / 0.003 / -0.000 | -0.00 / -0.00 / 0.00 | +| advanced-human-larynx-graph | 9.6e-5 / -38.1 | 3.3e-5 / -46.6 | 7.6e-7 / -94.2 | 0.002 / 0.001 / -0.000 | 0.002 / 0.001 / -0.000 | 0.05 / 0.05 / 0.04 | +| dx7-algorithm-32-additive-organ | 1.7e-4 / -79.3 | 1.6e-4 / -79.5 | 1.6e-4 / -79.5 | 0.000 / -0.000 / 0.000 | -0.000 / -0.000 / -0.000 | -0.00 / -0.00 / -0.00 | +| dx7-algorithm-8-bright-pair | 4.1e-6 / -94.5 | 4.2e-6 / -94.2 | 3.9e-6 / -94.7 | 0.000 / 0.000 / 0.000 | -0.000 / -0.000 / -0.000 | 0.00 / 0.00 / -0.00 | +| ding-instrument/glass-chime | 3.8e-2 / -18.4 | 2.5e-2 / -21.9 | 3.3e-7 / -130.0 | 0.000 / 0.000 / 0.000 | -0.000 / 0.000 / -0.000 | 0.02 / 0.01 / -0.00 | +| ding-instrument/muted-pluck | 6.6e-4 / -49.8 | 6.1e-4 / -50.2 | 3.0e-8 / -141.7 | -0.000 / -0.000 / -0.000 | -0.000 / -0.000 / -0.000 | 0.00 / -0.01 / -0.00 | +| ding-instrument/soft-triangle | 4.3e-3 / -30.3 | 2.4e-3 / -35.2 | 4.5e-8 / -142.0 | 0.000 / -0.000 / -0.000 | -0.000 / -0.000 / 0.000 | 0.16 / 0.12 / -0.00 | +| ding-instrument/warm-bell | 5.4e-2 / -18.3 | 3.2e-2 / -22.9 | 2.3e-7 / -134.2 | 0.000 / 0.000 / -0.000 | -0.000 / 0.000 / -0.000 | 0.01 / 0.00 / -0.00 | +| tract/vowel-a | 1.9e-4 / -52.6 | 6.7e-5 / -61.2 | 3.1e-7 / -109.3 | 0.017 / 0.006 / -0.000 | 0.013 / 0.004 / -0.000 | 0.01 / 0.01 / -0.00 | + +Largest metric deltas over all sources and the three conditions: |LUFS| 0.017 dB (tract), |true peak| 0.152 dB (thruster, old pinned), |centroid| 0.16 % (soft-triangle; the earlier core run has 0.3 % at thruster rpm600). **No gate flag differs on any source in any condition**; the tract's patch-group `FAIL onsetMs` is on both sides (see above). + +What the data says: + +- With imp(3) pinned on both sides all 13 rows agree to -75 dB or better (Ding instruments -130 to -142 dB, thruster -99.9 dB, tract -109 dB; worst: `advanced-bird-syrinx-graph` -75.4 dB). `dx7-algorithm-32-additive-organ` sits at -79.3 (library), -79.5 (old) and -79.5 dB (imp3), so its residual is not the pinned smoother (a smoother inside a library function, or float rounding; not isolated). +- With the old smoother pinned the thruster still differs (-21.2 dB): native's LLVM contracts the recursion to FMA and stalls at 4797.657 instead of wasm's 4796.632. The old form's stall point depends on the arithmetic; the imp(3) form does not. +- As shipped, nine of the 13 rows differ at waveform level by -16 to -50 dB (thruster, the three syrinx patches, the larynx graph, all four Ding instruments); `advanced-alien-multivalve-graph` (-68 dB), the tract (-52.6 dB) and the two dx7 patches are closer. + +## Tolerance in `native-diff.mjs` + +Added, because the data shows a gap. Across all 13 sources and the 3 conditions the metric deltas stay under |LUFS| 0.017 dB, |true peak| 0.152 dB, centroid 0.3 %. A deliberate +1 dB gain on the native buffer moves LUFS and true peak by 1.0 dB. The limits sit between: 0.1 dB, 0.5 dB, 1 %. Sample-level error has no limit with the library smoother (the waveforms legitimately diverge), but with `--smoother imp3` it has one at -60 dB (the worst row is -75.4 dB; a one-sample shift reads about -4 dB). + +Mutation check on the tool, `--items smoother --smoother imp3`: unmutated, 12 of 12 clean, exit 0; +1 dB native gain, 12 of 12 flagged (`loudnessLufs`, `truePeakDb`, `relRmsErrDb`), exit 1; one-sample native shift, 12 of 12 flagged (`relRmsErrDb`), exit 1. A one-sample shift is not detectable with the library smoother; `loudnessLufs`, `truePeakDb` and centroid also cannot see it. The limits are tripwires fit to these 13 sources, not taste. + +## What changes in aqua-feel's conclusions + +- Nothing in the gate changes: no flag differs on any tested source, and every metric delta is far below any threshold in `thresholds.json` that I looked at except the rpm600 thruster hiss cap noted above (1.0 dB, still under the cap). +- aqua-feel's WAVs are a fair rendering of what a host with the old `si.smooth` produces (the only Windows install found). They are **not** sample-identical to this sandbox's native render, and no sample-level claim about the thruster, the syrinx patches or the Ding instruments should be carried across them. +- The listening trial heard wasm renders; for a Windows daemon that is the closer path. For a daemon built on the sandbox's library, the same sources have the same level and spectrum and a different phase trajectory. +- Nothing was heard. + +## Proposed faustlibraries pin (not made) + +The cause is that `si.smooth` is whatever the host's `share/faust` says. Options, in order of my preference (confidence moderate; none of this is Soul-checked): +1. Vendor a pinned faustlibraries tree under the daemon's `Tools/Faust/share/faust` at build, record its commit in the compile manifest next to `faustLibrary`, and have `AquaSynthNativeOptions` prefer it over the system install. This makes the wasm bundle, Starfire and the sandbox agree only if aqua-feel's wasm path pins the same text too (faustwasm takes a library via its virtual FS), so pin both. +2. Decide the smoother once, in AquaSynth's emitted source: lowering emits a local smoother definition instead of calling `si.smooth`, so no host's stdlib decides it. Smaller, but changes every lowered patch and does not cover library functions that smooth internally. +3. Leave unpinned and record the host library identity in each compile manifest, accepting skew. + +Which smoother is the right one is a sound decision, not a measurement: imp(3) cannot stall (the library's own comment), the old form lands 0.07 % short of an rpm target at 4800. That is the owner's call. diff --git a/docs/renders/aqua-feel-native-diff-smoother-imp3-2026-11-10.json b/docs/renders/aqua-feel-native-diff-smoother-imp3-2026-11-10.json new file mode 100644 index 0000000..70ade89 --- /dev/null +++ b/docs/renders/aqua-feel-native-diff-smoother-imp3-2026-11-10.json @@ -0,0 +1,480 @@ +{ + "libfaustWasm": "2.90.19", + "sampleRate": 48000, + "items": "smoother", + "smoother": "imp3", + "rows": [ + { + "id": "thruster@rpm1200", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000007145106792449951, + "maxErrAt": 129748, + "rmsErr": 8.55413991926508e-7, + "relRmsErrDb": -99.87174472945775, + "wasm": { + "samplePeak": 0.18935100734233856, + "truePeakDb": -14.4241663695795, + "loudnessLufs": -26.05070420011194, + "centroidMedianHz": 2115.6325442720204, + "overSamples": 0, + "rmsDb": -21.48445687766135, + "crestDb": 7.029809275623743, + "noiseToToneDb": -4.965082233659885, + "noiseToToneADb": 12.71614612278843, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1893547624349594, + "truePeakDb": -14.423961460001248, + "loudnessLufs": -26.05070520432609, + "centroidMedianHz": 2115.6363534197994, + "overSamples": 0, + "rmsDb": -21.484457233344667, + "crestDb": 7.029981882815694, + "noiseToToneDb": -4.965083035181173, + "noiseToToneADb": 12.716143920958068, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-alien-multivalve-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 7.934868335723877e-7, + "maxErrAt": 35537, + "rmsErr": 8.335841454269617e-8, + "relRmsErrDb": -97.93819913427402, + "wasm": { + "samplePeak": 0.03596395254135132, + "truePeakDb": -28.873901668042834, + "loudnessLufs": -44.01583944248863, + "centroidMedianHz": 1719.746341068706, + "overSamples": 0, + "rmsDb": -38.86915824557575, + "crestDb": 9.986506563399741, + "noiseToToneDb": 2.2949371593837427, + "noiseToToneADb": -35.26674665881937, + "lowMotion": false + }, + "native": { + "samplePeak": 0.03596379607915878, + "truePeakDb": -28.87393371231402, + "loudnessLufs": -44.01584006251751, + "centroidMedianHz": 1719.7470242831325, + "overSamples": 0, + "rmsDb": -38.86915865101764, + "crestDb": 9.986469180550948, + "noiseToToneDb": 2.2949373787323424, + "noiseToToneADb": -35.266660047022036, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0000031944364309310913, + "maxErrAt": 45510, + "rmsErr": 3.8508989002632593e-7, + "relRmsErrDb": -88.25157230135844, + "wasm": { + "samplePeak": 0.07248446345329285, + "truePeakDb": -22.789810284125757, + "loudnessLufs": -39.38587031070514, + "centroidMedianHz": 3246.9416863147353, + "overSamples": 0, + "rmsDb": -35.86910042371045, + "crestDb": 13.073998993865658, + "noiseToToneDb": 3.398154713653433, + "noiseToToneADb": -0.2652457381911617, + "lowMotion": false + }, + "native": { + "samplePeak": 0.07248446345329285, + "truePeakDb": -22.789811642249525, + "loudnessLufs": -39.38587068917686, + "centroidMedianHz": 3246.965668802187, + "overSamples": 0, + "rmsDb": -35.86909904000429, + "crestDb": 13.0739976101595, + "noiseToToneDb": 3.3981546091622192, + "noiseToToneADb": -0.2652490593489034, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000015079975128173828, + "maxErrAt": 32899, + "rmsErr": 0.000001467630377556357, + "relRmsErrDb": -75.44121356490483, + "wasm": { + "samplePeak": 0.06643311679363251, + "truePeakDb": -23.549215787508704, + "loudnessLufs": -41.27251453792896, + "centroidMedianHz": 3608.1313492575864, + "overSamples": 0, + "rmsDb": -35.83554258887882, + "crestDb": 12.283235156759115, + "noiseToToneDb": 3.2057938097979033, + "noiseToToneADb": -14.047787523198789, + "lowMotion": false + }, + "native": { + "samplePeak": 0.06643310934305191, + "truePeakDb": -23.549216695315987, + "loudnessLufs": -41.27251147778152, + "centroidMedianHz": 3608.0824292245743, + "overSamples": 0, + "rmsDb": -35.83554221039036, + "crestDb": 12.28323380413414, + "noiseToToneDb": 3.205791878004028, + "noiseToToneADb": -14.047777587591895, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-syrinx-voice", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000002266373485326767, + "maxErrAt": 34649, + "rmsErr": 1.2956788367741612e-7, + "relRmsErrDb": -86.30175634080427, + "wasm": { + "samplePeak": 0.01358692068606615, + "truePeakDb": -37.32686366831106, + "loudnessLufs": -43.745499382417364, + "centroidMedianHz": 3723.182853016622, + "overSamples": 0, + "rmsDb": -46.7956174011546, + "crestDb": 9.458038205292446, + "noiseToToneDb": 1.791072654520292, + "noiseToToneADb": 1.7998750278369968, + "lowMotion": false + }, + "native": { + "samplePeak": 0.013587156310677528, + "truePeakDb": -37.32703848354456, + "loudnessLufs": -43.74549926644062, + "centroidMedianHz": 3723.2127010397694, + "overSamples": 0, + "rmsDb": -46.7956172502661, + "crestDb": 9.45818868394479, + "noiseToToneDb": 1.7910715795642531, + "noiseToToneADb": 1.7998739270327846, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-human-larynx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 7.587950676679611e-7, + "maxErrAt": 23972, + "rmsErr": 3.7846162388541006e-8, + "relRmsErrDb": -94.24621859478574, + "wasm": { + "samplePeak": 0.007788178510963917, + "truePeakDb": -42.17101862717542, + "loudnessLufs": -50.40721974974582, + "centroidMedianHz": 310.7891589769418, + "overSamples": 0, + "rmsDb": -50.08572494872536, + "crestDb": 7.914442895151346, + "noiseToToneDb": -30.816131815537496, + "noiseToToneADb": -27.766650651768487, + "lowMotion": false + }, + "native": { + "samplePeak": 0.0077881766483187675, + "truePeakDb": -42.17102078454992, + "loudnessLufs": -50.40722107125285, + "centroidMedianHz": 310.8999754545593, + "overSamples": 0, + "rmsDb": -50.08572618353941, + "crestDb": 7.914442052620592, + "noiseToToneDb": -30.816127938801678, + "noiseToToneADb": -27.766649805181505, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-32-additive-organ", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000164765864610672, + "maxErrAt": 23021, + "rmsErr": 0.000020540035138682098, + "relRmsErrDb": -79.45828076486667, + "wasm": { + "samplePeak": 0.6862082481384277, + "truePeakDb": -3.2699164206425015, + "loudnessLufs": -9.759124233116152, + "centroidMedianHz": 656.4226191119797, + "overSamples": 0, + "rmsDb": -8.539759068566523, + "crestDb": 5.268877746918222, + "noiseToToneDb": -37.01983113652173, + "noiseToToneADb": -37.788337502316935, + "lowMotion": false + }, + "native": { + "samplePeak": 0.6861962676048279, + "truePeakDb": -3.2700776073676936, + "loudnessLufs": -9.75912403739766, + "centroidMedianHz": 656.4212037746422, + "overSamples": 0, + "rmsDb": -8.53975888452333, + "crestDb": 5.268725914302816, + "noiseToToneDb": -37.0197944928564, + "noiseToToneADb": -37.78830410408929, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-8-bright-pair", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000003874767571687698, + "maxErrAt": 4727, + "rmsErr": 3.855039254064681e-7, + "relRmsErrDb": -94.6889441823849, + "wasm": { + "samplePeak": 0.150354266166687, + "truePeakDb": -16.445140264060527, + "loudnessLufs": -27.707281021900723, + "centroidMedianHz": 570.7301006928219, + "overSamples": 0, + "rmsDb": -25.228220791943087, + "crestDb": 8.770535898454384, + "noiseToToneDb": -33.00376708572004, + "noiseToToneADb": -31.168271339188635, + "lowMotion": false + }, + "native": { + "samplePeak": 0.15035313367843628, + "truePeakDb": -16.44521182640461, + "loudnessLufs": -27.707279746651498, + "centroidMedianHz": 570.7300173443439, + "overSamples": 0, + "rmsDb": -25.228219801277547, + "crestDb": 8.770469484271047, + "noiseToToneDb": -33.00374306131664, + "noiseToToneADb": -31.168260425434468, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/glass-chime", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 3.3155083656311035e-7, + "maxErrAt": 1305, + "rmsErr": 1.7352308119215504e-8, + "relRmsErrDb": -130.0126824087709, + "wasm": { + "samplePeak": 0.17621399462223053, + "truePeakDb": -15.062043767848326, + "loudnessLufs": -21.567272532108692, + "centroidMedianHz": 1602.876747515682, + "overSamples": 0, + "rmsDb": -22.84411377344017, + "crestDb": 7.764921701255961, + "noiseToToneDb": -3.1568108379227766, + "noiseToToneADb": -2.864104015545095, + "lowMotion": false + }, + "native": { + "samplePeak": 0.17621399462223053, + "truePeakDb": -15.062043917300066, + "loudnessLufs": -21.567272049422396, + "centroidMedianHz": 1602.87659074774, + "overSamples": 0, + "rmsDb": -22.844113441684826, + "crestDb": 7.764921369500614, + "noiseToToneDb": -3.156811070401325, + "noiseToToneADb": -2.8641043762574725, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/muted-pluck", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 2.9802322387695312e-8, + "maxErrAt": 39, + "rmsErr": 2.9894375811414372e-9, + "relRmsErrDb": -141.73373986178612, + "wasm": { + "samplePeak": 0.184849813580513, + "truePeakDb": -14.6610699229291, + "loudnessLufs": -27.093755336399102, + "centroidMedianHz": 337.57025931776576, + "overSamples": 0, + "rmsDb": -22.505770402082735, + "crestDb": 7.842150740622795, + "noiseToToneDb": -1.8131372817002074, + "noiseToToneADb": -3.773645767164254, + "lowMotion": false + }, + "native": { + "samplePeak": 0.184849813580513, + "truePeakDb": -14.66107041311351, + "loudnessLufs": -27.093755376725216, + "centroidMedianHz": 337.5700092248225, + "overSamples": 0, + "rmsDb": -22.505770501962672, + "crestDb": 7.842150840502738, + "noiseToToneDb": -1.813137412998607, + "noiseToToneADb": -3.773645881399787, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/soft-triangle", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 4.470348358154297e-8, + "maxErrAt": 8043, + "rmsErr": 4.533743604935639e-9, + "relRmsErrDb": -141.9875633660136, + "wasm": { + "samplePeak": 0.19771063327789307, + "truePeakDb": -14.073779284051348, + "loudnessLufs": -24.40759733605614, + "centroidMedianHz": 432.727373830872, + "overSamples": 0, + "rmsDb": -22.83707655432693, + "crestDb": 8.757677097882045, + "noiseToToneDb": -7.168489439744874, + "noiseToToneADb": -9.027978911866263, + "lowMotion": false + }, + "native": { + "samplePeak": 0.19771063327789307, + "truePeakDb": -14.073779075736933, + "loudnessLufs": -24.407597412236093, + "centroidMedianHz": 432.72721346965216, + "overSamples": 0, + "rmsDb": -22.83707661841455, + "crestDb": 8.757677161969662, + "noiseToToneDb": -7.168489371555516, + "noiseToToneADb": -9.027978838572636, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/warm-bell", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 2.3096799850463867e-7, + "maxErrAt": 4434, + "rmsErr": 1.76394996238211e-8, + "relRmsErrDb": -134.18055622815194, + "wasm": { + "samplePeak": 0.2345581352710724, + "truePeakDb": -12.586384128665326, + "loudnessLufs": -19.507703963974553, + "centroidMedianHz": 930.5955363634018, + "overSamples": 0, + "rmsDb": -19.84892961990138, + "crestDb": 7.25393962687934, + "noiseToToneDb": -4.493021207525722, + "noiseToToneADb": -3.568407049551638, + "lowMotion": false + }, + "native": { + "samplePeak": 0.2345581352710724, + "truePeakDb": -12.586384726471685, + "loudnessLufs": -19.50770471111663, + "centroidMedianHz": 930.5954188145085, + "overSamples": 0, + "rmsDb": -19.84893033283087, + "crestDb": 7.253940339808832, + "noiseToToneDb": -4.49302081177872, + "noiseToToneADb": -3.568406831675894, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "tract/vowel-a", + "lenWasm": 34560, + "lenNative": 34560, + "maxErr": 3.129243850708008e-7, + "maxErrAt": 32270, + "rmsErr": 6.777997120190261e-8, + "relRmsErrDb": -109.27441638992673, + "wasm": { + "samplePeak": 0.08390945941209793, + "truePeakDb": -21.50535980428806, + "loudnessLufs": -34.23468728787301, + "centroidMedianHz": 1937.2312975664222, + "overSamples": 0, + "rmsDb": -33.786820379265095, + "crestDb": 12.26303884229992, + "noiseToToneDb": -10.447396268131833, + "noiseToToneADb": -12.898741047906523, + "lowMotion": false + }, + "native": { + "samplePeak": 0.08390921354293823, + "truePeakDb": -21.50539262673788, + "loudnessLufs": -34.23471535099468, + "centroidMedianHz": 1937.2296872141594, + "overSamples": 0, + "rmsDb": -33.78684837329708, + "crestDb": 12.263041385142849, + "noiseToToneDb": -10.447393994101596, + "noiseToToneADb": -12.89873810612136, + "lowMotion": false + }, + "flagsWasm": [ + "FAIL onsetMs" + ], + "flagsNative": [ + "FAIL onsetMs" + ], + "violations": [] + } + ] +} \ No newline at end of file diff --git a/docs/renders/aqua-feel-native-diff-smoother-library-2026-11-10.json b/docs/renders/aqua-feel-native-diff-smoother-library-2026-11-10.json new file mode 100644 index 0000000..4ec5042 --- /dev/null +++ b/docs/renders/aqua-feel-native-diff-smoother-library-2026-11-10.json @@ -0,0 +1,480 @@ +{ + "libfaustWasm": "2.90.19", + "sampleRate": 48000, + "items": "smoother", + "smoother": "library", + "rows": [ + { + "id": "thruster@rpm1200", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.08972565084695816, + "maxErrAt": 143228, + "rmsErr": 0.012448641233006897, + "relRmsErrDb": -16.610480431926955, + "wasm": { + "samplePeak": 0.19161102175712585, + "truePeakDb": -14.343339234769212, + "loudnessLufs": -26.051033400616078, + "centroidMedianHz": 2114.222071076734, + "overSamples": 0, + "rmsDb": -21.48680915202533, + "crestDb": 7.1352188868162605, + "noiseToToneDb": -4.949059402813134, + "noiseToToneADb": 12.652391835948997, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1893547624349594, + "truePeakDb": -14.423961460001248, + "loudnessLufs": -26.05070520432609, + "centroidMedianHz": 2115.6363534197994, + "overSamples": 0, + "rmsDb": -21.484457233344667, + "crestDb": 7.029981882815694, + "noiseToToneDb": -4.965083035181173, + "noiseToToneADb": 12.716143920958068, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-alien-multivalve-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000028988346457481384, + "maxErrAt": 35885, + "rmsErr": 0.0000024852740234353792, + "relRmsErrDb": -68.44968710695318, + "wasm": { + "samplePeak": 0.03596847131848335, + "truePeakDb": -28.872530673301476, + "loudnessLufs": -44.01558191383624, + "centroidMedianHz": 1719.821403397199, + "overSamples": 0, + "rmsDb": -38.86917357972277, + "crestDb": 9.98761318843934, + "noiseToToneDb": 2.2946722668875523, + "noiseToToneADb": -35.26574829949449, + "lowMotion": false + }, + "native": { + "samplePeak": 0.03596379607915878, + "truePeakDb": -28.87393371231402, + "loudnessLufs": -44.01584006251751, + "centroidMedianHz": 1719.7470242831325, + "overSamples": 0, + "rmsDb": -38.86915865101764, + "crestDb": 9.986469180550948, + "noiseToToneDb": 2.2949373787323424, + "noiseToToneADb": -35.266660047022036, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0027769599109888077, + "maxErrAt": 45282, + "rmsErr": 0.0004283705044505341, + "relRmsErrDb": -27.32626534167103, + "wasm": { + "samplePeak": 0.0724848210811615, + "truePeakDb": -22.789532845508607, + "loudnessLufs": -39.384826220237244, + "centroidMedianHz": 3246.9736644772215, + "overSamples": 0, + "rmsDb": -35.86925853361916, + "crestDb": 13.074199958593006, + "noiseToToneDb": 3.395163753535492, + "noiseToToneADb": -0.26769809662280863, + "lowMotion": false + }, + "native": { + "samplePeak": 0.07248446345329285, + "truePeakDb": -22.789811642249525, + "loudnessLufs": -39.38587068917686, + "centroidMedianHz": 3246.965668802187, + "overSamples": 0, + "rmsDb": -35.86909904000429, + "crestDb": 13.0739976101595, + "noiseToToneDb": 3.3981546091622192, + "noiseToToneADb": -0.2652490593489034, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0009388141334056854, + "maxErrAt": 33098, + "rmsErr": 0.00010481991412365385, + "relRmsErrDb": -38.36457659477791, + "wasm": { + "samplePeak": 0.06643299758434296, + "truePeakDb": -23.549220126103343, + "loudnessLufs": -41.272964930830874, + "centroidMedianHz": 3608.3773722521782, + "overSamples": 0, + "rmsDb": -35.835637421519465, + "crestDb": 12.28331440320251, + "noiseToToneDb": 3.205981107524842, + "noiseToToneADb": -14.050756471755903, + "lowMotion": false + }, + "native": { + "samplePeak": 0.06643310934305191, + "truePeakDb": -23.549216695315987, + "loudnessLufs": -41.27251147778152, + "centroidMedianHz": 3608.0824292245743, + "overSamples": 0, + "rmsDb": -35.83554221039036, + "crestDb": 12.28323380413414, + "noiseToToneDb": 3.205791878004028, + "noiseToToneADb": -14.047777587591895, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-syrinx-voice", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0012954180128872395, + "maxErrAt": 33655, + "rmsErr": 0.00017071330356165833, + "relRmsErrDb": -23.90529822473789, + "wasm": { + "samplePeak": 0.013509727083146572, + "truePeakDb": -37.32985445982076, + "loudnessLufs": -43.74666608622773, + "centroidMedianHz": 3723.272760511277, + "overSamples": 0, + "rmsDb": -46.796675488586175, + "crestDb": 9.409607002734177, + "noiseToToneDb": 1.790967089244596, + "noiseToToneADb": 1.7997448212983471, + "lowMotion": false + }, + "native": { + "samplePeak": 0.013587156310677528, + "truePeakDb": -37.32703848354456, + "loudnessLufs": -43.74549926644062, + "centroidMedianHz": 3723.2127010397694, + "overSamples": 0, + "rmsDb": -46.7956172502661, + "crestDb": 9.45818868394479, + "noiseToToneDb": 1.7910715795642531, + "noiseToToneADb": 1.7998739270327846, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-human-larynx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.00009577462333254516, + "maxErrAt": 34584, + "rmsErr": 0.00002426493085728514, + "relRmsErrDb": -38.10546679970197, + "wasm": { + "samplePeak": 0.007785667199641466, + "truePeakDb": -42.173192592326515, + "loudnessLufs": -50.40895750738575, + "centroidMedianHz": 310.7359227777714, + "overSamples": 0, + "rmsDb": -50.08733256154268, + "crestDb": 7.913249276521016, + "noiseToToneDb": -30.80971405508631, + "noiseToToneADb": -27.762876726971946, + "lowMotion": false + }, + "native": { + "samplePeak": 0.0077881766483187675, + "truePeakDb": -42.17102078454992, + "loudnessLufs": -50.40722107125285, + "centroidMedianHz": 310.8999754545593, + "overSamples": 0, + "rmsDb": -50.08572618353941, + "crestDb": 7.914442052620592, + "noiseToToneDb": -30.816127938801678, + "noiseToToneADb": -27.766649805181505, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-32-additive-organ", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.00016526132822036743, + "maxErrAt": 23020, + "rmsErr": 0.000020994103574012447, + "relRmsErrDb": -79.26822857998425, + "wasm": { + "samplePeak": 0.6861975789070129, + "truePeakDb": -3.270050577733717, + "loudnessLufs": -9.759294906714691, + "centroidMedianHz": 656.4219631461098, + "overSamples": 0, + "rmsDb": -8.539888188883904, + "crestDb": 5.268871817143614, + "noiseToToneDb": -37.019826930432856, + "noiseToToneADb": -37.7883341551492, + "lowMotion": false + }, + "native": { + "samplePeak": 0.6861962676048279, + "truePeakDb": -3.2700776073676936, + "loudnessLufs": -9.75912403739766, + "centroidMedianHz": 656.4212037746422, + "overSamples": 0, + "rmsDb": -8.53975888452333, + "crestDb": 5.268725914302816, + "noiseToToneDb": -37.0197944928564, + "noiseToToneADb": -37.78830410408929, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-8-bright-pair", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000004090368747711182, + "maxErrAt": 4395, + "rmsErr": 3.9566542791342453e-7, + "relRmsErrDb": -94.46295503093202, + "wasm": { + "samplePeak": 0.1503547728061676, + "truePeakDb": -16.445110330444138, + "loudnessLufs": -27.707288476356375, + "centroidMedianHz": 570.7272310041899, + "overSamples": 0, + "rmsDb": -25.22822394507269, + "crestDb": 8.7705683198403, + "noiseToToneDb": -33.00382123099267, + "noiseToToneADb": -31.168321209089953, + "lowMotion": false + }, + "native": { + "samplePeak": 0.15035313367843628, + "truePeakDb": -16.44521182640461, + "loudnessLufs": -27.707279746651498, + "centroidMedianHz": 570.7300173443439, + "overSamples": 0, + "rmsDb": -25.228219801277547, + "crestDb": 8.770469484271047, + "noiseToToneDb": -33.00374306131664, + "noiseToToneADb": -31.168260425434468, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/glass-chime", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.03821572195738554, + "maxErrAt": 19431, + "rmsErr": 0.006593494603898945, + "relRmsErrDb": -18.417512948342896, + "wasm": { + "samplePeak": 0.17621681094169617, + "truePeakDb": -15.062032445423984, + "loudnessLufs": -21.56737037885148, + "centroidMedianHz": 1602.6138671100102, + "overSamples": 0, + "rmsDb": -22.844945161193554, + "crestDb": 7.765891909110638, + "noiseToToneDb": -3.1846833757850774, + "noiseToToneADb": -2.8894771746074213, + "lowMotion": false + }, + "native": { + "samplePeak": 0.17621399462223053, + "truePeakDb": -15.062043917300066, + "loudnessLufs": -21.567272049422396, + "centroidMedianHz": 1602.87659074774, + "overSamples": 0, + "rmsDb": -22.844113441684826, + "crestDb": 7.764921369500614, + "noiseToToneDb": -3.156811070401325, + "noiseToToneADb": -2.8641043762574725, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/muted-pluck", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.000662742939312011, + "maxErrAt": 13239, + "rmsErr": 0.00011858435565109058, + "relRmsErrDb": -49.764983408100534, + "wasm": { + "samplePeak": 0.18485014140605927, + "truePeakDb": -14.661061484678669, + "loudnessLufs": -27.09375077529527, + "centroidMedianHz": 337.55371147245654, + "overSamples": 0, + "rmsDb": -22.505768680170274, + "crestDb": 7.8421644228588985, + "noiseToToneDb": -1.8131427242661857, + "noiseToToneADb": -3.7732237451762884, + "lowMotion": false + }, + "native": { + "samplePeak": 0.184849813580513, + "truePeakDb": -14.66107041311351, + "loudnessLufs": -27.093755376725216, + "centroidMedianHz": 337.5700092248225, + "overSamples": 0, + "rmsDb": -22.505770501962672, + "crestDb": 7.842150840502738, + "noiseToToneDb": -1.813137412998607, + "noiseToToneADb": -3.773645881399787, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/soft-triangle", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.004339124541729689, + "maxErrAt": 27366, + "rmsErr": 0.0017425528838580662, + "relRmsErrDb": -30.292986249468473, + "wasm": { + "samplePeak": 0.19771148264408112, + "truePeakDb": -14.073758822230804, + "loudnessLufs": -24.407607566689737, + "centroidMedianHz": 432.02737524513924, + "overSamples": 0, + "rmsDb": -22.837073455193796, + "crestDb": 8.757711313308086, + "noiseToToneDb": -7.16850817229934, + "noiseToToneADb": -9.027202849078392, + "lowMotion": false + }, + "native": { + "samplePeak": 0.19771063327789307, + "truePeakDb": -14.073779075736933, + "loudnessLufs": -24.407597412236093, + "centroidMedianHz": 432.72721346965216, + "overSamples": 0, + "rmsDb": -22.83707661841455, + "crestDb": 8.757677161969662, + "noiseToToneDb": -7.168489371555516, + "noiseToToneADb": -9.027978838572636, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/warm-bell", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.05384007841348648, + "maxErrAt": 22907, + "rmsErr": 0.010932589918993823, + "relRmsErrDb": -18.335820982841014, + "wasm": { + "samplePeak": 0.23455719649791718, + "truePeakDb": -12.586378660039248, + "loudnessLufs": -19.50785299525781, + "centroidMedianHz": 930.5251827853554, + "overSamples": 0, + "rmsDb": -19.848928933876216, + "crestDb": 7.2539041772066515, + "noiseToToneDb": -4.487748875412059, + "noiseToToneADb": -3.561906168663185, + "lowMotion": false + }, + "native": { + "samplePeak": 0.2345581352710724, + "truePeakDb": -12.586384726471685, + "loudnessLufs": -19.50770471111663, + "centroidMedianHz": 930.5954188145085, + "overSamples": 0, + "rmsDb": -19.84893033283087, + "crestDb": 7.253940339808832, + "noiseToToneDb": -4.49302081177872, + "noiseToToneADb": -3.568406831675894, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "tract/vowel-a", + "lenWasm": 34560, + "lenNative": 34560, + "maxErr": 0.0001921914517879486, + "maxErrAt": 28187, + "rmsErr": 0.000046111049670587934, + "relRmsErrDb": -52.60441410619507, + "wasm": { + "samplePeak": 0.08378415554761887, + "truePeakDb": -21.518327105526673, + "loudnessLufs": -34.251568232392565, + "centroidMedianHz": 1937.1220888731689, + "overSamples": 0, + "rmsDb": -33.80275018649363, + "crestDb": 12.265988122620389, + "noiseToToneDb": -10.436050344670264, + "noiseToToneADb": -12.885220061403881, + "lowMotion": false + }, + "native": { + "samplePeak": 0.08390921354293823, + "truePeakDb": -21.50539262673788, + "loudnessLufs": -34.23471535099468, + "centroidMedianHz": 1937.2296872141594, + "overSamples": 0, + "rmsDb": -33.78684837329708, + "crestDb": 12.263041385142849, + "noiseToToneDb": -10.447393994101596, + "noiseToToneADb": -12.89873810612136, + "lowMotion": false + }, + "flagsWasm": [ + "FAIL onsetMs" + ], + "flagsNative": [ + "FAIL onsetMs" + ], + "violations": [] + } + ] +} \ No newline at end of file diff --git a/docs/renders/aqua-feel-native-diff-smoother-old-2026-11-10.json b/docs/renders/aqua-feel-native-diff-smoother-old-2026-11-10.json new file mode 100644 index 0000000..486b785 --- /dev/null +++ b/docs/renders/aqua-feel-native-diff-smoother-old-2026-11-10.json @@ -0,0 +1,480 @@ +{ + "libfaustWasm": "2.90.19", + "sampleRate": 48000, + "items": "smoother", + "smoother": "old", + "rows": [ + { + "id": "thruster@rpm1200", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.04497168958187103, + "maxErrAt": 141577, + "rmsErr": 0.007318071121139892, + "relRmsErrDb": -21.224986931184873, + "wasm": { + "samplePeak": 0.19161102175712585, + "truePeakDb": -14.343339234769212, + "loudnessLufs": -26.051033400616078, + "centroidMedianHz": 2114.222071076734, + "overSamples": 0, + "rmsDb": -21.48680915202533, + "crestDb": 7.1352188868162605, + "noiseToToneDb": -4.949059402813134, + "noiseToToneADb": 12.652391835948997, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1881352812051773, + "truePeakDb": -14.4953016770333, + "loudnessLufs": -26.05000842555788, + "centroidMedianHz": 2112.509030190081, + "overSamples": 0, + "rmsDb": -21.48554731845163, + "crestDb": 6.974952256138389, + "noiseToToneDb": -4.986880144493831, + "noiseToToneADb": 12.399428225827492, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-alien-multivalve-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000010244548320770264, + "maxErrAt": 35688, + "rmsErr": 0.0000015676294788398326, + "relRmsErrDb": -72.45230428385759, + "wasm": { + "samplePeak": 0.03596847131848335, + "truePeakDb": -28.872530673301476, + "loudnessLufs": -44.01558191383624, + "centroidMedianHz": 1719.821403397199, + "overSamples": 0, + "rmsDb": -38.86917357972277, + "crestDb": 9.98761318843934, + "noiseToToneDb": 2.2946722668875523, + "noiseToToneADb": -35.26574829949449, + "lowMotion": false + }, + "native": { + "samplePeak": 0.03596773371100426, + "truePeakDb": -28.872900426797354, + "loudnessLufs": -44.015640274857304, + "centroidMedianHz": 1719.8495576360149, + "overSamples": 0, + "rmsDb": -38.86920427795782, + "crestDb": 9.987465762817079, + "noiseToToneDb": 2.294718621072647, + "noiseToToneADb": -35.265694920035834, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0016491450369358063, + "maxErrAt": 45282, + "rmsErr": 0.00025934850824015885, + "relRmsErrDb": -31.68498145060413, + "wasm": { + "samplePeak": 0.0724848210811615, + "truePeakDb": -22.789532845508607, + "loudnessLufs": -39.384826220237244, + "centroidMedianHz": 3246.9736644772215, + "overSamples": 0, + "rmsDb": -35.86925853361916, + "crestDb": 13.074199958593006, + "noiseToToneDb": 3.395163753535492, + "noiseToToneADb": -0.26769809662280863, + "lowMotion": false + }, + "native": { + "samplePeak": 0.07248431444168091, + "truePeakDb": -22.78984322120366, + "loudnessLufs": -39.38565490955099, + "centroidMedianHz": 3246.899582775747, + "overSamples": 0, + "rmsDb": -35.86920348626081, + "crestDb": 13.074084200179179, + "noiseToToneDb": 3.396666156772234, + "noiseToToneADb": -0.2667876831196381, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-bird-syrinx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.0002693869173526764, + "maxErrAt": 32700, + "rmsErr": 0.00003826655739568763, + "relRmsErrDb": -47.117064721842894, + "wasm": { + "samplePeak": 0.06643299758434296, + "truePeakDb": -23.549220126103343, + "loudnessLufs": -41.272964930830874, + "centroidMedianHz": 3608.3773722521782, + "overSamples": 0, + "rmsDb": -35.835637421519465, + "crestDb": 12.28331440320251, + "noiseToToneDb": 3.205981107524842, + "noiseToToneADb": -14.050756471755903, + "lowMotion": false + }, + "native": { + "samplePeak": 0.06643303483724594, + "truePeakDb": -23.549216558140902, + "loudnessLufs": -41.27307677467835, + "centroidMedianHz": 3608.449964447863, + "overSamples": 0, + "rmsDb": -35.835623258716865, + "crestDb": 12.283305111089557, + "noiseToToneDb": 3.20619543415809, + "noiseToToneADb": -14.048989011382716, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-syrinx-voice", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.00035776360891759396, + "maxErrAt": 33071, + "rmsErr": 0.00004987960856320729, + "relRmsErrDb": -34.59218482563713, + "wasm": { + "samplePeak": 0.013509727083146572, + "truePeakDb": -37.32985445982076, + "loudnessLufs": -43.74666608622773, + "centroidMedianHz": 3723.272760511277, + "overSamples": 0, + "rmsDb": -46.796675488586175, + "crestDb": 9.409607002734177, + "noiseToToneDb": 1.790967089244596, + "noiseToToneADb": 1.7997448212983471, + "lowMotion": false + }, + "native": { + "samplePeak": 0.013522684574127197, + "truePeakDb": -37.32721733719029, + "loudnessLufs": -43.74636063400179, + "centroidMedianHz": 3723.1202654076387, + "overSamples": 0, + "rmsDb": -46.79638101226304, + "crestDb": 9.41763937099232, + "noiseToToneDb": 1.7908757840446228, + "noiseToToneADb": 1.7996603730072778, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "advanced-human-larynx-graph", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.00003339786780998111, + "maxErrAt": 29445, + "rmsErr": 0.0000091717665877173, + "relRmsErrDb": -46.55598808562419, + "wasm": { + "samplePeak": 0.007785667199641466, + "truePeakDb": -42.173192592326515, + "loudnessLufs": -50.40895750738575, + "centroidMedianHz": 310.7359227777714, + "overSamples": 0, + "rmsDb": -50.08733256154268, + "crestDb": 7.913249276521016, + "noiseToToneDb": -30.80971405508631, + "noiseToToneADb": -27.762876726971946, + "lowMotion": false + }, + "native": { + "samplePeak": 0.00778665067628026, + "truePeakDb": -42.172393196766, + "loudnessLufs": -50.40839338870849, + "centroidMedianHz": 310.88482517283643, + "overSamples": 0, + "rmsDb": -50.086764610534054, + "crestDb": 7.913778447935089, + "noiseToToneDb": -30.8115791514923, + "noiseToToneADb": -27.76354669965494, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-32-additive-organ", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.00016482919454574585, + "maxErrAt": 23021, + "rmsErr": 0.000020542966407864508, + "relRmsErrDb": -79.45691216935256, + "wasm": { + "samplePeak": 0.6861975789070129, + "truePeakDb": -3.270050577733717, + "loudnessLufs": -9.759294906714691, + "centroidMedianHz": 656.4219631461098, + "overSamples": 0, + "rmsDb": -8.539888188883904, + "crestDb": 5.268871817143614, + "noiseToToneDb": -37.019826930432856, + "noiseToToneADb": -37.7883341551492, + "lowMotion": false + }, + "native": { + "samplePeak": 0.6861848831176758, + "truePeakDb": -3.2702216053436386, + "loudnessLufs": -9.759310761718336, + "centroidMedianHz": 656.4199149750565, + "overSamples": 0, + "rmsDb": -8.539903940430642, + "crestDb": 5.268726863889608, + "noiseToToneDb": -37.01979015752506, + "noiseToToneADb": -37.78830074856767, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "dx7-algorithm-8-bright-pair", + "lenWasm": 144000, + "lenNative": 144000, + "maxErr": 0.000004217028617858887, + "maxErrAt": 4395, + "rmsErr": 4.0587175382789543e-7, + "relRmsErrDb": -94.24174055880029, + "wasm": { + "samplePeak": 0.1503547728061676, + "truePeakDb": -16.445110330444138, + "loudnessLufs": -27.707288476356375, + "centroidMedianHz": 570.7272310041899, + "overSamples": 0, + "rmsDb": -25.22822394507269, + "crestDb": 8.7705683198403, + "noiseToToneDb": -33.00382123099267, + "noiseToToneADb": -31.168321209089953, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1503528207540512, + "truePeakDb": -16.44522744967477, + "loudnessLufs": -27.707277125976997, + "centroidMedianHz": 570.7333021952353, + "overSamples": 0, + "rmsDb": -25.228219761996577, + "crestDb": 8.770451367352216, + "noiseToToneDb": -33.00373339984028, + "noiseToToneADb": -31.168249293529612, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/glass-chime", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.0249517853371799, + "maxErrAt": 16135, + "rmsErr": 0.004421638829626474, + "relRmsErrDb": -21.888160741392436, + "wasm": { + "samplePeak": 0.17621681094169617, + "truePeakDb": -15.062032445423984, + "loudnessLufs": -21.56737037885148, + "centroidMedianHz": 1602.6138671100102, + "overSamples": 0, + "rmsDb": -22.844945161193554, + "crestDb": 7.765891909110638, + "noiseToToneDb": -3.1846833757850774, + "noiseToToneADb": -2.8894771746074213, + "lowMotion": false + }, + "native": { + "samplePeak": 0.1762169897556305, + "truePeakDb": -15.06203177829347, + "loudnessLufs": -21.567324293584342, + "centroidMedianHz": 1602.7309843808985, + "overSamples": 0, + "rmsDb": -22.8449443529706, + "crestDb": 7.765899914787472, + "noiseToToneDb": -3.15615732806026, + "noiseToToneADb": -2.8631443461744297, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/muted-pluck", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.0006083704647608101, + "maxErrAt": 11771, + "rmsErr": 0.00011333511339155152, + "relRmsErrDb": -50.15824170095642, + "wasm": { + "samplePeak": 0.18485014140605927, + "truePeakDb": -14.661061484678669, + "loudnessLufs": -27.09375077529527, + "centroidMedianHz": 337.55371147245654, + "overSamples": 0, + "rmsDb": -22.505768680170274, + "crestDb": 7.8421644228588985, + "noiseToToneDb": -1.8131427242661857, + "noiseToToneADb": -3.7732237451762884, + "lowMotion": false + }, + "native": { + "samplePeak": 0.18485012650489807, + "truePeakDb": -14.661062836676077, + "loudnessLufs": -27.093759010723893, + "centroidMedianHz": 337.5238499031714, + "overSamples": 0, + "rmsDb": -22.505770398210146, + "crestDb": 7.842165440710797, + "noiseToToneDb": -1.8131374325665277, + "noiseToToneADb": -3.773589944716136, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/soft-triangle", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.0023703736951574683, + "maxErrAt": 25715, + "rmsErr": 0.0009930534248776234, + "relRmsErrDb": -35.17725332142618, + "wasm": { + "samplePeak": 0.19771148264408112, + "truePeakDb": -14.073758822230804, + "loudnessLufs": -24.407607566689737, + "centroidMedianHz": 432.02737524513924, + "overSamples": 0, + "rmsDb": -22.837073455193796, + "crestDb": 8.757711313308086, + "noiseToToneDb": -7.16850817229934, + "noiseToToneADb": -9.027202849078392, + "lowMotion": false + }, + "native": { + "samplePeak": 0.19771143794059753, + "truePeakDb": -14.07376035946359, + "loudnessLufs": -24.407608271539363, + "centroidMedianHz": 432.56670373115156, + "overSamples": 0, + "rmsDb": -22.837076588859357, + "crestDb": 8.757712483053472, + "noiseToToneDb": -7.168496214436587, + "noiseToToneADb": -9.027680538222803, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "ding-instrument/warm-bell", + "lenWasm": 72000, + "lenNative": 72000, + "maxErr": 0.031899021938443184, + "maxErrAt": 20346, + "rmsErr": 0.0064883595529472505, + "relRmsErrDb": -22.867583977030858, + "wasm": { + "samplePeak": 0.23455719649791718, + "truePeakDb": -12.586378660039248, + "loudnessLufs": -19.50785299525781, + "centroidMedianHz": 930.5251827853554, + "overSamples": 0, + "rmsDb": -19.848928933876216, + "crestDb": 7.2539041772066515, + "noiseToToneDb": -4.487748875412059, + "noiseToToneADb": -3.561906168663185, + "lowMotion": false + }, + "native": { + "samplePeak": 0.23455692827701569, + "truePeakDb": -12.58637491873251, + "loudnessLufs": -19.50777054747485, + "centroidMedianHz": 930.5575374762606, + "overSamples": 0, + "rmsDb": -19.84893016359035, + "crestDb": 7.253895474424504, + "noiseToToneDb": -4.485889851528931, + "noiseToToneADb": -3.56069167084123, + "lowMotion": false + }, + "flagsWasm": [], + "flagsNative": [], + "violations": [] + }, + { + "id": "tract/vowel-a", + "lenWasm": 34560, + "lenNative": 34560, + "maxErr": 0.00006673485040664673, + "maxErrAt": 28187, + "rmsErr": 0.000017135153866313287, + "relRmsErrDb": -61.202754106950046, + "wasm": { + "samplePeak": 0.08378415554761887, + "truePeakDb": -21.518327105526673, + "loudnessLufs": -34.251568232392565, + "centroidMedianHz": 1937.1220888731689, + "overSamples": 0, + "rmsDb": -33.80275018649363, + "crestDb": 12.265988122620389, + "noiseToToneDb": -10.436050344670264, + "noiseToToneADb": -12.885220061403881, + "lowMotion": false + }, + "native": { + "samplePeak": 0.08382102102041245, + "truePeakDb": -21.514509846938733, + "loudnessLufs": -34.245380439304775, + "centroidMedianHz": 1937.234382427543, + "overSamples": 0, + "rmsDb": -33.796727413065646, + "crestDb": 12.263786346131285, + "noiseToToneDb": -10.440493522060173, + "noiseToToneADb": -12.890432956436477, + "lowMotion": false + }, + "flagsWasm": [ + "FAIL onsetMs" + ], + "flagsNative": [ + "FAIL onsetMs" + ], + "violations": [] + } + ] +} \ No newline at end of file diff --git a/docs/renders/thruster-v5-manifest.json b/docs/renders/thruster-v5-manifest.json new file mode 100644 index 0000000..1907dea --- /dev/null +++ b/docs/renders/thruster-v5-manifest.json @@ -0,0 +1,46 @@ +{ + "patch": "thruster", + "dspSha256": "8afc045eab94f396e21d96a262c5d71910103acd51da5913d3eb3036b48a5809", + "sampleRate": 48000, + "blockFrames": 512, + "seconds": 4, + "rpmControl": "rpm", + "os": "Debian GNU/Linux 13 (trixie)", + "faustHome": "/var/lib/mind-aqua-self/faust-home-overlay-20261031", + "faustLibrary": "/var/lib/mind-aqua-self/faust-home-overlay-20261031/lib/libfaust.so", + "cases": [ + { + "label": "low-rpm600", + "file": "thruster-low-rpm600.wav", + "rpmStart": 600, + "rpmEnd": 600, + "frames": 192000, + "peak": 0.18832043, + "rms": 0.07348131804614891, + "nonFinite": false, + "sha256": "4aeaa1f4d9aa5d63025dd8b3d45b25fc6b030a366ba05237c0021b508308e0ba" + }, + { + "label": "high-rpm4800", + "file": "thruster-high-rpm4800.wav", + "rpmStart": 4800, + "rpmEnd": 4800, + "frames": 192000, + "peak": 0.3635283, + "rms": 0.14323418081979195, + "nonFinite": false, + "sha256": "390874aa1d5c4d50f251fa0735b82f859bba43ffee4236fd24d5b06a4733ef7d" + }, + { + "label": "sweep-600-4800", + "file": "thruster-sweep-600-4800.wav", + "rpmStart": 600, + "rpmEnd": 4800, + "frames": 192000, + "peak": 0.34473976, + "rms": 0.11363349353245127, + "nonFinite": false, + "sha256": "ad8d1508aa2b37b5038a6acfcfb21fc46ae04f0180adcd6ea60edc7e918e4068" + } + ] +} \ No newline at end of file diff --git a/docs/soundscape-grammar.md b/docs/soundscape-grammar.md new file mode 100644 index 0000000..d37061d --- /dev/null +++ b/docs/soundscape-grammar.md @@ -0,0 +1,222 @@ +# Soundscape grammar for the Ratatoskr generative symphony (spec) + +Serves follow-up `aqua-body:follow_up:chat-s938-943`. **Grammar only.** No notes, +no motifs, no pitches, no instrument choices. Nothing here was rendered, run, +listened to, or measured; every number marked *proposed* is a design choice +awaiting a listening trial and a device measurement. Nothing in this document +may be cited as heard. + +## 0. Inputs not seen + +Two inputs named in the brief could not be read when this was written, and the +grammar is drafted without them. Each is a place to reconcile before the doc is +treated as in force. + +- `heimdall-body:follow_up:ratatoskr-creature-projection-v2`: `huginn view` + from instance `aqua` answers `absent` (and `absent` from Eureka's mind on + 2026-11-05), and a search of the Heimdall checkout + (`GameCult/Heimdall`, forge, 2026-11-02) found no file mentioning it. The + constraints taken from the brief are used as stated there: coarse buckets, + neutral overrides, activity comes from the Heimdall session. Section 2 is the + place that must be reconciled against that follow-up's actual field list. +- `ratatoskr:follow_up:symphony-grammar-harvest-mimir` (Mimir birdsong + research): absent from aqua's mind, but it is in force in Eureka's mind + (read 2026-11-05, after this draft was written). It points at Mimir's + `docs/bioacoustic-timeline-watermark.md`, `research/bioacoustic-contestants-2026-05-24.md` + and `calibration/bioacoustic/` (at `c6f4541`), which this draft does not cite + and nobody has read for it. The earlier grep of `GameCult/Mimir` for a + symphony or voice-separation note found none; it did not look for bioacoustic research. + Section 4 is therefore built from this repo's own `research/syrinx-modeling` + plus the general auditory-scene-analysis and acoustic-niche literature named + there, *recalled, not re-read for this draft*. The harvest is still owed and + is the likeliest thing to change section 4. + +Context actually read: `docs/gamecult/ratatoskr-target.md` in `GameCult/Ratatoskr` +at `b33d8de` (the agreement that one activity vocabulary drives both sound and +motion, the member-safe projection, "kind, Mind, time and intensity, never +briefs or mind contents"), and `docs/agent-activity-grammar.md` (an earlier, +unmerged AquaSynth draft for the Dings event source; not part of this change). + +## 1. Purpose and owner + +One typed **activity frame** per Mind, produced once, read by two consumers: + +- the **orbit renderer** (Persona motion around Yggdrasil), and +- the **voice scheduler** (which instrument voice speaks, how densely). + +Neither consumer owns the vocabulary; neither adds a field. The vocabulary owner +is this grammar. The frame's producer is the Heimdall session (section 2). A +consumer that needs a distinction the vocabulary lacks raises it as a change to +this document, not as a private side channel. + +## 2. The activity frame + +Typed CultNet/CultCache document, projected member-safe. No private pipe: the +frame reaches both consumers by the same subscription, and a consumer that +cannot read it plays nothing and draws the neutral pose. + +| field | type | values | note | +|---|---|---|---| +| `mind` | id | instance name | stable identity, not a run/session label | +| `kind` | enum, 7 | `Idle`, `Reading`, `Thinking`, `Working`, `Speaking`, `Waiting`, `Alert` | coarse; see below | +| `energy` | enum, 4 | `Low`, `Mid`, `High`, `Peak` | coarse bucket of rate/intensity, never a float | +| `seq` | u32 | monotonic per `mind` | ordering and staleness; consumers drop `seq <= last` | +| `at` | time bucket | 1 s resolution | producer's observation time, for the staleness rule | +| `override` | enum, 3 | `None`, `Neutral`, `Muted` | see rules O1..O3 | + +Why this small: 7 x 4 = 28 states fit in one byte on the wire and in a lookup +table on a phone; both consumers map state to behaviour by table, no arithmetic +on unbounded values. `kind` and `energy` are orthogonal on purpose, because +orbit radius/speed wants energy and voice choice wants kind. + +Rules: + +- **A1. Source.** The frame comes from the Heimdall session and only from it. + Neither consumer infers activity from Matrix presence, typing, message + traffic, or audio. (The target already notes presence/typing are too coarse + for Minds.) *Reconcile the exact kind list against the projection-v2 + follow-up before use.* +- **A2. Coarse by construction.** There is no continuous field in the frame. A + renderer that wants smooth motion interpolates between buckets locally; it + does not request finer data. +- **A3. Member-safe.** The frame carries kind, Mind, time and energy bucket. + Never brief text, document ids, claims, or mind contents. +- **A4. Missing is Idle.** No frame within the staleness window (section 5, S2) + means `Idle`/`Low`, rendered and sounded as nothing. Silence is the default + state, not a failure state. + +### Overrides + +- **O1. `Neutral`** (set by the session or by the member's own setting): the + Mind renders at the neutral pose and contributes no voice, regardless of + `kind`/`energy`. Neutral is not an error; it hides activity, it does not + pretend there is none to other consumers. +- **O2. `Muted`**: no voice, orbit unchanged. Lets a listener silence one Mind + without hiding its motion. +- **O3.** Overrides are data in the frame, set upstream. A consumer never + applies a local override and then reports it as the Mind's state. (A *user's* + own mute/volume is a client preference and stays local; it never rewrites a + frame.) + +## 3. Mapping tables (shape only) + +The grammar defines the mapping *axes*, not the values. Each consumer owns one +table keyed by the 28 states; the tables are checked in beside this document +when values exist. + +| axis | owner | driven by | notes | +|---|---|---|---| +| orbit radius / speed / phase coupling | orbit renderer | `energy`, `kind` | Nibu's side; not specified here | +| voice **presence** (sounding or silent) | voice scheduler | `kind` | `Idle`, `Waiting` default silent | +| voice **density** (events per window) | voice scheduler | `energy` | capped, section 5 | +| voice **role** (lead / support / texture) | voice scheduler | `kind` | role, not instrument | +| voice **identity** (which instrument) | voice scheduler | `mind` | never `kind`; section 4 | + +The one rule that ties the two consumers: **`mind` selects identity, `kind` +selects behaviour, `energy` selects amount.** A given Mind sounds like itself +in every state; what it does changes. + +## 4. Keeping voices distinct when mixed + +Principle, from the acoustic-niche and auditory-scene-analysis literature +(recalled, to be confirmed by the Mimir harvest): voices that share a niche in +frequency, time and space mask each other; voices that differ on several +independent axes stay separable even at high density. Assign each Mind a +distinct **niche** on independent axes, and make the scheduler protect those +niches. Axes: + +1. **Register band.** Each Mind owns a band; bands do not overlap among + concurrently audible voices. Most robust single cue. +2. **Timbre family.** Distinct spectral envelope and onset shape. Identity is + carried by instrument (`ratatoskr-target.md`: "the instrument says who + spoke"); this grammar does not choose instruments. +3. **Rhythmic slot.** Each Mind owns a phase offset within a shared pulse grid, + so onsets interleave instead of colliding. Collisions are the main cause of + masking at low density. +4. **Stereo position.** Fixed pan per Mind, stable across sessions. Free on + phones with headphones, nearly useless on speakers; never the only cue. +5. **Role contrast.** At most one `lead` voice sounding at a time; the rest + support or texture (quieter, sparser, narrower band). + +Rules: + +- **V1. Identity is stable.** The niche (axes 1-4) is a function of `mind` + only. It never moves with `kind`, `energy`, or which other Minds are present. + A listener learns a Mind once. +- **V2. Niche table is data**, held beside the Mind's other typed state + (the target already says the instrument per Mind belongs in a typed document + on the Mind side, not hardcoded). Not defined here. +- **V3. Voice cap.** At most **4** voices audible at once (*proposed*; the + human limit on following concurrent streams is small and phones are + small). Over the cap, the lowest-`energy` voices drop first, never the + `lead`. The cap is a requirement to measure, not a finding. +- **V4. Collision backoff.** If two voices would start within one pulse of each + other in the same register band, the lower-priority one slips to its next + slot. It does not stack. +- **V5. More Minds than niches.** When Minds exceed available niches, the + excess are *not* squeezed in by shrinking bands. They share a `texture` + voice or stay silent. Distinctness beats completeness. +- **V6. Identification is unproven.** Nominal separation (different bands, + different families) is not human discriminability. No Mind's niche is + accepted until a listener-identification trial passes (the existing + notification ledger requires the same). That trial has not happened. + +## 5. Timing, staleness and the callback deadline + +Hard rule: **the audio callback deadline wins over everything in this +document.** The callback never waits on the grammar, the network, a decode, an +allocation, or a lock. + +- **C1. Frame handoff is a snapshot.** The network/projection thread writes the + latest frame per `mind` into a preallocated table; the scheduler thread reads + it and writes a preallocated voice-command queue; the audio callback only + reads that queue. No thread but the callback touches the output buffer; the + callback never blocks on, or is blocked by, the others (single-producer + single-consumer, fixed capacity). +- **C2. Drop, never delay.** If the queue is full or the scheduler is late, the + frame or command is dropped. A late sound is not played to catch up and its + internal timing is never stretched. +- **S1. Latest wins.** Per `mind`, only the newest `seq` matters. Intermediate + frames missed during a stall are not replayed. +- **S2. Staleness.** A frame older than **10 s** (*proposed*) is treated as + absent (rule A4). Sounding on stale activity is misleading. +- **S3. Rate.** Voice density per `energy` is bounded by a ceiling per Mind per + window (*proposed*, set with the device measurement); rule V3 bounds the + total. Bursts are coalesced, not queued. +- **S4. Pre-rendered material.** The client ships rendered audio assets, no + synth in the browser (`ratatoskr-target.md` agreements). The scheduler + selects and positions assets; it generates nothing at callback time. Browsers + block audio until a first user gesture: before it, the scheduler runs but the + output is silent, and no state is lost. +- **S5. Backgrounded or hidden page.** Voice scheduler pauses with the orbit + renderer's pause (the target already pauses the background when hidden). On + resume, S1/S2 apply; there is no catch-up burst. +- **S6. Failure is silent.** No frame source, a dead scheduler, a refused + autoplay, or a full queue all produce silence and neutral pose. Nothing + retries, alerts, or alters the frame source. + +## 6. What this grammar does not decide + +- Motifs, notes, pitches, instruments, envelopes. (Next layer; needs a + listening trial per the repo's audio doctrine.) +- Orbit geometry and shader work (Nibu's side). +- The feed transport. `ratatoskr:question:activity-feed-source` owns where the + frame comes from; this document fixes only its *shape*, and that it is a + typed CultNet document, not a private channel. +- Per-device budgets. All numbers marked *proposed* need a phone measurement. + +## 7. Verification owed + +None run. To be done before the grammar leaves draft: + +1. Reconcile section 2 with `heimdall-body:follow_up:ratatoskr-creature-projection-v2`. +2. Reconcile section 4 with the Mimir harvest. +3. Round-trip the frame as a CultCache document and check the size claim (one + byte per state, 28 states). +4. Measure voice cap, density ceilings and scheduler cost on a real phone. +5. Listener-identification trial for any niche table (rule V6). + +## 8. Status + +Draft. Not implemented. No code changed. No audio was produced and none is +claimed. diff --git a/patches/dx7/public-domain/prc-synth1-calibrated.aqua b/patches/dx7/public-domain/prc-synth1-calibrated.aqua index 78bbe7d..05b5ce2 100644 --- a/patches/dx7/public-domain/prc-synth1-calibrated.aqua +++ b/patches/dx7/public-domain/prc-synth1-calibrated.aqua @@ -2,9 +2,11 @@ # Family: dx7/public-domain # Source: analog1.syx voice 17 parity probe, generated from calibrated DX7 lowering # Notes: Hard DX7 parity target that forced applied EG, summed route, feedback, detune, and COM calibration. +# Headroom: patch gain is 0.45, not the 1.4 the parity probe in Dx7ReferenceParityTests uses. At 1.4 this graph peaked at 2.10 +# (18,633 samples past full scale in a 3 s render, found by tools/aqua-feel); 0.45 keeps the same signal at 0.675, about -3.4 dBFS. patch - gain=1.4 + gain=0.45 soft_clip=false opgraph diff --git a/patches/thruster/thruster.dsp b/patches/thruster/thruster.dsp new file mode 100644 index 0000000..c36a8ae --- /dev/null +++ b/patches/thruster/thruster.dsp @@ -0,0 +1,30 @@ +// thruster: sustained, heavy, brittle-hull thruster with an RPM control. +// Compile as Faust source (AquaSynthPatchCompiler.CompileSource); it is not a .aqua script. +// Control: rpm (300..6000). Rotor tone = 28 Hz + rpm*0.025 (about 36..180 Hz); three blades chop the rasp at 3x that. +declare name "thruster"; +import("stdfaust.lib"); + +rpm = hslider("rpm", 1200, 300, 6000, 1) : si.smooth(ba.tau2pole(0.2)); +load = (rpm - 300) / 5700; +shaft = 28 + rpm * 0.025; + +// effort: slow random wander of the shaft speed +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 +// 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 (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.015 + 0.985 * load ^ 2) * 3 : *(4) : ma.tanh : /(4); + +mix = rumble * (0.5 + 0.5 * load) + rasp * (0.05 + 1.75 * load) + rattle; +// drive falls only gently with load so tanh stays out of saturation while high rpm still ends up louder than low rpm +// final 0.425 trim keeps peak under 0.4 at rpm 4800 +process = mix * (0.4 + 0.6 * load) * (0.9 - 0.5 * load) : ma.tanh : *(0.425); diff --git a/spikes/faust-aot-thruster/.gitignore b/spikes/faust-aot-thruster/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/spikes/faust-aot-thruster/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/spikes/faust-aot-thruster/README.md b/spikes/faust-aot-thruster/README.md new file mode 100644 index 0000000..6cad95b --- /dev/null +++ b/spikes/faust-aot-thruster/README.md @@ -0,0 +1,50 @@ +# Faust AOT spike: thruster v5 as a native lib (Linux) + +Spike for `docs/faust-unity-player-scoping.md` path (a). One patch, `patches/thruster/thruster.dsp` +(copied here as `thruster.dsp`; re-copy if the patch changes), compiled by the `faust` CLI to C++ and +wrapped behind a C ABI. Not wired into the solution or into Unity. + +Limitation: AOT fixes the patch set at build time, so player-authored patches cannot run this way. + +- `aqs_thruster.h`: the C ABI. `rpm` enters only via `aqs_thruster_set_rpm`: bounds are read from the + compiled patch's `hslider` (300..6000, default 1200); out-of-range and +-inf clamp to the nearest bound + (`AQS_PARAM_CLAMPED`), NaN applies the default (`AQS_PARAM_NAN`); both are counted and returned. + `aqs_thruster_process` rejects NULL, negative or >`AQS_MAX_BLOCK` frames instead of truncating. +- `test_thruster.c`: plain-C behaviour test (bounds, clamp, NaN, inf, hostile values give finite bounded + output, rpm actually changes the output, instances are deterministic). A flipped clamp bound fails it. +- `bench_thruster.c`: per-block `process` time at 48 kHz / 512 frames against the 10.667 ms deadline; prints one `OVERRUN` line + per block over the deadline with wall-clock timestamp, wall ms and thread CPU ms. +- `run.sh`: generate, build, test, bench. `FAUST`, `FAUST_PREFIX` (needs `include/faust`), `FAUST_LIBS` + (needs `stdfaust.lib`), `BLOCKS`, `RATE`, `FRAMES`, `CORE` (pin the bench to one cpu via `taskset`), `NOOP=1` (time a busy-wait + instead of the patch) override defaults. Exit status is test status; timing + is reported, not gated. + +**Scope: this measures a bare C loop.** `bench_thruster` calls `aqs_thruster_process` back to back on one +Linux thread. It shows the patch is cheap, not that a Unity player meets its deadline (no Unity audio +thread, P/Invoke, IL2CPP, mixer or Windows scheduling in it). + +Measured 2026-11-06/07, Faust 2.90.20, AMD EPYC-Genoa, shared Linux host (load average ~8 on 16 cores): +median 0.052 ms (0.49% of the 10.667 ms deadline), p99 0.08-0.16 ms (~1-1.5%). Cheap on this host: +median 0.052 ms, p99 under 0.16 ms. The host's tail is not judgeable (see below). + +**Max block time: UNEXPLAINED. It is not a pass.** Max was 10.8-17.1 ms, with 1-5 of 20000 blocks over +the deadline per run (400000-block `process`: 74 over in one run, core 3; see the control below). What the instrumented runs (`run.sh`, `CORE=` pins with +`taskset`; every overrun prints a `wall_unix` timestamp, wall ms and thread CPU ms) do show: + +- Not per-block DSP cost: `NOOP=1` times a 0.05 ms busy-wait instead of `process`, and stalls about the + same way (core 3, 400000 blocks: 74 overruns with `process`, 67 with the busy-wait, both ~11 ms; counts + differ by ~10%, no run-to-run spread measured for the control). +- Not a DSP-state pattern: overrun block indices differ every run and are not tied to the rpm sweep. +- Periodic in wall time, per core: pinned to core 3, overruns recur every ~0.3-0.4 s (mostly); pinned + to core 9, every ~1-2 s (17 in 400000 blocks). Cause of the periodicity untested (not shown to be a timer). Unpinned runs get 1-5 per 20000. +- The thread's CPU time over a stalled block is about equal to its wall time (e.g. 10.84 vs 10.85 ms), + so the stall is charged to the thread rather than showing as time off-CPU. An ordinary guest + run-queue preemption would be expected to show off-CPU time; this does not. The host (vCPU steal) is + left open. The earlier claim that these were scheduler preemption is withdrawn, not refuted. + +Cause not identified (candidates: hypervisor/container CPU accounting, per-core host timer or housekeeping +work, SMI; none tested). Whether the player would see it is unknown. A quiet, realtime-priority or +isolated-core host is needed before the max can be called anything. + +**Unverified:** Windows (MSVC build, `__declspec(dllexport)`), Unity P/Invoke, IL2CPP, the Unity audio +thread, any device. Nothing here ran inside Unity. diff --git a/spikes/faust-aot-thruster/aqs_thruster.cpp b/spikes/faust-aot-thruster/aqs_thruster.cpp new file mode 100644 index 0000000..8bfe829 --- /dev/null +++ b/spikes/faust-aot-thruster/aqs_thruster.cpp @@ -0,0 +1,71 @@ +#include "aqs_thruster.h" + +#include +#include + +#include +#include +#include +#include "thruster_dsp.h" + +struct aqs_thruster { + ThrusterDsp dsp; + APIUI ui; + float lo, hi, init, applied; + unsigned clamps = 0, nans = 0; +}; + +extern "C" { + +aqs_thruster* aqs_thruster_create(int sample_rate) { + if (sample_rate < 8000 || sample_rate > 384000) return nullptr; + aqs_thruster* t = new (std::nothrow) aqs_thruster(); + if (!t) return nullptr; + t->dsp.init(sample_rate); + t->dsp.buildUserInterface(&t->ui); + t->lo = t->ui.getParamMin(0); + t->hi = t->ui.getParamMax(0); + t->init = t->ui.getParamInit(0); + t->applied = t->init; + return t; +} + +void aqs_thruster_destroy(aqs_thruster* t) { delete t; } + +float aqs_thruster_rpm_min(const aqs_thruster* t) { return t->lo; } +float aqs_thruster_rpm_max(const aqs_thruster* t) { return t->hi; } +float aqs_thruster_rpm_default(const aqs_thruster* t) { return t->init; } + +int aqs_thruster_set_rpm(aqs_thruster* t, float rpm) { + if (!t) return -1; + int status = AQS_PARAM_OK; + if (std::isnan(rpm)) { + rpm = t->init; + status = AQS_PARAM_NAN; + ++t->nans; + } else if (rpm < t->lo) { + rpm = t->lo; + status = AQS_PARAM_CLAMPED; + ++t->clamps; + } else if (rpm > t->hi) { + rpm = t->hi; + status = AQS_PARAM_CLAMPED; + ++t->clamps; + } + t->applied = rpm; + t->ui.setParamValue(0, rpm); + return status; +} + +float aqs_thruster_rpm(const aqs_thruster* t) { return t->applied; } +unsigned aqs_thruster_clamp_count(const aqs_thruster* t) { return t->clamps; } +unsigned aqs_thruster_nan_count(const aqs_thruster* t) { return t->nans; } + +int aqs_thruster_process(aqs_thruster* t, float* out, int frames) { + if (!t || !out || frames < 0 || frames > AQS_MAX_BLOCK) return -1; + float* outs[1] = {out}; + t->dsp.compute(frames, nullptr, outs); + return frames; +} + +} // extern "C" diff --git a/spikes/faust-aot-thruster/aqs_thruster.h b/spikes/faust-aot-thruster/aqs_thruster.h new file mode 100644 index 0000000..0b93baf --- /dev/null +++ b/spikes/faust-aot-thruster/aqs_thruster.h @@ -0,0 +1,52 @@ +/* C ABI over the AOT-compiled thruster (patches/thruster/thruster.dsp). + * The host never touches Faust types. rpm enters only through aqs_thruster_set_rpm, + * which enforces the bounds declared in the .dsp (hslider min/max/init). */ +#ifndef AQS_THRUSTER_H +#define AQS_THRUSTER_H +#ifdef __cplusplus +extern "C" { +#endif + +#if defined(_WIN32) +#define AQS_API __declspec(dllexport) +#else +#define AQS_API __attribute__((visibility("default"))) +#endif + +typedef struct aqs_thruster aqs_thruster; + +/* Result of a parameter write. The applied value is always inside [min,max]. */ +enum { + AQS_PARAM_OK = 0, /* applied as given */ + AQS_PARAM_CLAMPED = 1, /* out of range (including +-inf): applied the nearest bound */ + AQS_PARAM_NAN = 2 /* NaN: applied the declared default */ +}; + +/* Largest frame count one process call accepts; larger requests are rejected, not truncated. */ +#define AQS_MAX_BLOCK 16384 + +AQS_API aqs_thruster* aqs_thruster_create(int sample_rate); /* NULL on bad rate (<8000 or >384000) or OOM */ +AQS_API void aqs_thruster_destroy(aqs_thruster*); + +/* Declared bounds, read from the compiled patch. */ +AQS_API float aqs_thruster_rpm_min(const aqs_thruster*); +AQS_API float aqs_thruster_rpm_max(const aqs_thruster*); +AQS_API float aqs_thruster_rpm_default(const aqs_thruster*); + +/* Returns AQS_PARAM_*; negative on NULL handle. */ +AQS_API int aqs_thruster_set_rpm(aqs_thruster*, float rpm); +/* Value currently applied (post-clamp). */ +AQS_API float aqs_thruster_rpm(const aqs_thruster*); + +/* Counters since create, for the host to report: out-of-range and NaN writes. */ +AQS_API unsigned aqs_thruster_clamp_count(const aqs_thruster*); +AQS_API unsigned aqs_thruster_nan_count(const aqs_thruster*); + +/* Writes `frames` mono samples. Returns frames written, or -1 on NULL handle/out, + * frames < 0, or frames > AQS_MAX_BLOCK. No allocation, no locks. */ +AQS_API int aqs_thruster_process(aqs_thruster*, float* out, int frames); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/spikes/faust-aot-thruster/bench_thruster.c b/spikes/faust-aot-thruster/bench_thruster.c new file mode 100644 index 0000000..8972cb6 --- /dev/null +++ b/spikes/faust-aot-thruster/bench_thruster.c @@ -0,0 +1,59 @@ +/* Per-block callback time vs deadline. usage: bench_thruster [blocks] [rate] [frames] [noop] + noop: time a 0.05 ms busy-wait instead of process(), to tell host stalls from DSP cost */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include "aqs_thruster.h" + +static int cmp(const void* a, const void* b) { double x = *(const double*)a, y = *(const double*)b; return (x > y) - (x < y); } + +int main(int argc, char** argv) { + int blocks = argc > 1 ? atoi(argv[1]) : 20000; + int rate = argc > 2 ? atoi(argv[2]) : 48000; + int frames = argc > 3 ? atoi(argv[3]) : 512; + int noop = argc > 4; + aqs_thruster* t = aqs_thruster_create(rate); + if (!t || blocks < 100) return 2; + float* out = malloc(sizeof(float) * frames); + double* ms = malloc(sizeof(double) * blocks); + double* cpu = malloc(sizeof(double) * blocks); /* thread CPU time per block: a large wall-cpu gap means time off-CPU; no gap means the stall is charged to the thread */ + double* wall = malloc(sizeof(double) * blocks); /* CLOCK_REALTIME at block end, seconds, for lining up with host load */ + const int warm = 200; + for (int i = 0; i < warm + blocks; ++i) { + /* sweep rpm across its whole range so the smoother and filters keep moving */ + aqs_thruster_set_rpm(t, 300.f + 5700.f * (0.5f + 0.5f * (float)((i % 997) - 498) / 498.f)); + struct timespec a, b, ca, cb, w; + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ca); + clock_gettime(CLOCK_MONOTONIC, &a); + if (noop) { struct timespec s; do clock_gettime(CLOCK_MONOTONIC, &s); while ((s.tv_sec - a.tv_sec) * 1e9 + (s.tv_nsec - a.tv_nsec) < 5e4); } + else aqs_thruster_process(t, out, frames); + clock_gettime(CLOCK_MONOTONIC, &b); + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cb); + clock_gettime(CLOCK_REALTIME, &w); + if (i >= warm) { + ms[i - warm] = (b.tv_sec - a.tv_sec) * 1e3 + (b.tv_nsec - a.tv_nsec) / 1e6; + cpu[i - warm] = (cb.tv_sec - ca.tv_sec) * 1e3 + (cb.tv_nsec - ca.tv_nsec) / 1e6; + wall[i - warm] = w.tv_sec + w.tv_nsec / 1e9; + } + } + double deadline = 1e3 * frames / rate, sum = 0; + int over = 0; + printf("start_cpu=%d\n", sched_getcpu()); + for (int i = 0; i < blocks; ++i) { + sum += ms[i]; + if (ms[i] > deadline) { + ++over; + printf("OVERRUN block=%d wall_unix=%.6f wall_ms=%.4f thread_cpu_ms=%.4f\n", i, wall[i], ms[i], cpu[i]); + } + } + qsort(ms, blocks, sizeof *ms, cmp); + double med = ms[blocks / 2], p99 = ms[(int)(blocks * 0.99)], mx = ms[blocks - 1]; + printf("rate=%d frames=%d blocks=%d deadline_ms=%.4f\n", rate, frames, blocks, deadline); + printf("median_ms=%.4f p99_ms=%.4f max_ms=%.4f mean_ms=%.4f\n", med, p99, mx, sum / blocks); + printf("median_pct_deadline=%.2f p99_pct_deadline=%.2f max_pct_deadline=%.2f over_deadline=%d\n", + 100 * med / deadline, 100 * p99 / deadline, 100 * mx / deadline, over); + free(out); free(ms); free(cpu); free(wall); aqs_thruster_destroy(t); + return 0; /* timing is reported, not gated: a shared host stalls */ +} diff --git a/spikes/faust-aot-thruster/run.sh b/spikes/faust-aot-thruster/run.sh new file mode 100755 index 0000000..40e9717 --- /dev/null +++ b/spikes/faust-aot-thruster/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the AOT thruster spike, run the boundary test, then the per-block timing report. +# Linux only. usage: run.sh [--bench-only] ; env: FAUST (faust CLI), FAUST_PREFIX (has include/faust), +# FAUST_LIBS (dir with stdfaust.lib), CORE (cpu to pin the bench to via taskset; default: unpinned), OUT (build dir), BLOCKS, RATE, FRAMES. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +FAUST=${FAUST:-faust} +FAUST_PREFIX=${FAUST_PREFIX:-$(dirname "$(dirname "$(command -v "$FAUST")")")} +FAUST_LIBS=${FAUST_LIBS:-$FAUST_PREFIX/share/faust} +OUT=${OUT:-$here/build} +mkdir -p "$OUT" +[ -f "$FAUST_LIBS/stdfaust.lib" ] || { echo "no stdfaust.lib in FAUST_LIBS=$FAUST_LIBS" >&2; exit 2; } +"$FAUST" -lang cpp -cn ThrusterDsp -I "$FAUST_LIBS" "$here/thruster.dsp" -o "$OUT/thruster_dsp.h" +"$FAUST" --version | head -1 +CXXF="-O2 -std=c++17 -fvisibility=hidden -I$OUT -I$here -I$FAUST_PREFIX/include" +g++ $CXXF -shared -fPIC -o "$OUT/libaqs_thruster.so" "$here/aqs_thruster.cpp" +gcc -O2 -I"$here" -o "$OUT/test_thruster" "$here/test_thruster.c" -L"$OUT" -laqs_thruster -lm -Wl,-rpath,"$OUT" +gcc -O2 -I"$here" -o "$OUT/bench_thruster" "$here/bench_thruster.c" -L"$OUT" -laqs_thruster -lm -Wl,-rpath,"$OUT" +"$OUT/test_thruster" +echo "--- timing (Linux $(uname -r), $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2 | xargs)); Windows/Unity: unverified" +PIN=() +if [ -n "${CORE:-}" ]; then + command -v taskset >/dev/null && PIN=(taskset -c "$CORE") || echo "CORE set but taskset missing: running unpinned" >&2 +fi +echo "bench start $(date -u +%FT%T.%NZ) pin=${PIN[*]:-none} loadavg_before=$(cut -d' ' -f1-3 /proc/loadavg)" +"${PIN[@]}" "$OUT/bench_thruster" "${BLOCKS:-20000}" "${RATE:-48000}" "${FRAMES:-512}" ${NOOP:+noop} +echo "bench end $(date -u +%FT%T.%NZ) loadavg_after=$(cut -d' ' -f1-3 /proc/loadavg)" diff --git a/spikes/faust-aot-thruster/test_thruster.c b/spikes/faust-aot-thruster/test_thruster.c new file mode 100644 index 0000000..2ecf293 --- /dev/null +++ b/spikes/faust-aot-thruster/test_thruster.c @@ -0,0 +1,94 @@ +/* Behaviour test for the bounded-parameter boundary. Plain C: proves the ABI is callable without C++. */ +#include +#include +#include +#include "aqs_thruster.h" + +static int fails; +#define CHECK(c) do { if (!(c)) { printf("FAIL line %d: %s\n", __LINE__, #c); ++fails; } } while (0) + +static float peak_after(aqs_thruster* t, float rpm, int* finite) { + static float buf[512]; + float pk = 0; + *finite = 1; + aqs_thruster_set_rpm(t, rpm); + for (int b = 0; b < 200; ++b) { /* ~2 s: lets the 0.2 s rpm smoother settle */ + CHECK(aqs_thruster_process(t, buf, 512) == 512); + for (int i = 0; i < 512; ++i) { + if (!isfinite(buf[i])) *finite = 0; + if (fabsf(buf[i]) > pk) pk = fabsf(buf[i]); + } + } + return pk; +} + +int main(void) { + CHECK(aqs_thruster_create(100) == NULL); + CHECK(aqs_thruster_create(1000000) == NULL); + aqs_thruster* t = aqs_thruster_create(48000); + CHECK(t != NULL); + if (!t) return 1; + + /* bounds come from the .dsp hslider("rpm", 1200, 300, 6000, 1) */ + CHECK(aqs_thruster_rpm_min(t) == 300.f); + CHECK(aqs_thruster_rpm_max(t) == 6000.f); + CHECK(aqs_thruster_rpm_default(t) == 1200.f); + CHECK(aqs_thruster_rpm(t) == 1200.f); + + CHECK(aqs_thruster_set_rpm(t, 4800.f) == AQS_PARAM_OK && aqs_thruster_rpm(t) == 4800.f); + CHECK(aqs_thruster_set_rpm(t, 300.f) == AQS_PARAM_OK && aqs_thruster_rpm(t) == 300.f); + CHECK(aqs_thruster_set_rpm(t, 6000.f) == AQS_PARAM_OK && aqs_thruster_rpm(t) == 6000.f); + CHECK(aqs_thruster_clamp_count(t) == 0 && aqs_thruster_nan_count(t) == 0); + + CHECK(aqs_thruster_set_rpm(t, 299.9f) == AQS_PARAM_CLAMPED && aqs_thruster_rpm(t) == 300.f); + CHECK(aqs_thruster_set_rpm(t, -50.f) == AQS_PARAM_CLAMPED && aqs_thruster_rpm(t) == 300.f); + CHECK(aqs_thruster_set_rpm(t, 6000.1f) == AQS_PARAM_CLAMPED && aqs_thruster_rpm(t) == 6000.f); + CHECK(aqs_thruster_set_rpm(t, INFINITY) == AQS_PARAM_CLAMPED && aqs_thruster_rpm(t) == 6000.f); + CHECK(aqs_thruster_set_rpm(t, -INFINITY) == AQS_PARAM_CLAMPED && aqs_thruster_rpm(t) == 300.f); + CHECK(aqs_thruster_clamp_count(t) == 5); + + aqs_thruster_set_rpm(t, 5000.f); + CHECK(aqs_thruster_set_rpm(t, NAN) == AQS_PARAM_NAN && aqs_thruster_rpm(t) == 1200.f); + CHECK(aqs_thruster_nan_count(t) == 1); + + CHECK(aqs_thruster_set_rpm(NULL, 1000.f) < 0); + float buf[16]; + CHECK(aqs_thruster_process(t, buf, -1) == -1); + CHECK(aqs_thruster_process(t, buf, AQS_MAX_BLOCK + 1) == -1); + CHECK(aqs_thruster_process(t, NULL, 16) == -1); + CHECK(aqs_thruster_process(NULL, buf, 16) == -1); + CHECK(aqs_thruster_process(t, buf, 0) == 0); + + /* hostile inputs never reach the DSP: output stays finite and under the patch's own peak bound */ + float hostile[] = {NAN, INFINITY, -INFINITY, 1e30f, -1e30f, 0.f}; + for (unsigned i = 0; i < sizeof hostile / sizeof *hostile; ++i) { + aqs_thruster* h = aqs_thruster_create(48000); + int fin; + float pk = peak_after(h, hostile[i], &fin); + CHECK(fin); + CHECK(pk > 0.01f && pk < 1.0f); + aqs_thruster_destroy(h); + } + + /* the patch is audibly rpm-dependent and loud at high rpm (guards against a stuck parameter) */ + int fin; + aqs_thruster* lo = aqs_thruster_create(48000); + aqs_thruster* hi = aqs_thruster_create(48000); + float plo = peak_after(lo, 300.f, &fin), phi = peak_after(hi, 4800.f, &fin); + CHECK(phi > plo); + CHECK(phi < 0.45f); /* patch comment: peak under 0.4 at rpm 4800, small margin */ + aqs_thruster_destroy(lo); aqs_thruster_destroy(hi); + + /* same rpm, same sample rate: two instances render identical bytes (noise is seeded) */ + aqs_thruster* a = aqs_thruster_create(48000); + aqs_thruster* b = aqs_thruster_create(48000); + static float xa[2048], xb[2048]; + aqs_thruster_set_rpm(a, 2000.f); aqs_thruster_set_rpm(b, 2000.f); + aqs_thruster_process(a, xa, 2048); aqs_thruster_process(b, xb, 2048); + CHECK(memcmp(xa, xb, sizeof xa) == 0); + aqs_thruster_destroy(a); aqs_thruster_destroy(b); + + aqs_thruster_destroy(t); + printf(fails ? "FAILED (%d)\n" : "ok\n", fails); + return fails != 0; +} diff --git a/spikes/faust-aot-thruster/thruster.dsp b/spikes/faust-aot-thruster/thruster.dsp new file mode 100644 index 0000000..c36a8ae --- /dev/null +++ b/spikes/faust-aot-thruster/thruster.dsp @@ -0,0 +1,30 @@ +// thruster: sustained, heavy, brittle-hull thruster with an RPM control. +// Compile as Faust source (AquaSynthPatchCompiler.CompileSource); it is not a .aqua script. +// Control: rpm (300..6000). Rotor tone = 28 Hz + rpm*0.025 (about 36..180 Hz); three blades chop the rasp at 3x that. +declare name "thruster"; +import("stdfaust.lib"); + +rpm = hslider("rpm", 1200, 300, 6000, 1) : si.smooth(ba.tau2pole(0.2)); +load = (rpm - 300) / 5700; +shaft = 28 + rpm * 0.025; + +// effort: slow random wander of the shaft speed +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 +// 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 (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.015 + 0.985 * load ^ 2) * 3 : *(4) : ma.tanh : /(4); + +mix = rumble * (0.5 + 0.5 * load) + rasp * (0.05 + 1.75 * load) + rattle; +// drive falls only gently with load so tanh stays out of saturation while high rpm still ends up louder than low rpm +// final 0.425 trim keeps peak under 0.4 at rpm 4800 +process = mix * (0.4 + 0.6 * load) * (0.9 - 0.5 * load) : ma.tanh : *(0.425); diff --git a/src/AquaSynth.Core/IpaGestureExperiment.cs b/src/AquaSynth.Core/IpaGestureExperiment.cs index aead558..27b94cc 100644 --- a/src/AquaSynth.Core/IpaGestureExperiment.cs +++ b/src/AquaSynth.Core/IpaGestureExperiment.cs @@ -108,7 +108,7 @@ public static class IpaGestureExperiment throw new ArgumentException("At least one experiment variant is required.", nameof(variants)); } - var roundDirectory = Path.Combine(rootDirectory, SafeFileName(roundId)); + var roundDirectory = Path.Combine(rootDirectory, StoreFileName.From(roundId)); var candidateDirectory = Path.Combine(roundDirectory, "candidates"); var timelineDirectory = Path.Combine(roundDirectory, "timelines"); Directory.CreateDirectory(candidateDirectory); @@ -122,7 +122,7 @@ public static class IpaGestureExperiment { foreach (var variant in variantList) { - var candidateId = SafeFileName($"{target.Id}-{variant.Id}"); + var candidateId = StoreFileName.From($"{StoreFileName.From(target.Id)}/{StoreFileName.From(variant.Id)}"); var script = BuildCandidateScript(target, variant); var scriptPath = Path.Combine(candidateDirectory, $"{candidateId}.aqua"); File.WriteAllText(scriptPath, script, Encoding.UTF8); @@ -705,13 +705,6 @@ public static class IpaGestureExperiment private static string SafeDslName(string value) => new(value.Select(character => char.IsLetterOrDigit(character) ? character : '_').ToArray()); - private static string SafeFileName(string value) - { - var invalid = Path.GetInvalidFileNameChars().ToHashSet(); - var safe = new string(value.Select(character => invalid.Contains(character) || char.IsWhiteSpace(character) ? '-' : character).ToArray()); - return safe.Length == 0 ? "candidate" : safe; - } - private static string Format(float value) => value.ToString("0.######", CultureInfo.InvariantCulture); diff --git a/src/AquaSynth.Core/StoreFileName.cs b/src/AquaSynth.Core/StoreFileName.cs new file mode 100644 index 0000000..9dcac70 --- /dev/null +++ b/src/AquaSynth.Core/StoreFileName.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AquaSynth.Dsl; + +/// +/// The one mapping from a caller-chosen id to a single file-name component in a store. +/// An id passes through unchanged only if it is already a fixed point: ASCII [A-Za-z0-9._-], +/// all lowercase (stores live on case-insensitive filesystems), at most long, +/// no trailing dot, not a Windows device stem (CON, NUL, COM1, ...), and not ending in a +/// "-" plus 16 hex digits. Every other id is sanitised and suffixed with "-" plus the first +/// 16 hex of SHA-256 of the original id. Passed-through names never end in the hash shape and +/// rewritten names always do, so the two sets cannot meet; distinct ids stay distinct up to a 64-bit +/// hash. The result is never a path: no separators, never "." or "..". +/// +/// +/// Not handled: symlinks and junctions already inside a store are followed, and Windows 8.3 short names +/// were not verified. An id with an uppercase letter is rewritten, so a batch directory named for such +/// an id gains a hash suffix. +/// +public static class StoreFileName +{ + private const int MaxStem = 100; + private const int HashLength = 16; + + public static string From(string id) + { + ArgumentNullException.ThrowIfNull(id); + if (id.Length == 0 || id.All(c => c == '.')) + { + throw new ArgumentException($"Id '{id}' cannot name a store file.", nameof(id)); + } + + var builder = new StringBuilder(Math.Min(id.Length, MaxStem)); + foreach (var c in id.Take(MaxStem)) + { + builder.Append(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-' or '_' or '.' ? c : '_'); + } + + var safe = builder.ToString(); + if (IsFixedPoint(id, safe)) + { + return safe; + } + + // "nul.x-" still has the device stem "nul"; the underscore moves the whole name off it. + if (IsDeviceStem(safe)) + { + safe = "_" + safe; + } + + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id)))[..HashLength].ToLowerInvariant(); + return $"{safe}-{hash}"; + } + + /// A child of named for an external id; the result never leaves the root. + public static string Combine(string root, string id) => Path.Combine(root, From(id)); + + private static bool IsFixedPoint(string id, string safe) + { + if (safe != id || id.EndsWith('.') || id.Any(char.IsAsciiLetterUpper) || IsDeviceStem(id) || EndsWithHashShape(id)) + { + return false; + } + + return true; + } + + // Windows resolves CON, PRN, AUX, NUL, COM0-9, LPT0-9 as devices whatever the extension. + private static bool IsDeviceStem(string id) + { + var dot = id.IndexOf('.'); + var stem = (dot < 0 ? id : id[..dot]).TrimEnd(' '); + return stem.Length switch + { + 3 => stem.Equals("con", StringComparison.OrdinalIgnoreCase) + || stem.Equals("prn", StringComparison.OrdinalIgnoreCase) + || stem.Equals("aux", StringComparison.OrdinalIgnoreCase) + || stem.Equals("nul", StringComparison.OrdinalIgnoreCase), + 4 => char.IsAsciiDigit(stem[3]) + && (stem.StartsWith("com", StringComparison.OrdinalIgnoreCase) || stem.StartsWith("lpt", StringComparison.OrdinalIgnoreCase)), + _ => false, + }; + } + + private static bool EndsWithHashShape(string id) + { + if (id.Length <= HashLength || id[^(HashLength + 1)] != '-') + { + return false; + } + + return id[^HashLength..].All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f'); + } +} diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs index 28ae62d..0021895 100644 --- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs +++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs @@ -556,9 +556,9 @@ public sealed class AquaSynthDaemonService : IDisposable session.Stream.ProcessBlock([], outputs, command.FrameCount, session.Controls); var mono = MixMono(outputs, command.FrameCount, command.Gain * session.Gain); var blockId = BlockId(command.CommandId); - var blockDir = Path.Combine(options.StoreRoot, "live", SafeFileName(command.SessionId)); + var blockDir = Path.Combine(options.StoreRoot, "live", StoreFileName.From(command.SessionId)); Directory.CreateDirectory(blockDir); - var floatPath = Path.Combine(blockDir, $"{SafeFileName(blockId)}.f32"); + var floatPath = Path.Combine(blockDir, $"{StoreFileName.From(blockId)}.f32"); await WriteFloat32Async(floatPath, mono).ConfigureAwait(false); var sequence = session.NextSequence(); @@ -661,8 +661,9 @@ public sealed class AquaSynthDaemonService : IDisposable var renderId = RenderId(command.CommandId); var sampleDir = Path.Combine(options.StoreRoot, "samples"); Directory.CreateDirectory(sampleDir); - var floatPath = Path.Combine(sampleDir, $"{renderId}.f32"); - var wavPath = Path.Combine(sampleDir, $"{renderId}.wav"); + var sampleStem = StoreFileName.From(renderId); + var floatPath = Path.Combine(sampleDir, $"{sampleStem}.f32"); + var wavPath = Path.Combine(sampleDir, $"{sampleStem}.wav"); await WriteFloat32Async(floatPath, samples).ConfigureAwait(false); await WriteWavAsync(wavPath, samples, patch.Manifest.SampleRate).ConfigureAwait(false); @@ -703,8 +704,8 @@ public sealed class AquaSynthDaemonService : IDisposable var runId = $"{command.CommandId}-stream"; var verseId = "aquasynth.instrument"; - var audioStreamId = $"aquasynth.audio.{SafeFileName(command.CommandId)}"; - var controlStreamId = $"aquasynth.controls.{SafeFileName(command.CommandId)}"; + var audioStreamId = $"aquasynth.audio.{StoreFileName.From(command.CommandId)}"; + var controlStreamId = $"aquasynth.controls.{StoreFileName.From(command.CommandId)}"; if (!string.Equals(compile.Status, "succeeded", StringComparison.Ordinal)) { @@ -741,7 +742,7 @@ public sealed class AquaSynthDaemonService : IDisposable .GroupBy(frame => frame.Block) .ToDictionary(group => group.Key, group => NormalizeControls(group.Last().Controls), EqualityComparer.Default); - var streamDir = Path.Combine(options.StoreRoot, "streams", SafeFileName(runId)); + var streamDir = Path.Combine(options.StoreRoot, "streams", StoreFileName.From(runId)); Directory.CreateDirectory(streamDir); var packets = new List(); using (var stream = patch.CreateStreamingPatch()) @@ -918,7 +919,7 @@ public sealed class AquaSynthDaemonService : IDisposable private async Task WriteReceiptAsync(string family, string id, T document, bool global = false) where T : class { - var filePath = Path.Combine(options.StoreRoot, family, $"{SafeFileName(id)}.cc"); + var filePath = Path.Combine(options.StoreRoot, family, $"{StoreFileName.From(id)}.cc"); Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); // Opening hydrates; a receipt file holds exactly one document, so everything else goes before the write. using var cache = CultCacheMessagePack.Create(filePath); @@ -1129,18 +1130,7 @@ public sealed class AquaSynthDaemonService : IDisposable private static string BlockId(string commandId) => $"{commandId}-block"; - private static string SessionId(string patchId, string compileKey) => $"{SafeFileName(patchId)}-{compileKey[..Math.Min(12, compileKey.Length)]}"; - - private static string SafeFileName(string value) - { - var builder = new StringBuilder(value.Length); - foreach (var c in value) - { - builder.Append(char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '-'); - } - - return builder.ToString().Trim('-'); - } + private static string SessionId(string patchId, string compileKey) => $"{StoreFileName.From(patchId)}-{compileKey[..Math.Min(12, compileKey.Length)]}"; private static string ToArtifactUri(string path) => $"file:///{Path.GetFullPath(path).Replace('\\', '/')}"; diff --git a/src/AquaSynth.Faust/FaustCompiler.cs b/src/AquaSynth.Faust/FaustCompiler.cs index b2b197d..94f363b 100644 --- a/src/AquaSynth.Faust/FaustCompiler.cs +++ b/src/AquaSynth.Faust/FaustCompiler.cs @@ -222,12 +222,8 @@ public static class FaustCompiler _ => throw new ArgumentOutOfRangeException(nameof(language), language, null) }; - private static string SafeArtifactName(string value) - { - var sanitized = new string(value.Select(character => - char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '_').ToArray()); - return string.IsNullOrWhiteSpace(sanitized) ? "aquasynth_patch" : sanitized; - } + private static string SafeArtifactName(string value) => + string.IsNullOrWhiteSpace(value) ? "aquasynth_patch" : StoreFileName.From(value); private static async Task FaustArchDirAsync(string faustPath, CancellationToken cancellationToken) { diff --git a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs index 4f33bf4..ef37d3d 100644 --- a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs +++ b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs @@ -124,7 +124,7 @@ public static class IpaTrialOrchestrator throw new ArgumentException("At least one IPA trial target set is required.", nameof(trialSets)); } - var batchDirectory = Path.Combine(artifactRoot, options.BatchId); + var batchDirectory = StoreFileName.Combine(artifactRoot, options.BatchId); Directory.CreateDirectory(batchDirectory); var storePath = Path.Combine(batchDirectory, options.TrialResultStoreFileName); var referenceRenderer = new PinkTromboneReferenceRenderer(); @@ -156,7 +156,7 @@ public static class IpaTrialOrchestrator var candidateDir = Path.Combine(round.RoundDirectory, "audio", candidate.Id); Directory.CreateDirectory(candidateDir); var script = await File.ReadAllTextAsync(candidate.ScriptPath, cancellationToken).ConfigureAwait(false); - var source = FaustEmitter.EmitScript(script, new FaustExportOptions(SafeName(candidate.Id))).Source; + var source = FaustEmitter.EmitScript(script, new FaustExportOptions(candidate.Id)).Source; var dspPath = Path.Combine(candidateDir, "candidate.dsp"); await File.WriteAllTextAsync(dspPath, source, cancellationToken).ConfigureAwait(false); @@ -274,7 +274,7 @@ public static class IpaTrialOrchestrator throw new ArgumentException("At least one IPA script candidate is required.", nameof(candidates)); } - var batchDirectory = Path.Combine(artifactRoot, options.BatchId); + var batchDirectory = StoreFileName.Combine(artifactRoot, options.BatchId); var candidateRoot = Path.Combine(batchDirectory, "agent-candidates"); var timelineRoot = Path.Combine(batchDirectory, "agent-timelines"); Directory.CreateDirectory(candidateRoot); @@ -298,7 +298,7 @@ public static class IpaTrialOrchestrator var startedAt = DateTimeOffset.UtcNow; var startedStamp = Stopwatch.GetTimestamp(); - var safeCandidateId = SafeName(candidate.CandidateId); + var safeCandidateId = StoreFileName.From(candidate.CandidateId); var candidateDir = Path.Combine(candidateRoot, safeCandidateId); Directory.CreateDirectory(candidateDir); var audioDir = Path.Combine(candidateDir, "audio"); @@ -721,9 +721,6 @@ public static class IpaTrialOrchestrator return samples; } - private static string SafeName(string value) => - new(value.Select(character => char.IsLetterOrDigit(character) ? character : '_').ToArray()); - private static string Escape(string value) => value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" diff --git a/src/AquaSynth.Faust/NativeFaustRuntime.cs b/src/AquaSynth.Faust/NativeFaustRuntime.cs index bf707ef..99bc288 100644 --- a/src/AquaSynth.Faust/NativeFaustRuntime.cs +++ b/src/AquaSynth.Faust/NativeFaustRuntime.cs @@ -633,18 +633,10 @@ public sealed class AquaSynthNativeCompiler : IDisposable } Directory.CreateDirectory(outputRoot); - var path = Path.Combine(outputRoot, $"{SafeFileName(patchId)}.dsp"); + var path = Path.Combine(outputRoot, $"{StoreFileName.From(patchId)}.dsp"); File.WriteAllText(path, source); return path; } - - private static string SafeFileName(string value) - { - var invalid = Path.GetInvalidFileNameChars().ToHashSet(); - var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray(); - var name = new string(chars).Trim(); - return string.IsNullOrWhiteSpace(name) ? "aquasynth_patch" : name; - } } public static class AquaSynthFaustToolchain @@ -840,16 +832,32 @@ internal sealed class FaustNativeToolchain : IDisposable public void SetControls(IntPtr dsp, IReadOnlyDictionary controls, IReadOnlyDictionary map) { - foreach (var (path, value) in controls) + // Dictionary's struct enumerator avoids boxing one through the interface on every audio block. + if (controls is Dictionary dictionary) { - if (!map.TryGetValue(path, out var zone) && - !map.TryGetValue(path.TrimStart('/'), out zone)) + foreach (var (path, value) in dictionary) { - throw new ArgumentException($"Faust control `{path}` was not exported by the compiled patch.", nameof(controls)); + WriteControl(map, path, value); } - Marshal.Copy([value], 0, zone, 1); + return; } + + foreach (var (path, value) in controls) + { + WriteControl(map, path, value); + } + } + + private static void WriteControl(IReadOnlyDictionary map, string path, float value) + { + if (!map.TryGetValue(path, out var zone) && + !map.TryGetValue(path.TrimStart('/'), out zone)) + { + throw new ArgumentException($"Faust control `{path}` was not exported by the compiled patch.", "controls"); + } + + Marshal.WriteInt32(zone, BitConverter.SingleToInt32Bits(value)); } public void Dispose() diff --git a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs index 4c45a01..5e6f9a6 100644 --- a/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs +++ b/tests/AquaSynth.Dsl.Tests/DingCatalogTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AquaSynth.Dings; namespace AquaSynth.Dsl.Tests; @@ -32,4 +33,37 @@ public sealed class DingCatalogTests foreach (var pair in dingEvent.Notes.Zip(dingEvent.Notes.Skip(1))) Assert.InRange(pair.Second.DelayMilliseconds - pair.First.DelayMilliseconds, 180, 320); } + + // tools/aqua-feel measures the lowered files in tools/aqua-feel/lowered-dings; regenerate them with + // `dotnet run --project tools/AquaLower -- --dings tools/aqua-feel/lowered-dings` when the catalog changes. + [Fact] + public void LoweredDingsFedToTheFeelMeterMatchTheCatalog() + { + var dir = Path.Combine(RepositoryRoot(), "tools", "aqua-feel", "lowered-dings"); + foreach (var instrument in DingCatalog.Instruments.Values) + { + var expected = FaustEmitter.EmitScript(instrument.Script, new FaustExportOptions("dings_" + instrument.Id.Replace('-', '_'))).Source; + Assert.Equal(expected, File.ReadAllText(Path.Combine(dir, instrument.Id + ".dsp"))); + } + + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "dings.json"))); + var instruments = doc.RootElement.GetProperty("instruments").EnumerateArray().ToDictionary(e => e.GetProperty("id").GetString()!); + Assert.Equal(DingCatalog.Instruments.Keys.Order(), instruments.Keys.Order()); + foreach (var instrument in DingCatalog.Instruments.Values) + Assert.Equal(instrument.RootFrequency, instruments[instrument.Id].GetProperty("rootFrequency").GetSingle()); + var events = doc.RootElement.GetProperty("events").EnumerateArray().ToDictionary(e => e.GetProperty("id").GetString()!); + Assert.Equal(DingCatalog.Events.Keys.Order(), events.Keys.Order()); + foreach (var ev in DingCatalog.Events.Values) + { + var notes = events[ev.Id].GetProperty("notes").EnumerateArray().Select(n => (n.GetProperty("semitones").GetDouble(), n.GetProperty("delayMilliseconds").GetInt32(), n.GetProperty("gain").GetSingle())).ToArray(); + Assert.Equal(ev.Notes.Select(n => (n.Semitones, n.DelayMilliseconds, n.Gain)).ToArray(), notes); + } + } + + private static string RepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AquaSynth.sln"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("AquaSynth.sln not found above the test binaries."); + } } diff --git a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs new file mode 100644 index 0000000..0e3f5da --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs @@ -0,0 +1,227 @@ +using AquaSynth.Faust; + +namespace AquaSynth.Dsl.Tests; + +public sealed class StoreFileNameTests +{ + [Fact] + public void DistinctIdsGetDistinctNames() + { + var ids = new[] { "a/b", "a b", "a:b", "a-b", "a_b", "a\\b", "a?b" }; + var names = ids.Select(StoreFileName.From).ToArray(); + Assert.Equal(ids.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Theory] + [InlineData("dings.warm-bell")] + [InlineData("demo-stream-block")] + [InlineData("a_b.c-1")] + [InlineData("conx")] + [InlineData("com10")] + public void CleanIdsPassThroughUnchanged(string id) => Assert.Equal(id, StoreFileName.From(id)); + + [Fact] + public void RewrittenIdOutputAsAnIdDoesNotCollideWithTheOriginal() + { + var rewritten = StoreFileName.From("a b"); + Assert.NotEqual(rewritten, StoreFileName.From(rewritten)); + Assert.NotEqual(StoreFileName.From("a_b"), rewritten); + } + + [Fact] + public void IdsDifferingOnlyByCaseGetDistinctNames() + { + var names = new[] { "A", "a", "Ab", "aB", "ab" }.Select(StoreFileName.From).ToArray(); + Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Theory] + [InlineData("CON")] + [InlineData("nul")] + [InlineData("Aux.txt")] + [InlineData("com1")] + [InlineData("COM9.f32")] + [InlineData("lpt0")] + [InlineData("prn.cc")] + [InlineData("nul.x")] + [InlineData("LPT1.x")] + [InlineData("CON.tar.gz")] + public void WindowsDeviceStemsAreRewritten(string id) + { + var name = StoreFileName.From(id); + Assert.StartsWith("_", name); + Assert.NotEqual(id.ToLowerInvariant(), name.ToLowerInvariant()); + Assert.Matches("-[0-9a-f]{16}$", name); + var stem = name.Split('.')[0].TrimEnd(' '); + Assert.DoesNotMatch("^(?i:con|prn|aux|nul|com[0-9]|lpt[0-9])$", stem); + } + + [Theory] + [InlineData("nul ")] + [InlineData("COM\u00b9")] + [InlineData("COM1 .x")] + [InlineData("con\u00e9.x")] + [InlineData("NUL.")] + public void DeviceLookalikesNeverLeaveAReservedStem(string id) + { + var name = StoreFileName.From(id); + Assert.Matches("^[A-Za-z0-9._-]+$", name); + Assert.DoesNotMatch("^(?i:con|prn|aux|nul|com[0-9]|lpt[0-9])$", name.Split('.')[0].TrimEnd(' ')); + Assert.Matches("-[0-9a-f]{16}$", name); + } + + [Fact] + public void OverlongIdsAreBoundedAndStayDistinct() + { + var a = StoreFileName.From(new string('x', 400)); + var b = StoreFileName.From(new string('x', 399) + "y"); + Assert.True(a.Length + ".cc".Length <= 255); + Assert.NotEqual(a, b); + } + + [Fact] + public void RewrittenIdsCarryHashOfTheOriginal() + { + // sha256("a b") begins c2a5..., checked independently of the implementation. + var expected = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData("a b"u8.ToArray()))[..16].ToLowerInvariant(); + Assert.Equal($"a_b-{expected}", StoreFileName.From("a b")); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("")] + public void DotsAndEmptyAreRefused(string id) => Assert.Throws(() => StoreFileName.From(id)); + + [Theory] + [InlineData("../..")] + [InlineData("../../etc/passwd")] + [InlineData("..\\..\\x")] + [InlineData("/abs/path")] + [InlineData("C:\\abs")] + [InlineData("a.")] + [InlineData("a/..")] + public void NoIdCanMakePathCombineEscapeTheRoot(string id) + { + var root = Path.Combine(Path.GetTempPath(), "store-root-probe", "store"); + var name = StoreFileName.From(id); + Assert.Equal(name, Path.GetFileName(name)); + Assert.NotEqual(".", name); + Assert.NotEqual("..", name); + var full = Path.GetFullPath(Path.Combine(root, name)); + Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(full)); + } + + [Fact] + public async Task DaemonSampleFilesForDotDotCommandIdStayInsideStore() + { + var parent = Path.Combine(Path.GetTempPath(), "aquasynth-sfn-" + Guid.NewGuid().ToString("N")); + var storeRoot = Path.Combine(parent, "store"); + using var service = new AquaSynthDaemonService(new AquaSynthDaemonOptions(storeRoot)); + + // A patch that compiles reaches the samples/ write; without native Faust only the compile-failed branch runs. + var result = await service.SampleAsync(new AquaSynthInstrumentSampleCommand( + "..", "test.sine", "test_sine", + "voice wave=sine freq=440 gain=.2 attack=.001 sustain=.05 decay=.05", DurationSeconds: 0.1f)); + + Assert.Empty(Directory.GetFiles(parent)); + Assert.Empty(Directory.GetDirectories(parent).Where(d => Path.GetFileName(d) != "store")); + if (string.Equals(result.RenderReceipt.Status, "succeeded", StringComparison.Ordinal)) + { + Assert.Equal(storeRoot, Path.GetDirectoryName(Path.GetDirectoryName(UriPath(result.RenderReceipt.Float32Uri)))); + Assert.Equal(storeRoot, Path.GetDirectoryName(Path.GetDirectoryName(UriPath(result.RenderReceipt.WavUri)))); + } + } + + [Fact] + public void IpaRoundCandidatesWithCollidingSanitisedIdsKeepDistinctFilesAndStayInRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"aquasynth-ipa-sfn-{Guid.NewGuid():N}"); + var result = IpaGestureExperiment.WriteRound( + root, + "round-sfn", + [new IpaGestureExperimentTarget("a/b", "p", "voiceless_bilabial_plosive", .1f), new IpaGestureExperimentTarget("a-b", "m", "voiced_bilabial_nasal", .1f)], + [new IpaGestureExperimentVariant("base")]); + + Assert.Equal(2, result.Candidates.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(result.Candidates, c => Assert.StartsWith(Path.GetFullPath(root), Path.GetFullPath(c.ScriptPath))); + Assert.Throws(() => IpaGestureExperiment.WriteRound(root, "..", [new IpaGestureExperimentTarget("a", "p", "voiceless_bilabial_plosive")], [new IpaGestureExperimentVariant("base")])); + } + + [Fact] + public void IpaRoundJoinedTargetVariantIdsThatConcatenateEquallyKeepDistinctFiles() + { + var root = Path.Combine(Path.GetTempPath(), $"aquasynth-ipa-join-{Guid.NewGuid():N}"); + var result = IpaGestureExperiment.WriteRound( + root, + "round-join", + [new IpaGestureExperimentTarget("a", "p", "voiceless_bilabial_plosive", .1f), new IpaGestureExperimentTarget("a-b", "m", "voiced_bilabial_nasal", .1f)], + [new IpaGestureExperimentVariant("b-c"), new IpaGestureExperimentVariant("c")]); + + Assert.Equal(4, result.Candidates.Count); + Assert.Equal(4, result.Candidates.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(4, result.Candidates.Select(c => c.ScriptPath).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void IpaRoundPairsWithSeparatorCharactersInIdsStayDistinct() + { + var root = Path.Combine(Path.GetTempPath(), $"aquasynth-ipa-pair-{Guid.NewGuid():N}"); + string[] targets = ["a", "a/b", "a-b", "a_b", "A", "a b"]; + string[] variants = ["b-c", "c", "b/c", "b_c", "B-C", "b c"]; + var result = IpaGestureExperiment.WriteRound( + root, + "round-pairs", + targets.Select(t => new IpaGestureExperimentTarget(t, "p", "voiceless_bilabial_plosive", .1f)).ToArray(), + variants.Select(v => new IpaGestureExperimentVariant(v)).ToArray()); + + Assert.Equal(36, result.Candidates.Count); + Assert.Equal(36, result.Candidates.Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(36, result.Candidates.Select(c => c.ScriptPath).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Theory] + [InlineData("../x")] + [InlineData("..\\x")] + [InlineData("/etc/x")] + [InlineData("C:\\x")] + public void BatchIdsFromTheCommandLineCannotLeaveTheArtifactRoot(string batchId) + { + var root = Path.Combine(Path.GetTempPath(), "artifact-root-probe", "artifacts"); + var full = Path.GetFullPath(StoreFileName.Combine(root, batchId)); + Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(full)); + } + + [Theory] + [InlineData("\\\\host\\share\\x")] + [InlineData("C:x")] + [InlineData("a:b")] + [InlineData("x.")] + [InlineData("x ")] + [InlineData("x. ")] + public void WindowsAbsoluteAndTrailingDotOrSpaceIdsStayContainedAndPlain(string id) + { + var root = Path.Combine(Path.GetTempPath(), "artifact-root-probe", "artifacts"); + var name = StoreFileName.From(id); + Assert.Matches("^[A-Za-z0-9._-]+$", name); + Assert.False(name.EndsWith('.') || name.EndsWith(' ')); + Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(Path.GetFullPath(StoreFileName.Combine(root, id)))); + } + + [Theory] + [InlineData("../x")] + [InlineData("a/b")] + [InlineData("name:with:colons")] + public void LoweredDspFileNameStaysInTheOutputDirectory(string name) + { + var outDir = Path.Combine(Path.GetTempPath(), "aqualower-probe", "out"); + var file = StoreFileName.From(name) + ".dsp"; + Assert.Equal(Path.GetFullPath(outDir), Path.GetDirectoryName(Path.GetFullPath(Path.Combine(outDir, file)))); + } + + [Fact] + public void BareDotDotBatchIdIsRefused() => + Assert.Throws(() => StoreFileName.Combine(Path.GetTempPath(), "..")); + + private static string UriPath(string uri) => new Uri(uri).LocalPath; +} diff --git a/tools/AquaLower/AquaLower.csproj b/tools/AquaLower/AquaLower.csproj new file mode 100644 index 0000000..fcd7f18 --- /dev/null +++ b/tools/AquaLower/AquaLower.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/tools/AquaLower/Program.cs b/tools/AquaLower/Program.cs new file mode 100644 index 0000000..8ee3fd7 --- /dev/null +++ b/tools/AquaLower/Program.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using AquaSynth.Dings; +using AquaSynth.Dsl; + +// Lowers .aqua patches to Faust source for the static playground (tools/aqua-playground). +// usage: AquaLower = ... +// A .aqua entry goes through FaustEmitter.EmitScript, the same lowering the daemon uses; a .dsp entry +// (the thruster) is already Faust and is copied verbatim. Writes /.dsp, and +// catalog.json for the browser (the one non-CultNet xeno at this boundary). +// usage: AquaLower --dings : lowers each DingCatalog instrument to /.dsp and writes dings.json +// (instruments with their root frequency, events with their notes) so aqua-feel can mix an event the way DingsPlaybackHost does. +if (args.Length == 2 && args[0] == "--dings") +{ + var dingDir = Path.GetFullPath(args[1]); + Directory.CreateDirectory(dingDir); + var instruments = new List(); + foreach (var ins in DingCatalog.Instruments.Values.OrderBy(i => i.Id, StringComparer.Ordinal)) + { + var export = FaustEmitter.EmitScript(ins.Script, new FaustExportOptions("dings_" + ins.Id.Replace('-', '_'))); + File.WriteAllText(Path.Combine(dingDir, ins.Id + ".dsp"), export.Source); + instruments.Add(new { id = ins.Id, rootFrequency = ins.RootFrequency, dsp = ins.Id + ".dsp", controlPath = "/ding/frequency", warnings = export.Warnings }); + Console.WriteLine($"{ins.Id}: {export.Source.Length} chars, {export.Warnings.Count} warnings"); + } + var events = DingCatalog.Events.Values.OrderBy(e => e.Id, StringComparer.Ordinal) + .Select(e => new { id = e.Id, meaning = e.Meaning, notes = e.Notes.Select(n => new { semitones = n.Semitones, delayMilliseconds = n.DelayMilliseconds, gain = n.Gain }) }); + File.WriteAllText(Path.Combine(dingDir, "dings.json"), JsonSerializer.Serialize(new { instruments, events, noteSeconds = 1.5 }, new JsonSerializerOptions { WriteIndented = true })); + return 0; +} +if (args.Length < 3) { Console.Error.WriteLine("usage: AquaLower = ..."); return 2; } +var outDir = Path.GetFullPath(args[0]); +var root = Path.GetFullPath(args[1]); +Directory.CreateDirectory(outDir); +var entries = new List(); +foreach (var spec in args.Skip(2)) +{ + var eq = spec.IndexOf('='); + var name = spec[..eq]; + var rel = spec[(eq + 1)..]; + var path = Path.Combine(root, rel); + var text = File.ReadAllText(path); + string source; + var warnings = new List(); + if (path.EndsWith(".aqua", StringComparison.Ordinal)) + { + var export = FaustEmitter.EmitScript(text, new FaustExportOptions(name)); + source = export.Source; + warnings.AddRange(export.Warnings); + } + else source = text; + var dspFile = StoreFileName.From(name) + ".dsp"; + File.WriteAllText(Path.Combine(outDir, dspFile), source); + entries.Add(new { name, origin = rel.Replace('\\', '/'), kind = path.EndsWith(".aqua", StringComparison.Ordinal) ? "aqua" : "faust", dsp = dspFile, warnings }); + Console.WriteLine($"{name}: {source.Length} chars from {rel}, {warnings.Count} warnings"); +} +File.WriteAllText(Path.Combine(outDir, "catalog.json"), JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true })); +return 0; diff --git a/tools/FaustNativeRender/FaustNativeRender.csproj b/tools/FaustNativeRender/FaustNativeRender.csproj new file mode 100644 index 0000000..9dd3508 --- /dev/null +++ b/tools/FaustNativeRender/FaustNativeRender.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + true + + + + + diff --git a/tools/FaustNativeRender/Program.cs b/tools/FaustNativeRender/Program.cs new file mode 100644 index 0000000..c533296 --- /dev/null +++ b/tools/FaustNativeRender/Program.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AquaSynth.Faust; + +// Offline render of one Faust source through the daemon's native libfaust path (AquaSynthPatchCompiler, -vec -lv 1) +// to a mono float32 WAV, for diffing against aqua-feel's faustwasm render. 48 kHz, 512-frame blocks, like render.js. +// usage: FaustNativeRender [control=value ...] +const int Rate = 48000, Frames = 512; +if (args.Length < 3) { Console.Error.WriteLine("usage: FaustNativeRender [control=value ...]"); return 2; } +var dspPath = Path.GetFullPath(args[0]); +var outPath = Path.GetFullPath(args[1]); +var total = (int)Math.Round(double.Parse(args[2]) * Rate); +var source = File.ReadAllText(dspPath); +Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + +using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate)); +var name = Path.GetFileNameWithoutExtension(dspPath); +if (!compiler.TryCompileSource(new AquaSynthCompileIdentity(name, name, ""), source, (float)total / Rate, out var patch0, out var cerr)) +{ + Console.Error.WriteLine("COMPILE FAILED: " + cerr); + return 1; +} +using var patch = patch0!; +if (patch.Manifest.OutputCount != 1) { Console.Error.WriteLine($"expected 1 output, patch has {patch.Manifest.OutputCount}"); return 1; } +var ctl = new Dictionary(); +foreach (var spec in args.Skip(3)) +{ + var eq = spec.IndexOf('='); + var key = spec[..eq]; + var hits = patch.ControlPaths.Where(p => p.TrimStart('/') == key.TrimStart('/') || p.EndsWith("/" + key.TrimStart('/'))).ToList(); + if (hits.Count != 1) { Console.Error.WriteLine($"control \"{key}\" matches {hits.Count} of: {string.Join(", ", patch.ControlPaths)}"); return 1; } + var path = hits[0]; + ctl[path] = float.Parse(spec[(eq + 1)..], System.Globalization.CultureInfo.InvariantCulture); +} + +using var stream = patch.CreateStreamingPatch(); +var buf = GC.AllocateArray(Frames, pinned: true); +var ptrs = GC.AllocateArray(1, pinned: true); +unsafe { ptrs[0] = (IntPtr)Unsafe.AsPointer(ref buf[0]); } +var outP = Marshal.UnsafeAddrOfPinnedArrayElement(ptrs, 0); +var pcm = new float[total]; +for (var at = 0; at < total; at += Frames) +{ + stream.ProcessBlock(Frames, IntPtr.Zero, outP, ctl); + Array.Copy(buf, 0, pcm, at, Math.Min(Frames, total - at)); +} +using (var w = new BinaryWriter(File.Create(outPath))) +{ + var db = total * 4; + w.Write("RIFF"u8); w.Write(36 + db); w.Write("WAVEfmt "u8); w.Write(16); w.Write((short)3); w.Write((short)1); + w.Write(Rate); w.Write(Rate * 4); w.Write((short)4); w.Write((short)32); w.Write("data"u8); w.Write(db); + foreach (var s in pcm) w.Write(s); +} +Console.WriteLine($"{outPath} frames={total} controls={ctl.Count}"); +return 0; diff --git a/tools/FaustRender/FaustRender.csproj b/tools/FaustRender/FaustRender.csproj new file mode 100644 index 0000000..9dd3508 --- /dev/null +++ b/tools/FaustRender/FaustRender.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + true + + + + + diff --git a/tools/FaustRender/Program.cs b/tools/FaustRender/Program.cs new file mode 100644 index 0000000..1f0861b --- /dev/null +++ b/tools/FaustRender/Program.cs @@ -0,0 +1,90 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using AquaSynth.Faust; + +// Offline probe: render one Faust source with a swept `rpm` control to mono 16-bit WAVs through the +// native libfaust path, and write manifest.json (hashes, levels, toolchain). It measures peak, rms and +// NaN only; it cannot say how anything sounds. +// usage: FaustRender