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/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/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