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);