IpaGestureExperiment onto StoreFileName (stacked on #11) #14
13 changed files with 767 additions and 11 deletions
316
docs/faust-unity-player-scoping.md
Normal file
316
docs/faust-unity-player-scoping.md
Normal file
|
|
@ -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_<name>.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_<name>.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
|
||||
`<AppContext.BaseDirectory>/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/<name>/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<float>(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.
|
||||
1
spikes/faust-aot-thruster/.gitignore
vendored
Normal file
1
spikes/faust-aot-thruster/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
build/
|
||||
50
spikes/faust-aot-thruster/README.md
Normal file
50
spikes/faust-aot-thruster/README.md
Normal file
|
|
@ -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=<cpu>` 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.
|
||||
71
spikes/faust-aot-thruster/aqs_thruster.cpp
Normal file
71
spikes/faust-aot-thruster/aqs_thruster.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#include "aqs_thruster.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <new>
|
||||
|
||||
#include <faust/dsp/dsp.h>
|
||||
#include <faust/gui/APIUI.h>
|
||||
#include <faust/gui/meta.h>
|
||||
#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"
|
||||
52
spikes/faust-aot-thruster/aqs_thruster.h
Normal file
52
spikes/faust-aot-thruster/aqs_thruster.h
Normal file
|
|
@ -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
|
||||
59
spikes/faust-aot-thruster/bench_thruster.c
Normal file
59
spikes/faust-aot-thruster/bench_thruster.c
Normal file
|
|
@ -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 <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#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 */
|
||||
}
|
||||
27
spikes/faust-aot-thruster/run.sh
Executable file
27
spikes/faust-aot-thruster/run.sh
Executable file
|
|
@ -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)"
|
||||
94
spikes/faust-aot-thruster/test_thruster.c
Normal file
94
spikes/faust-aot-thruster/test_thruster.c
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* Behaviour test for the bounded-parameter boundary. Plain C: proves the ABI is callable without C++. */
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
30
spikes/faust-aot-thruster/thruster.dsp
Normal file
30
spikes/faust-aot-thruster/thruster.dsp
Normal file
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ namespace AquaSynth.Dsl;
|
|||
/// 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 "..".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class StoreFileName
|
||||
{
|
||||
private const int MaxStem = 100;
|
||||
|
|
|
|||
|
|
@ -118,6 +118,36 @@ public sealed class StoreFileNameTests
|
|||
}
|
||||
}
|
||||
|
||||
[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<ArgumentException>(() => 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());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../x")]
|
||||
[InlineData("..\\x")]
|
||||
|
|
@ -130,6 +160,33 @@ public sealed class StoreFileNameTests
|
|||
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<ArgumentException>(() => StoreFileName.Combine(Path.GetTempPath(), ".."));
|
||||
|
|
|
|||
|
|
@ -47,8 +47,9 @@ foreach (var spec in args.Skip(2))
|
|||
warnings.AddRange(export.Warnings);
|
||||
}
|
||||
else source = text;
|
||||
File.WriteAllText(Path.Combine(outDir, name + ".dsp"), source);
|
||||
entries.Add(new { name, origin = rel.Replace('\\', '/'), kind = path.EndsWith(".aqua", StringComparison.Ordinal) ? "aqua" : "faust", dsp = name + ".dsp", warnings });
|
||||
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 }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue