From c5d313e00774ca33d18b9681123dcb1968cfeec8 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 5 Oct 2026 12:16:19 +0200 Subject: [PATCH 1/5] Release build in CI: armhf against glibc 2.31, profile-guided, catalog-checked The plugin catalog lists only plugins that need glibc 2.32 or less (they then load on MPC OS 2.x too); Ubuntu 24.04's cross toolchain made the .so need 2.38 (the C23 __isoc23_sscanf). The workflow, as PolyForce's, builds the device .so in arm32v7/gcc:11-bullseye (GCC 11, glibc 2.31) under QEMU, profile-guided, runs the suite against the objects it is linked from, packages, and checks the zip with the catalog's own catalog_check.py; the sanitizer suite runs on x86. A vX.Y.Z tag publishes a GitHub release (a prerelease for 0.x: the catalog's beta channel). Makefile: ARM_PREFIX (empty for a native ARM build) and ARM_RUN (qemu-arm; empty natively) replace ARM_TOOL and the hard-coded qemu-arm; local defaults are unchanged. The PGO trainer is linked dynamically (static against glibc 2.31, std::thread fails before glibc 2.34). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 66 +++++++++++++++++++++++++++++++++++++ Makefile | 44 ++++++++++++++----------- README.md | 4 ++- docs/BUILDING.md | 34 +++++++++++++++---- 4 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1348df9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +# The release build. The plugin catalog (sd88me/mpc-vst-plugins) lists only plugins that need glibc 2.32 or +# less, so they load on MPC OS 2.x as well as 3.x: the device build runs in arm32v7/gcc:11-bullseye (GCC 11, +# glibc 2.31) under QEMU, as the catalog's own ports do, profile-guided, and the test suite runs against the +# objects the .so is linked from. The sanitizer suite runs on x86. The zip is checked with the catalog's +# checker and kept as an artifact; a vX.Y.Z tag also publishes it as a GitHub release (a prerelease while the +# version is 0.x: the catalog's beta channel). +name: build + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test suite (x86, ASan/UBSan) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: make test + + device: + name: Device build (armhf, glibc 2.31, PGO), package, catalog check + runs-on: ubuntu-24.04 + permissions: + contents: write # the release, on a tag + steps: + - uses: actions/checkout@v4 + # What doesn't depend on the CPU is built here, natively: the generated sources and the skin. The ARM + # container then needs nothing beyond its own compiler (bullseye's package mirrors are going to the archive). + - name: Generated sources and skin + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends python3-pil + make surface skin + - uses: docker/setup-qemu-action@v3 + with: + platforms: arm + - name: Device build and the suite on ARM, in arm32v7/gcc:11-bullseye + run: >- + docker run --rm --platform linux/arm/v7 -v "$PWD:/w" -w /w arm32v7/gcc:11-bullseye + make ARM_PREFIX= ARM_RUN= PGO=1 arm-plugin test-arm-pgo + - name: Package + run: | + case "$GITHUB_REF" in refs/tags/v*) version="PLUGIN_VERSION=${GITHUB_REF_NAME#v}" ;; *) version= ;; esac + make PGO=1 $version plugin-package # the .so above is up to date; a rebuild here would fail loudly (no ARM compiler) + - name: Catalog check + run: >- + python3 third_party/mpc-vst-plugins/tools/catalog_check.py dist/*-mpc-armv7.zip + --catalog --expect-id subforce --expect-repo Devko/SubForce + - uses: actions/upload-artifact@v4 + with: + name: SubForce-mpc-armv7 + path: dist/*-mpc-armv7.zip + if-no-files-found: error + - name: Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: dist/*-mpc-armv7.zip + prerelease: ${{ startsWith(github.ref_name, 'v0.') }} + generate_release_notes: true diff --git a/Makefile b/Makefile index 2358d4a..2954c64 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,17 @@ # SubForce: an analog-style monosynth as a VST2 instrument for MPC OS (Force / MPC standalone). -# Builds on Linux or WSL. Native: g++ (tests, x86 bench). Device: arm-linux-gnueabihf-g++ 13 (the -# Force ships GCC 13's libstdc++, so the .so links it dynamically). +# Builds on Linux or WSL. Native: g++ (tests, x86 bench). Device: arm-linux-gnueabihf-g++ 11 or newer +# (libstdc++ is linked dynamically; MPC OS has it). Releases come from CI, built against glibc 2.31 so +# they load on MPC OS 2.x and 3.x; a newer distribution's cross toolchain needs a newer glibc (3.x only). # Your own settings (FORCE, SSH_KEY, PY) go in local.mk, which git ignores. -include local.mk CXX ?= g++ -ARM_CXX ?= arm-linux-gnueabihf-g++ -# The prefix of strip, readelf and nm for the device build. -ARM_TOOL ?= arm-linux-gnueabihf- +# ARM_PREFIX: the device toolchain's prefix (also for strip, readelf and nm); empty for a native ARM +# build (the release CI builds in arm32v7/gcc:11-bullseye, glibc 2.31, see .github/workflows/build.yml). +# ARM_RUN: how ARM programs run here: qemu-user on x86, nothing on ARM. +ARM_PREFIX ?= arm-linux-gnueabihf- +ARM_CXX ?= $(ARM_PREFIX)g++ +ARM_RUN ?= qemu-arm -L /usr/arm-linux-gnueabihf BUILD := build # FORCE: the device, root@, for bench-device and plugin-install. SSH_KEY: the private key for # it (empty: ssh's own defaults). PY: a Python 3 with Pillow, for skin, preview and plugin-package. @@ -93,7 +97,7 @@ $(BUILD)/plugin_test: $(TESTS) $(wildcard test/*.h) $(SRC) $(HDR) $(GEN) | $(BUI # The same suite cross-compiled for the Force's CPU and run under qemu-user (no sanitizers): # catches 32-bit and ARM-only code paths (the FPSCR flush, NEON float code). test-arm: $(BUILD)/arm/plugin_test - qemu-arm -L /usr/arm-linux-gnueabihf $< + $(ARM_RUN) $< $(BUILD)/arm/plugin_test: $(TESTS) $(wildcard test/*.h) $(SRC) $(HDR) $(GEN) mkdir -p $(BUILD)/arm @@ -135,16 +139,16 @@ ARM_SO_FLAGS = -std=c++17 $(ARM_OPT) -fPIC -fvisibility=hidden -fvisibility-inli ARM_SO_LINK = -shared -Wl,--no-undefined -Wl,-soname,subforce.so -Wl,--version-script=plugin/exports.map ARM_SO_CMD = $(ARM_CXX) $(ARM_SO_FLAGS) $(ARM_SO_LINK) -# Profile-guided: on by default when qemu-arm is installed (as test-arm needs); PGO=0 builds -# without. A copy of the plugin compiled with counters is linked into tools/pgo_train.cpp, -# which plays a spread of patches under qemu-arm; then the .so is compiled from the same sources +# Profile-guided: on by default when ARM programs can run here (qemu-arm installed, as test-arm +# needs, or a native ARM build); PGO=0 builds without. A copy of the plugin compiled with counters +# is linked into tools/pgo_train.cpp, which plays a spread of patches; then the .so is compiled from the same sources # with the same flags plus that profile, which tells the compiler which paths are hot. # -fprofile-partial-training keeps functions the trainer never ran optimised as usual. Objects # keep one path (dir_name.o) in both rounds: GCC names the profile files after it. A missing # profile fails the build instead of quietly building without. PGO ?= auto -QEMU_ARM := $(shell command -v qemu-arm 2>/dev/null) -PGO_ON := $(if $(filter auto,$(PGO)),$(if $(QEMU_ARM),1,0),$(PGO)) +ARM_RUNS := $(if $(strip $(ARM_RUN)),$(shell command -v $(firstword $(ARM_RUN)) 2>/dev/null),native) +PGO_ON := $(if $(filter auto,$(PGO)),$(if $(ARM_RUNS),1,0),$(PGO)) PGO_DIR := $(BUILD)/arm/pgo PGO_PROF := $(abspath $(PGO_DIR)/profile) PGO_OBJ := $(PGO_DIR)/obj @@ -164,8 +168,8 @@ ifeq ($(PGO_ON),1) rm -rf $(PGO_DIR) && mkdir -p $(PGO_OBJ) $(PGO_PROF) for f in $(SRC); do $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate=$(PGO_PROF) -fprofile-update=prefer-atomic \ -c $$f -o $(PGO_O) || exit 1; done - $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate -static tools/pgo_train.cpp $(PGO_OBJ)/*.o -o $(PGO_DIR)/train - SF_DATA_DIR=$(PGO_DIR) SF_PRESET_ROOTS=$(PGO_DIR) qemu-arm $(PGO_DIR)/train + $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-generate tools/pgo_train.cpp $(PGO_OBJ)/*.o -o $(PGO_DIR)/train + SF_DATA_DIR=$(PGO_DIR) SF_PRESET_ROOTS=$(PGO_DIR) $(ARM_RUN) $(PGO_DIR)/train @n=$$(ls $(PGO_PROF)/*.gcda 2>/dev/null | wc -l); [ $$n -eq $(words $(SRC)) ] || \ { echo "PGO: $$n of $(words $(SRC)) profiles written (PGO=0 builds without)"; exit 1; } for f in $(SRC); do $(ARM_CXX) $(ARM_SO_FLAGS) -fprofile-use=$(PGO_PROF) -fprofile-partial-training -Werror=missing-profile \ @@ -174,18 +178,18 @@ ifeq ($(PGO_ON),1) @echo "profile-guided build" else $(ARM_SO_CMD) $(SRC) -o $@ - @echo "plain build (PGO=$(PGO): qemu-arm $(if $(QEMU_ARM),found,not found))" + @echo "plain build (PGO=$(PGO): $(firstword $(ARM_RUN)) $(if $(ARM_RUNS),found,not found))" endif - $(ARM_TOOL)strip --strip-unneeded $@ - @$(ARM_TOOL)readelf -V $@ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 | sed 's/^/needs /' - @n=$$($(ARM_TOOL)nm -D --defined-only $@ | wc -l); echo "exported symbols: $$n"; \ - [ $$n -eq 1 ] || { $(ARM_TOOL)nm -D --defined-only $@; echo "only VSTPluginMain may be exported"; exit 1; } + $(ARM_PREFIX)strip --strip-unneeded $@ + @$(ARM_PREFIX)readelf -V $@ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 | sed 's/^/needs /' + @n=$$($(ARM_PREFIX)nm -D --defined-only $@ | wc -l); echo "exported symbols: $$n"; \ + [ $$n -eq 1 ] || { $(ARM_PREFIX)nm -D --defined-only $@; echo "only VSTPluginMain may be exported"; exit 1; } # The suite against the objects the shipped .so is linked from (profile-guided), under qemu. test-arm-pgo: $(ARM_SO) ifeq ($(PGO_ON),1) $(ARM_CXX) -std=c++17 $(ARM_OPT) -Wno-psabi -pthread $(INC) $(TESTS) $(PGO_OBJ)/*.o -o $(BUILD)/arm/plugin_test_pgo - qemu-arm -L /usr/arm-linux-gnueabihf $(BUILD)/arm/plugin_test_pgo + $(ARM_RUN) $(BUILD)/arm/plugin_test_pgo else @echo "test-arm-pgo: the .so is a plain build here (PGO=$(PGO)); test-arm covers it" endif @@ -198,7 +202,7 @@ $(ARM_SO_STAGES): $(SRC) $(HDR) $(GEN) plugin/exports_stages.map $(ARM_SO_STAMP) mkdir -p $(BUILD)/arm $(ARM_CXX) $(ARM_SO_FLAGS) -shared -Wl,--no-undefined -Wl,-soname,subforce.so \ -Wl,--version-script=plugin/exports_stages.map -DSF_STAGE_TIMING $(SRC) -o $@ - $(ARM_TOOL)strip --strip-unneeded $@ + $(ARM_PREFIX)strip --strip-unneeded $@ arm-bench: $(ARM_BENCH) $(ARM_BENCH): tools/bench.cpp $(HDR) $(GEN) diff --git a/README.md b/README.md index 13b74bd..7232275 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ points into `build/demos-out/` (WAV), the way MPC plays it — no device needed. - An **Akai Force**. Other first-generation (32-bit ARM) MPC OS devices may work but are untested. - **Root SSH access** to the device (for example through MockbaMod). Stock MPC OS has no way to install third-party plugins. -- A recent MPC OS: the plugin needs glibc 2.38, which MPC OS 2.x doesn't have. +- **MPC OS 3.x** for the touchscreen pages. Release builds need glibc 2.31 or less, so MPC OS 2.x + loads them too, but it doesn't draw third-party plugin pages yet. A local build with a newer cross + compiler needs glibc 2.38 (MPC OS 3.x only; see [Building](docs/BUILDING.md#release-builds)). ## Installation diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 8f74e83..afc42a4 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -7,6 +7,7 @@ - [Tests](#tests) - [Benchmarking on the device](#benchmarking-on-the-device) - [Packaging and installing](#packaging-and-installing) +- [Release builds](#release-builds) - [Binary compatibility](#binary-compatibility) --- @@ -18,12 +19,12 @@ SubForce builds on Linux or WSL; it is developed on Ubuntu 24.04. | Tool | Needed for | |---|---| | `g++` 13 | tests, demos and the x86 bench | -| `arm-linux-gnueabihf-g++` 13 | the device build (the Force ships GCC 13's libstdc++, linked dynamically) | +| `arm-linux-gnueabihf-g++` 11 or newer | the device build (libstdc++ is linked dynamically; MPC OS ships it). Release builds come from [CI](#release-builds) | | GNU make ≥ 4.3 | everything | | `python3` | generating the parameter list, layout and C++ headers from `surface/surface.py` | | `gcc` | the skin generator's C renderer | | Python 3 with Pillow (`PY=`) | the skin, the page previews and the release package | -| `qemu-user` (`qemu-arm`) | `test-arm`, `test-arm-pgo` and the profile-guided device build | +| `qemu-user` (`qemu-arm`) | `test-arm`, `test-arm-pgo` and the profile-guided device build (`qemu-user-static` works too; `ARM_RUN` says how ARM programs run) | | `ssh`, `scp` | `bench-device`, `plugin-install` | On Ubuntu 24.04, for example: @@ -77,7 +78,9 @@ changes; it checks the layout and every factory preset before writing anything. | `FORCE` | The device's SSH address, `root@`; required by `bench-device` and `plugin-install` | | `SSH_KEY` | Private key for the device's root login (default: ssh's own keys and config) | | `PY` | Python 3 with Pillow, for `skin`, `preview` and `plugin-package` (default `python3`) | -| `PGO` | `auto` (default): profile-guided when `qemu-arm` is installed; `1`: always; `0`: plain build | +| `PGO` | `auto` (default): profile-guided when ARM programs can run here (`qemu-arm`, or natively); `1`: always; `0`: plain build | +| `ARM_PREFIX` | The device toolchain's prefix (default `arm-linux-gnueabihf-`); empty for a native ARM build | +| `ARM_RUN` | How ARM programs run here (default `qemu-arm -L /usr/arm-linux-gnueabihf`); empty on ARM | | `PLUGIN_VERSION` | Version in the package name | | `BENCH_ARGS` | `sfbench` arguments for `bench-device` (default `-s 3`) | | `PRESET_LUFS` | The loudness `preset-levels` matches the factory presets to | @@ -146,11 +149,30 @@ Packages, copies the package to the device and runs its installer: it **stops MP project first), backs up and edits `MPC.settings`, and starts MPC again. A reinstall keeps the user's presets and favorites/recent lists. +## Release builds + +Releases are built by CI (`.github/workflows/build.yml`) on every push, the way the plugin catalog's +own ports are built: the device build runs in `arm32v7/gcc:11-bullseye` (GCC 11, glibc 2.31) under +QEMU, profile-guided, with the test suite run against the objects the `.so` is linked from; the +sanitizer suite runs on x86. The zip is checked with the catalog's own checker +(`third_party/mpc-vst-plugins/tools/catalog_check.py --catalog`) and kept as the run's artifact. + +Pushing a tag `vX.Y.Z` also publishes it as a GitHub release, a prerelease while the version is 0.x +(the catalog's beta channel). The catalog reads the major version as the parameter list's +compatibility: bump X whenever parameter indices change. + +The same build outside CI, in an ARM environment: `make ARM_PREFIX= ARM_RUN= PGO=1 plugin-package` +(`ARM_PREFIX` empty: the native compiler; `ARM_RUN` empty: ARM programs run directly). + +A local build with a newer distribution's cross compiler (Ubuntu 24.04: glibc 2.39, the C23 +`__isoc23_sscanf`) needs glibc 2.38. That loads on the Force and other MPC OS 3.x devices, fine for +testing, but the catalog refuses it. + ## Binary compatibility - The `.so` exports only `VSTPluginMain` (a linker version script; the build counts every defined dynamic symbol and fails otherwise) and links with `--no-undefined`: an unresolved symbol would otherwise only show as MPC crashing on load. `-fno-gnu-unique` keeps it unloadable. -- It needs glibc 2.38 (`__isoc23_sscanf`, from the C library headers of the toolchain), which is - too new for devices still on MPC OS 2.x. The device build prints the highest glibc version it - needs. +- The [release build](#release-builds) needs glibc 2.31 or less, so it loads on MPC OS 2.x and 3.x. + Built with a newer toolchain it needs that toolchain's glibc (Ubuntu 24.04: 2.38). The device build + prints the highest glibc version it needs. From d2769020c36a585aca361684263472375cc8226f Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 5 Oct 2026 12:50:45 +0200 Subject: [PATCH 2/5] Engine: NEON ladder, no divisions in the oscillators, cheaper control and idle Measured on the Force (Cortex-A17 at 1.8 GHz), profile-guided: Init on one note 3.64% -> 2.67% of the block (p99 4.09% -> 3.00%), the heavy Duo patch 3.70% -> 2.87%, idle 0.38% -> 0.17%. The voice was queueing divisions behind VFP's one unpipelined divider (18 cycles each, one every 14): 10 per ladder tick, 14 in the audio loop's body, one in every oscillator tick. - dsp/simd.h: four floats at a time, NEON on the device and GCC's generic vectors elsewhere (the x86 tests run the same arithmetic): reciprocal estimates refined by Newton-Raphson steps, and four-lane exp2Fast / tanFast / tanhXdX with the scalar versions' polynomials. - The ladder: the four stages' nonlinear gains and 1 / (1 + f t) in vector lanes; the loop solved as y_k = al_k + be_k y3 by a short scalar chain and closed with one division; the stages' inputs, outputs and integrators in one vector step each. 162 -> 103 ns a tick. (An all-NEON prefix scan was slower: on this core a q-register op issues every 2 cycles, and the serial part is quicker in VFP.) - The oscillators' advance() takes the stretch's length instead of dividing for it, its rare divisions are reciprocals, and it is inlined; the shape's tri/pulse checks are flags. - renderRun in two passes: the envelopes and the cutoff coefficients (four at a time) for the run first, then the audio; a still wave knob's shape is worked out once per run. - control(): no powf / log2f (log2Fast, the cutoff note cached), multiplications for the divisions, exp glide by exp2Fast; Multidrive's in and out gains ramp separately. - Silent: only time moves on, on the same control grid (block sizes still never change the sound); the values snap to their targets when a note wakes the voice. Co-Authored-By: Claude Opus 5.5 --- dsp/fastmath.h | 36 +++++++++ dsp/ladder.h | 54 +++++++------ dsp/mod.h | 11 +++ dsp/osc.h | 38 +++++---- dsp/simd.h | 181 +++++++++++++++++++++++++++++++++++++++++++ dsp/synth.cpp | 126 ++++++++++++++++++------------ dsp/synth.h | 37 ++++++--- test/engine_test.cpp | 28 +++++++ 8 files changed, 413 insertions(+), 98 deletions(-) create mode 100644 dsp/simd.h diff --git a/dsp/fastmath.h b/dsp/fastmath.h index 2d824b0..360b6ee 100644 --- a/dsp/fastmath.h +++ b/dsp/fastmath.h @@ -1,6 +1,9 @@ #pragma once // Small, branch-light math for the audio thread: libm's exp2f / tanf cost ~10x as much and the // engine calls these per sample. Error bounds are checked in test/engine_test.cpp. +#include "simd.h" + +#include #include #include @@ -28,6 +31,25 @@ inline float exp2Fast(float x) { return p * scale; } +// log2(x) for normal x > 0: the exponent by bits, a degree-6 polynomial on the mantissa (error +// < 2.3e-6, continuous from one octave to the next). +inline float log2Fast(float x) { + uint32_t bits; + std::memcpy(&bits, &x, sizeof bits); + const float e = static_cast(static_cast(bits >> 23) - 127); + bits = (bits & 0x007FFFFFu) | 0x3F800000u; + float m; + std::memcpy(&m, &bits, sizeof m); + const float u = m - 1.0f; + float p = -2.584141108e-2f; + p = p * u + 1.217977931e-1f; + p = p * u - 2.779052116e-1f; + p = p * u + 4.575491284e-1f; + p = p * u - 7.181452413e-1f; + p = p * u + 1.442544942e+0f; + return e + u * p; +} + // tan(w) for 0 <= w < pi/2 (filter coefficients): an odd polynomial on [0, pi/4], and // tan(w) = 1 / tan(pi/2 - w) above. Relative error < 5e-7 up to 0.4 pi (the ladder's range); // it grows toward the pole, where pi/2 - w loses float precision. @@ -58,10 +80,24 @@ inline float sinCycle(float x) { return sinQuarter(6.283185307f * y); } +// floor() without the library call (ARMv7 has no rounding instruction): through an int32, the +// library only for |x| >= 2^31 or NaN (a host's wild song position). +inline double floorFast(double x) { + if (!(x > -2147483648.0 && x < 2147483648.0)) return std::floor(x); + const double t = static_cast(static_cast(x)); + return t > x ? t - 1.0 : t; +} +inline float floorFast(float x) { + if (!(x > -2147483648.0f && x < 2147483648.0f)) return std::floor(x); + const float t = static_cast(static_cast(x)); + return t > x ? t - 1.0f : t; +} + // MIDI note (semitones, fractional) <-> Hz. inline float noteHz(float note) { return 440.0f * exp2Fast((note - 69.0f) * (1.0f / 12.0f)); } // tanh-like saturator, exactly +-1 from |x| = 3 on (a Pade fit: 1st and 3rd order match tanh). +// A division: in a serial chain VFP's divider is quicker than a reciprocal through NEON. inline float softclip(float x) { x = clampf(x, -3.0f, 3.0f); return x * (27.0f + x * x) / (27.0f + 9.0f * x * x); diff --git a/dsp/ladder.h b/dsp/ladder.h index 7484e49..65adc26 100644 --- a/dsp/ladder.h +++ b/dsp/ladder.h @@ -9,7 +9,16 @@ // - past r = 4 it self-oscillates, the tanh stages holding the amplitude // Run at 2x (dsp/synth.cpp); taps after stage 1..4 give the 6/12/18/24 dB slopes, the feedback // always from stage 4 as in the circuit. +// +// The work is split by its shape (dsp/simd.h; measured on the Force, docs/PERFORMANCE.md): +// - the four stages' nonlinear gains and their 1 / (1 + f t) are independent: four vector +// lanes, NEON reciprocals (ARMv7 has no vector divide, and VFP's one divider is unpipelined: +// the scalar version queued 10 divisions per sample behind each other); +// - each stage's output is affine in the previous one's, y_k = a_k + b_k y_k-1, so the loop is +// solved as y_k = al_k + be_k y3 by a short scalar chain, closed by the last stage with one +// division, and every stage's input and output then come out in one vector step. #include "fastmath.h" +#include "simd.h" namespace sf { @@ -21,30 +30,27 @@ struct Ladder { void tick(float in, float f, float r, float* y) { const float ih = 0.5f * (in + zi); zi = in; - // Nonlinear gains, from the previous sample's states. - const float t0 = tanhXdX(ih - r * s[3]); - const float t1 = tanhXdX(s[0]); - const float t2 = tanhXdX(s[1]); - const float t3 = tanhXdX(s[2]); - const float t4 = tanhXdX(s[3]); - // Each stage's denominator, and the feedback path factored out. - const float g0 = 1.0f / (1.0f + f * t1), g1 = 1.0f / (1.0f + f * t2); - const float g2 = 1.0f / (1.0f + f * t3), g3 = 1.0f / (1.0f + f * t4); - const float f3 = f * t3 * g3, f2 = f * t2 * g2 * f3, f1 = f * t1 * g1 * f2, f0 = f * t0 * g0 * f1; - // The loop, solved for the last stage, then the others from it. - const float y3 = (g3 * s[3] + f3 * g2 * s[2] + f2 * g1 * s[1] + f1 * g0 * s[0] + f0 * in) / (1.0f + r * f0); - const float xx = t0 * (in - r * y3); - const float y0 = t1 * g0 * (s[0] + f * xx); - const float y1 = t2 * g1 * (s[1] + f * y0); - const float y2 = t3 * g2 * (s[2] + f * y1); - s[0] += 2.0f * f * (xx - y0); - s[1] += 2.0f * f * (y0 - y1); - s[2] += 2.0f * f * (y1 - y2); - s[3] += 2.0f * f * (y2 - t4 * y3); - y[0] = y0; - y[1] = y1; - y[2] = y2; - y[3] = y3; + const f4 S = load4(s), F = splat(f), top = f4{0.0f, 0.0f, 0.0f, 1.0f}, low = splat(1.0f) - top; + // Nonlinear gains, from the previous sample's states: t1..t4 of the stages, t0 of the + // input pair (lane 0 of its own vector: as cheap as one lane, and no divider). + const f4 T = tanhXdX4(S); + const float t0 = tanhXdX4(splat(ih - r * s[3]))[0]; + // Stage k solved for its own state: u_k = g_k (s_k + f y_k-1), g_k = 1 / (1 + f t_k+1); it + // passes on y_k = t_k+1 u_k (tanh'd), the last stage its raw u_3: y_k = a_k + b_k y_k-1. + const f4 MG = (T * low + top) * recip4<2>(splat(1.0f) + F * T); // t1 g0, t2 g1, t3 g2, g3 + const f4 A = MG * S, B = MG * F; + // The input pair's output x = t0 (in - r y3), and every stage after it, as al + be y3... + const float alx = t0 * in, bex = -t0 * r; + const float al0 = A[0] + B[0] * alx, be0 = B[0] * bex; + const float al1 = A[1] + B[1] * al0, be1 = B[1] * be0; + const float al2 = A[2] + B[2] * al1, be2 = B[2] * be1; + // ...closed by the last stage, y3 = a3 + b3 y2 (1 - b3 be2 = 1 + r f^4 t0..t3 g0..g3 > 0). + const float y3 = (A[3] + B[3] * al2) / (1.0f - B[3] * be2); + const f4 X = f4{alx, al0, al1, al2} + f4{bex, be0, be1, be2} * splat(y3); // each stage's input + const f4 Y = ext<1>(X, splat(y3)); // ...and output + store4(y, Y); + // Trapezoidal integrators: s += 2 f (input - output), the last stage's output tanh'd. + store4(s, S + (F + F) * (X - Y * (T * top + low))); } void reset() { diff --git a/dsp/mod.h b/dsp/mod.h index f080934..1877bbf 100644 --- a/dsp/mod.h +++ b/dsp/mod.h @@ -19,6 +19,17 @@ enum ModControl : int { MC_ALWAYS, MC_MODWHEEL, MC_AFTERTOUCH, MC_VELOCITY, MC_C constexpr int kNumSyncDivs = 17; constexpr double kSyncBeats[kNumSyncDivs] = {32.0, 16.0, 8.0, 4.0, 2.0, 4.0 / 3.0, 1.0, 2.0 / 3.0, 1.5, 0.5, 1.0 / 3.0, 0.75, 0.25, 1.0 / 6.0, 0.375, 0.125, 1.0 / 12.0}; +// The same as cycles per beat: the audio thread multiplies (a double division costs ~30 cycles). +constexpr double kSyncPerBeat[kNumSyncDivs] = {1.0 / 32.0, 1.0 / 16.0, 0.125, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0 / 3.0, + 2.0, 3.0, 4.0 / 3.0, 4.0, 6.0, 8.0 / 3.0, 8.0, 12.0}; +constexpr bool syncTablesAgree() { + for (int i = 0; i < kNumSyncDivs; ++i) { + const double p = kSyncBeats[i] * kSyncPerBeat[i]; + if (p < 1.0 - 1e-12 || p > 1.0 + 1e-12) return false; + } + return true; +} +static_assert(syncTablesAgree(), "kSyncPerBeat is 1 / kSyncBeats"); // Amount curves (amount a in -1..1 -> a * |a| * range: fine near 0, wide at the ends). constexpr float kModPitchRange = 24.0f; // semitones diff --git a/dsp/osc.h b/dsp/osc.h index d6a04b4..2a9f858 100644 --- a/dsp/osc.h +++ b/dsp/osc.h @@ -19,6 +19,7 @@ struct Shape { float tri = 0.0f, saw = 1.0f, pul = 0.0f; float pw = 0.5f; // pulse width float dc = 0.0f; // pul * (2 pw - 1): the pulse's mean, removed + bool hasTri = false, hasPul = false; // tri / pul != 0, worked out once (a float compare costs ~10 cycles) }; constexpr float kMinPulse = 0.06f; @@ -40,6 +41,8 @@ inline Shape shapeOf(float m) { s.pw = 0.5f - (m - 2.0f) * (0.5f - kMinPulse); } s.dc = s.pul * (2.0f * s.pw - 1.0f); + s.hasTri = s.tri != 0.0f; + s.hasPul = s.pul != 0.0f; return s; } @@ -71,39 +74,40 @@ struct Blep { } }; -// Moves phase t forward by d (d <= dt, the phase per sample) on a stretch that ends `end` -// samples before sample n, while the pulse width moves from pw0 to pw1 across it, correcting -// every discontinuity on the way: the wrap, the triangle's corner at 0.5, and the pulse edge -// wherever phase and width cross -- the phase passing the width (falling), or the width -// sweeping past the phase (a modulated width: either way). Returns true if it wrapped; *wrapX -// is then the wrap's distance to sample n. -inline bool advance(float& t, float d, float dt, float pw0, float pw1, float end, const Shape& s, Blep& b, - float* wrapX) { - const float len = d / dt; // the stretch, in samples +// Moves phase t forward over a stretch of `len` samples (0..1) that ends `end` samples before +// sample n, while the pulse width moves from pw0 to pw1 across it, correcting every +// discontinuity on the way: the wrap, the triangle's corner at 0.5, and the pulse edge wherever +// phase and width cross -- the phase passing the width (falling), or the width sweeping past the +// phase (a modulated width: either way). Returns true if it wrapped; *wrapX is then the wrap's +// distance to sample n. No division on the way: the two it needs, only at a wrap or an edge, are +// reciprocals (dsp/simd.h). +SF_INLINE bool advance(float& t, float len, float dt, float pw0, float pw1, float end, const Shape& s, Blep& b, + float* wrapX) { + const float d = len * dt; // the phase it moves // Something at position u (0..1) along the stretch: its distance to sample n. auto xAt = [&](float u) { return clampf(end + (1.0f - u) * len, 0.0f, 1.0f); }; const float t0 = t, t1 = t + d; const bool wraps = t1 >= 1.0f; - const float uw = wraps ? (1.0f - t0) / d : 1.0f; // where it wraps (d > 0 if it does) - if (s.pul != 0.0f) { + const float uw = wraps ? (1.0f - t0) * recip1<2>(d) : 1.0f; // where it wraps (d > 0 if it does) + if (s.hasPul) { // g = phase - width, linear on each side of the wrap: the edge is where it changes sign. auto edge = [&](float g0, float g1, float u0, float u1) { if ((g0 < 0.0f) == (g1 < 0.0f)) return; - const float u = u0 + (u1 - u0) * g0 / (g0 - g1); + const float u = u0 + (u1 - u0) * g0 * recip1<2>(g0 - g1); // g0 - g1 != 0: the signs differ b.step(g0 < 0.0f ? -2.0f * s.pul : 2.0f * s.pul, xAt(u)); }; const float pwW = pw0 + (pw1 - pw0) * uw; edge(t0 - pw0, (wraps ? 1.0f : t1) - pwW, 0.0f, uw); if (wraps) edge(-pwW, t1 - 1.0f - pw1, uw, 1.0f); } - if (s.tri != 0.0f) { + if (s.hasTri) { if (t0 < 0.5f && t1 >= 0.5f) b.corner(-8.0f * s.tri * dt, xAt((0.5f - t0) / d)); if (t1 >= 1.5f) b.corner(-8.0f * s.tri * dt, xAt((1.5f - t0) / d)); } if (wraps) { const float x = xAt(uw); b.step(2.0f * (s.pul - s.saw), x); // saw falls by 2, the pulse rises back above its width - if (s.tri != 0.0f) b.corner(8.0f * s.tri * dt, x); + if (s.hasTri) b.corner(8.0f * s.tri * dt, x); if (wrapX) *wrapX = x; } t = wraps ? t1 - 1.0f : t1; @@ -119,7 +123,7 @@ struct Osc { // Free running. wrapX: where it wrapped (for sync and the sub), if it did. float tick(float dt, const Shape& s, bool& wrapped, float& wrapX) { b.cur = 0.0f; - wrapped = advance(t, dt, dt, pw, s.pw, 0.0f, s, b, &wrapX); + wrapped = advance(t, 1.0f, dt, pw, s.pw, 0.0f, s, b, &wrapX); pw = s.pw; const float out = b.pending; b.pending = waveValue(s, t) + b.cur; @@ -133,11 +137,11 @@ struct Osc { Shape at = s; // the shape at the reset, its width where the sweep has got to at.pw = pw + (s.pw - pw) * (1.0f - x); at.dc = at.pul * (2.0f * at.pw - 1.0f); - advance(t, (1.0f - x) * dt, dt, pw, at.pw, x, s, b, nullptr); // up to the reset + advance(t, 1.0f - x, dt, pw, at.pw, x, s, b, nullptr); // up to the reset b.step(waveValue(at, 0.0f) - waveValue(at, t), x); b.corner((waveSlope(at, 0.0f) - waveSlope(at, t)) * dt, x); t = 0.0f; - advance(t, x * dt, dt, at.pw, s.pw, 0.0f, s, b, nullptr); // and on from 0 + advance(t, x, dt, at.pw, s.pw, 0.0f, s, b, nullptr); // and on from 0 pw = s.pw; const float out = b.pending; b.pending = waveValue(s, t) + b.cur; diff --git a/dsp/simd.h b/dsp/simd.h new file mode 100644 index 0000000..8c4cfc9 --- /dev/null +++ b/dsp/simd.h @@ -0,0 +1,181 @@ +#pragma once +// Four floats at a time for the per-sample hot spots (the ladder, the cutoff coefficients): NEON +// on the device, GCC's generic vectors elsewhere, so the x86 tests run the same arithmetic lane by +// lane. +// +// Reciprocals: ARMv7 has no vector divide, and VFP's divider isn't pipelined (about 15 cycles a +// division, one at a time; the engine used to queue 14 of them per sample behind each other). +// NEON's estimate (8 bits) refined by Newton-Raphson steps runs in the pipelined vector unit: one +// step is good to about 2e-5, two to about 2e-7. Elsewhere they are plain divisions. +#include + +#if defined(__ARM_NEON) || defined(__ARM_NEON__) +#include +#define SF_NEON 1 +#else +#define SF_NEON 0 +#endif + +// For the few per-sample helpers GCC would otherwise call out of line. +#define SF_INLINE inline __attribute__((always_inline)) + +namespace sf { + +#if SF_NEON +using f4 = float32x4_t; +using i4 = int32x4_t; +#else +typedef float f4 __attribute__((vector_size(16))); +typedef int32_t i4 __attribute__((vector_size(16))); +#endif + +inline f4 splat(float x) { return f4{x, x, x, x}; } + +inline f4 load4(const float* p) { +#if SF_NEON + return vld1q_f32(p); +#else + return f4{p[0], p[1], p[2], p[3]}; +#endif +} + +inline void store4(float* p, f4 v) { +#if SF_NEON + vst1q_f32(p, v); +#else + p[0] = v[0]; + p[1] = v[1]; + p[2] = v[2]; + p[3] = v[3]; +#endif +} + +// a's top 4 - N lanes, then b's bottom N: ext(a, b) = (a[N], .., a[3], b[0], .., b[N - 1]). +template +inline f4 ext(f4 a, f4 b) { +#if SF_NEON + return vextq_f32(a, b, N); +#else + f4 r; + for (int i = 0; i < 4; ++i) r[i] = i + N < 4 ? a[i + N] : b[i + N - 4]; + return r; +#endif +} + +// Lane 3 in every lane. +inline f4 lane3(f4 a) { +#if SF_NEON + return vdupq_lane_f32(vget_high_f32(a), 1); +#else + return splat(a[3]); +#endif +} + +inline f4 min4(f4 a, f4 b) { +#if SF_NEON + return vminq_f32(a, b); +#else + return a < b ? a : b; +#endif +} + +inline f4 max4(f4 a, f4 b) { +#if SF_NEON + return vmaxq_f32(a, b); +#else + return a > b ? a : b; +#endif +} + +// 1 / d for d > 0, Steps Newton-Raphson steps on NEON's estimate. +template +inline f4 recip4(f4 d) { +#if SF_NEON + f4 e = vrecpeq_f32(d); + for (int i = 0; i < Steps; ++i) e = vmulq_f32(e, vrecpsq_f32(d, e)); + return e; +#else + return splat(1.0f) / d; +#endif +} + +// The same for one value, without VFP's divider. +template +inline float recip1(float d) { +#if SF_NEON + const float32x2_t v = vdup_n_f32(d); + float32x2_t e = vrecpe_f32(v); + for (int i = 0; i < Steps; ++i) e = vmul_f32(e, vrecps_f32(v, e)); + return vget_lane_f32(e, 0); +#else + return 1.0f / d; +#endif +} + +// tanh(x) / x: the Pade approximant of fastmath.h's tanhXdX, four at a time. Its denominator is +// at least 945, so one refining step leaves 2e-5 -- far inside the approximant's own error. +inline f4 tanhXdX4(f4 x) { + const f4 a = x * x; + const f4 num = (a + splat(105.0f)) * a + splat(945.0f); + const f4 den = (splat(15.0f) * a + splat(420.0f)) * a + splat(945.0f); + return num * recip4<1>(den); +} + +// 2^x, fastmath.h's exp2Fast four at a time (the same polynomial, the exponent by bits). +inline f4 exp2Fast4(f4 x) { + x = min4(max4(x, splat(-126.0f)), splat(126.0f)); +#if SF_NEON + const uint32x4_t neg = vcltq_f32(x, vdupq_n_f32(0.0f)); + const f4 xt = vbslq_f32(neg, vsubq_f32(x, vdupq_n_f32(1.0f)), x); + const i4 i = vcvtq_s32_f32(xt); // toward zero: floor, or one below + const f4 f = vsubq_f32(x, vcvtq_f32_s32(i)); +#else + const f4 xt = x < splat(0.0f) ? x - splat(1.0f) : x; + const i4 i = __builtin_convertvector(xt, i4); + const f4 f = x - __builtin_convertvector(i, f4); +#endif + f4 p = splat(2.120030258e-4f); + p = p * f + splat(1.258059288e-3f); + p = p * f + splat(9.664113633e-3f); + p = p * f + splat(5.549038202e-2f); + p = p * f + splat(2.402283251e-1f); + p = p * f + splat(6.931471229e-1f); + p = p * f + splat(1.0f); +#if SF_NEON + const f4 scale = vreinterpretq_f32_s32(vshlq_n_s32(vaddq_s32(i, vdupq_n_s32(127)), 23)); +#else + const i4 bits = (i + 127) << 23; + f4 scale; + __builtin_memcpy(&scale, &bits, sizeof scale); +#endif + return p * scale; +} + +// tan(w) for 0 <= w < pi/2: fastmath.h's tanFast four at a time. +inline f4 tanFast4(f4 w) { + const f4 quarter = splat(0.785398163f), half = splat(1.570796327f); +#if SF_NEON + const uint32x4_t upper = vcgtq_f32(w, quarter); + const f4 x = vbslq_f32(upper, vsubq_f32(half, w), w); +#else + const auto upper = w > quarter; + const f4 x = upper ? half - w : w; +#endif + const f4 t = x * x; + f4 p = splat(8.657055907e-3f); + p = p * t + splat(4.348253831e-3f); + p = p * t + splat(2.366562374e-2f); + p = p * t + splat(5.362507701e-2f); + p = p * t + splat(1.333622932e-1f); + p = p * t + splat(3.333325386e-1f); + p = p * t + splat(1.0f); + const f4 r = p * x; + const f4 inv = recip4<2>(max4(r, splat(1e-12f))); +#if SF_NEON + return vbslq_f32(upper, inv, r); +#else + return upper ? inv : r; +#endif +} + +} // namespace sf diff --git a/dsp/synth.cpp b/dsp/synth.cpp index 0c35f77..79266b7 100644 --- a/dsp/synth.cpp +++ b/dsp/synth.cpp @@ -33,7 +33,9 @@ inline float noteOf(float hz) { return 69.0f + 12.0f * std::log2(std::max(hz, 1. } // namespace Synth::Synth(float sampleRate) - : sr_(sampleRate), osr_(sampleRate * kOversample), invOsr_(1.0f / (sampleRate * kOversample)) { + : sr_(sampleRate), osr_(sampleRate * kOversample), invOsr_(1.0f / (sampleRate * kOversample)), + invSr_(1.0f / sampleRate), driftK_(1.0f / (0.6f * sampleRate)) { + setTransport(120.0, 0.0, false, false); setPatch(Patch{}); } @@ -41,6 +43,7 @@ void Synth::setPatch(const Patch& p) { const bool repick = havePatch_ && (p.keyMode != patch_.keyMode || p.priority != patch_.priority); patch_ = p; havePatch_ = true; + cutNote_ = noteOf(p.cutoffHz); // A bus whose rate the other one modulates runs free even when synced (a locked phase can't // speed up and slow down). for (int b = 0; b < 2; ++b) @@ -169,8 +172,9 @@ void Synth::trigger(int vel) { } if (patch_.kbReset) resetPending_ = true; for (float& d : noteDrift_) d = randBipolar(rng_) * patch_.drift * 3.0f; // cents - if (silent_) { + if (silent_) { // waking: nothing to glide from, every value starts where it belongs silent_ = false; + snapAll_ = true; aePrev_ = 0.0f; fPrevValid_ = false; } @@ -186,9 +190,9 @@ void Synth::glideTo(Glide& g, float target, bool glide) { snap_ = true; return; } - if (patch_.glideType == GT_EXP) { // RC: 99% of the way at glideTime + if (patch_.glideType == GT_EXP) { // RC: 99% of the way at glideTime (e^-4.6) g.exp = true; - g.k = 1.0f - std::exp(-4.6f / (patch_.glideTime * sr_)); + g.lk = -4.6f / (patch_.glideTime * sr_ * 0.693147181f); g.left = 1; return; } @@ -202,7 +206,7 @@ void Synth::glideTo(Glide& g, float target, bool glide) { void Synth::stepGlide(Glide& g, int n) const { if (g.left <= 0) return; if (g.exp) { - g.pitch += (g.target - g.pitch) * (1.0f - std::pow(1.0f - g.k, static_cast(n))); + g.pitch += (g.target - g.pitch) * (1.0f - exp2Fast(g.lk * static_cast(n))); if (std::fabs(g.target - g.pitch) < 1e-3f) { g.pitch = g.target; g.left = 0; @@ -254,6 +258,7 @@ void Synth::aftertouch(float amount) { pressure_ = clampf(amount, 0.0f, 1.0f); } void Synth::setTransport(double bpm, double beats, bool playing, bool beatsValid) { bpm_ = bpm > 1.0 ? bpm : 120.0; + beatsPerSample_ = bpm_ / 60.0 / static_cast(sr_); playing_ = playing; beatsValid_ = beatsValid; if (playing && beatsValid) beats_ = beats; // stopped: keep counting on our own @@ -272,22 +277,22 @@ Synth::Info Synth::info() const { // One bus's source over n samples: its value at the end of them (-1..1; the filter envelope 0..1). float Synth::busValue(Bus& b, const ModPatch& m, int n) { - const double div = kSyncBeats[std::clamp(m.div, 0, kNumSyncDivs - 1)]; + const double perBeat = kSyncPerBeat[std::clamp(m.div, 0, kNumSyncDivs - 1)]; // cycles auto newCycle = [&] { b.held = randBipolar(rng_); b.from = b.to; b.to = randBipolar(rng_); }; if (m.sync && !m.retrig && playing_ && beatsValid_ && !b.rateModulated) { // locked to MPC's bar position - double ph = beats_ / div; - ph -= std::floor(ph); + double ph = beats_ * perBeat; + ph -= floorFast(ph); if (static_cast(ph) < b.phase) newCycle(); b.phase = static_cast(ph); } else { - const float hz = (m.sync ? static_cast(bpm_ / 60.0 / div) : m.rateHz) * b.rateMul; - float next = b.phase + hz * static_cast(n) / sr_; + const float hz = (m.sync ? static_cast(bpm_ * (1.0 / 60.0) * perBeat) : m.rateHz) * b.rateMul; + float next = b.phase + hz * static_cast(n) * invSr_; if (next >= 1.0f) { - next -= std::floor(next); + next -= floorFast(next); newCycle(); } b.phase = next; @@ -311,7 +316,7 @@ float Synth::driftStep(Drift& d, int n) { d.target = randBipolar(rng_); d.left = static_cast(sr_ * (0.3f + 1.2f * static_cast(xorshift(rng_) >> 8) / 16777216.0f)); } - d.v += (d.target - d.v) * std::min(1.0f, static_cast(n) / (0.6f * sr_)); + d.v += (d.target - d.v) * std::min(1.0f, static_cast(n) * driftK_); return d.v; } @@ -320,7 +325,7 @@ float Synth::driftStep(Drift& d, int n) { // Time passes for everything that moves at the control rate: the song position, glides, the // busses' sources (their values kept in busOut_), drift. n may be 0 (only the values are read). void Synth::advance(int n) { - beats_ += static_cast(n) * bpm_ / 60.0 / static_cast(sr_); + beats_ += static_cast(n) * beatsPerSample_; stepGlide(glide_[0], n); stepGlide(glide_[1], n); fVel_ = 1.0f - patch_.fenv.vel + patch_.fenv.vel * vel_; @@ -383,28 +388,28 @@ void Synth::control() { clampf(noteHz(pitch[1]) * invOsr_, 1e-7f, 0.45f), clampf(p.osc[0].wave + waveMod[0], 0.0f, 1.0f), clampf(p.osc[1].wave + waveMod[1], 0.0f, 1.0f), - noteOf(p.cutoffHz) + p.keyTrack * (glide_[0].pitch - 60.0f) + cutMod + driftCut, + cutNote_ + p.keyTrack * (glide_[0].pitch - 60.0f) + cutMod + driftCut, envSemis(p.envAmount) * fVel_, kResMax * clampf(p.res + resMod, 0.0f, 1.0f), kInGain * driveGain, - 1.0f + 2.0f * drive, + 0.5f + drive, // Multidrive's second stage: into the clipper... + 2.0f / (1.0f + 2.0f * drive), // ...and out of it taper(p.mixOsc1 + lvlMod[0]), taper(p.mixSub + lvlMod[1]), taper(p.mixOsc2 + lvlMod[2]), taper(p.mixNoise + lvlMod[3]), taper(p.mixFeedback + lvlMod[4]) * kFeedbackGain, - kOutGain * aVel * volMul / std::pow(driveGain, 0.35f), + kOutGain * aVel * volMul * exp2Fast(-0.35f * log2Fast(driveGain)), // / driveGain^0.35 exp2Fast(clampf(p.volumeDb, -60.0f, 12.0f) * (1.0f / 6.0206f)) * (p.volumeDb <= -59.5f ? 0.0f : 1.0f), }; - Ramp* ramps[] = {&dt_[0], &dt_[1], &wave_[0], &wave_[1], &cut_, &egAmt_, &res_, &inGain_, &post_, - &lvl_[0], &lvl_[1], &lvl_[2], &lvl_[3], &lvl_[4], &vca_, &vol_}; - static_assert(sizeof target / sizeof target[0] == sizeof ramps / sizeof ramps[0], "a target per ramp"); + const auto rs = ramps(); + static_assert(sizeof target / sizeof target[0] == std::tuple_size::value, "a target per ramp"); constexpr float invN = 1.0f / static_cast(kControl); - for (size_t i = 0; i < sizeof ramps / sizeof ramps[0]; ++i) { + for (size_t i = 0; i < rs.size(); ++i) { // A pitch jump (a new note without glide) lands at once; everything else glides. The // very first step starts every value where it belongs. - if (snapAll_ || (snap_ && i < 2)) ramps[i]->snap(target[i]); - else ramps[i]->to(target[i], invN); + if (snapAll_ || (snap_ && i < 2)) rs[i]->snap(target[i]); + else rs[i]->to(target[i], invN); } // Another slope mid-note: crossfade the ladder's taps instead of switching. const int slope = std::clamp(p.slope, 0, 3); @@ -444,7 +449,10 @@ void Synth::render(float* outL, float* outR, int n) { int pos = 0; while (pos < n) { if (ctlLeft_ <= 0) { - control(); + // Silent: only time moves on (glides, busses, drift), on the same grid, so block sizes + // never change the sound; the values are worked out when a note wakes the voice. + if (silent_) catchUp(); + else control(); ctlLeft_ = kControl; clock.lap(STG_CONTROL); } @@ -464,49 +472,71 @@ void Synth::render(float* outL, float* outR, int n) { } // Nothing sounds: the oscillators keep running (free phase), the filter envelope keeps its -// release, the control values arrive where they were going. +// release, the control values arrive where they were going (and snap to new ones on waking). void Synth::renderSilent(float* out, int n) { for (int i = 0; i < n; ++i) out[i] = 0.0f; for (int o = 0; o < 2; ++o) { const float d = dt_[o].v * static_cast(kOversample * n); - osc_[o].t += d - std::floor(osc_[o].t + d); + osc_[o].t += d - floorFast(osc_[o].t + d); } - for (Ramp* r : {&dt_[0], &dt_[1], &wave_[0], &wave_[1], &cut_, &egAmt_, &res_, &inGain_, &post_, - &lvl_[0], &lvl_[1], &lvl_[2], &lvl_[3], &lvl_[4], &vca_, &vol_}) - r->skip(n); - for (int i = 0; i < n; ++i) fenv_.tick(fc_); + for (Ramp* r : ramps()) r->arrive(); + if (fenv_.stage != E_IDLE) + for (int i = 0; i < n; ++i) fenv_.tick(fc_); } +// n <= kControl samples of the voice, in two passes. First, per base sample: the envelopes, the +// cutoff they move and its coefficient (four at a time: dsp/simd.h), the VCA. Then the audio, at +// the 2x rate, with the coefficients ready. void Synth::renderRun(float* out, int n) { + // [0]: the previous base sample's, for the first half-step; [1..n]: this run's. + float fq[kControl + 1], ae[kControl + 1], cs[kControl]; + for (int i = 0; i < n; ++i) { + const float fe = fenv_.tick(fc_); + ae[i + 1] = aenv_.tick(ac_); + cs[i] = cut_.next() + egAmt_.next() * fe; + } + for (int i = n; i < kControl; ++i) cs[i] = cs[n - 1]; // the last vector's spare lanes + { + const f4 lo = splat(kMinCutoff), hi = splat(kMaxCutoff * osr_), w = splat(kPi * invOsr_); + for (int i = 0; i < n; i += 4) { // tan(pi fc / 2fs), fc = 440 * 2^((note - 69) / 12) clamped + const f4 hz = splat(440.0f) * exp2Fast4((load4(cs + i) - splat(69.0f)) * splat(1.0f / 12.0f)); + float f[4]; + store4(f, tanFast4(min4(max4(hz, lo), hi) * w)); + for (int k = 0; k < 4 && i + k < n; ++k) fq[1 + i + k] = f[k]; + } + } + if (!fPrevValid_) { // just woken: no stale cutoff from before the silence + fPrev_ = fq[1]; + fPrevValid_ = true; + } + fq[0] = fPrev_; + ae[0] = aePrev_; + const int tap = tap_, tapFrom = tapFrom_; const int subOct = patch_.subOctave == SO_TWO ? 2 : 1; const bool sync = patch_.sync; const float nk = noiseK_, nc = noiseComp_; const float fbR = 1.0f - 2.0f * kPi * 15.0f * invOsr_; // feedback DC blocker, 15 Hz - const float dcR = 1.0f - 2.0f * kPi * 5.0f / sr_; // output DC blocker, 5 Hz - const float maxHz = kMaxCutoff * osr_; + const float dcR = 1.0f - 2.0f * kPi * 5.0f * invSr_; // output DC blocker, 5 Hz + // A still wave knob (no sweep, no bus on it): its shape once, not every sample. + const bool morph1 = wave_[0].d != 0.0f, morph2 = wave_[1].d != 0.0f; + Shape s1 = shapeOf(wave_[0].v), s2 = shapeOf(wave_[1].v); float peak = peak_; for (int i = 0; i < n; ++i) { const float dt1 = dt_[0].next(), dt2 = dt_[1].next(); - const Shape s1 = shapeOf(wave_[0].next()), s2 = shapeOf(wave_[1].next()); + if (morph1) s1 = shapeOf(wave_[0].next()); + if (morph2) s2 = shapeOf(wave_[1].next()); const float l0 = lvl_[0].next(), l1 = lvl_[1].next(), l2 = lvl_[2].next(), l3 = lvl_[3].next() * nc, l4 = lvl_[4].next(); - const float fe = fenv_.tick(fc_); - const float ae = aenv_.tick(ac_); - const float cs = cut_.next() + egAmt_.next() * fe; - const float f = tanFast(kPi * clampf(noteHz(cs), kMinCutoff, maxHz) * invOsr_); - const float r = res_.next(), gain = inGain_.next(), post = post_.next(), vca = vca_.next(); - const float postIn = 0.5f * post, postOut = 2.0f / post; - const float xf = xfLeft_ > 0 ? static_cast(xfLeft_--) * (1.0f / kTapFade) : 0.0f; // of the old tap - if (!fPrevValid_) { // just woken: no stale cutoff from before the silence - fPrev_ = f; - fPrevValid_ = true; - } + const float r = res_.next(), gain = inGain_.next(), postIn = postIn_.next(), postOut = postOut_.next(), + vca = vca_.next(); + const bool fade = xfLeft_ > 0; // a slope change, crossfading the taps + const float xf = fade ? static_cast(xfLeft_--) * (1.0f / kTapFade) : 0.0f; // of the old tap float hi[kOversample]; for (int k = 0; k < kOversample; ++k) { // Cutoff and VCA move per base sample; the first half-step goes halfway. - const float fk = k == 0 ? 0.5f * (fPrev_ + f) : f; - const float amp = (k == 0 ? 0.5f * (aePrev_ + ae) : ae) * vca; + const float fk = k == 0 ? 0.5f * (fq[i] + fq[i + 1]) : fq[i + 1]; + const float amp = (k == 0 ? 0.5f * (ae[i] + ae[i + 1]) : ae[i + 1]) * vca; float v1, v2, vs; if (resetPending_) { // keyboard reset: every oscillator restarts its cycle here v1 = osc_[0].tickReset(dt1, s1, 1.0f); @@ -528,15 +558,13 @@ void Synth::renderRun(float* out, int n) { // The circuit's own noise floor (-80 dB): what starts a self-oscillating filter with // every source down, as on the hardware. ladder_.tick(mix * gain + kThermal * white, fk, r, y); - const float yt = xf > 0.0f ? y[tap] + (y[tapFrom] - y[tap]) * xf : y[tap]; + const float yt = fade ? y[tap] + (y[tapFrom] - y[tap]) * xf : y[tap]; const float v = softclip(yt * postIn) * postOut * amp; // Multidrive's second stage, VCA fbY1_ = v - fbX1_ + fbR * fbY1_; // the feedback path: DC blocked, back into the mixer next sample fbX1_ = v; fbIn_ = fbY1_; hi[k] = v; } - fPrev_ = f; - aePrev_ = ae; const float lo = dec_.process(hi[0], hi[1]); dcY1_ = lo - dcX1_ + dcR * dcY1_; dcX1_ = lo; @@ -544,6 +572,8 @@ void Synth::renderRun(float* out, int n) { peak = std::max(peak, std::fabs(o)); out[i] = o; } + fPrev_ = fq[n]; + aePrev_ = ae[n]; peak_ = peak; } diff --git a/dsp/synth.h b/dsp/synth.h index 587b923..a88e592 100644 --- a/dsp/synth.h +++ b/dsp/synth.h @@ -22,6 +22,8 @@ #include "mod.h" #include "osc.h" +#include +#include #include namespace sf { @@ -136,17 +138,27 @@ class Synth { private: struct Ramp { // a control value gliding across a control step - float v = 0.0f, d = 0.0f; - void to(float target, float invN) { d = (target - v) * invN; } - void snap(float target) { v = target; d = 0.0f; } + float v = 0.0f, d = 0.0f, t = 0.0f; // t: where it is going + void to(float target, float invN) { + // Arrived (the same target again, only float rounding apart): exactly there, still -- + // a still wave shape is worked out once per run instead of every sample. + if (target == t && std::fabs(target - v) <= 1e-6f * std::fabs(target)) { + v = target; + d = 0.0f; + } else { + d = (target - v) * invN; + } + t = target; + } + void snap(float target) { v = t = target; d = 0.0f; } float next() { return v += d; } - void skip(int n) { v += d * static_cast(n); } + void arrive() { snap(t); } }; struct Glide { float pitch = 60.0f, from = 60.0f, target = 60.0f; int len = 0, left = 0; // linear glides, in samples; left 0 = arrived bool exp = false; - float k = 1.0f; // Exp: one-pole step per sample + float lk = 0.0f; // Exp: log2 of what is left of the distance after one sample }; struct Bus { float phase = 0.0f; @@ -176,8 +188,15 @@ class Synth { void goSilent(); float driftStep(Drift& d, int n); float kbScale(float kb) const; - - float sr_, osr_, invOsr_; + // Every control value, in the order control() works out their targets. + std::array ramps() { + return {&dt_[0], &dt_[1], &wave_[0], &wave_[1], &cut_, &egAmt_, &res_, &inGain_, &postIn_, &postOut_, + &lvl_[0], &lvl_[1], &lvl_[2], &lvl_[3], &lvl_[4], &vca_, &vol_}; + } + + float sr_, osr_, invOsr_, invSr_; + float driftK_; // a drift's one-pole step per sample (0.6 s) + float cutNote_ = 0.0f; // the cutoff knob, as a note Patch patch_; bool havePatch_ = false; @@ -218,14 +237,14 @@ class Synth { int sinceCtl_ = 0; // samples since the last one bool snap_ = false; // pitch jumps: no glide from the old values bool snapAll_ = true; // the first step: every value starts at its target - Ramp dt_[2], wave_[2], cut_, egAmt_, res_, inGain_, post_, lvl_[5], vca_, vol_; + Ramp dt_[2], wave_[2], cut_, egAmt_, res_, inGain_, postIn_, postOut_, lvl_[5], vca_, vol_; Bus bus_[2]; float busOut_[2] = {}; // the busses' sources at the last control-rate time float driftNow_[3] = {}; Drift drift_[3]; // osc 1, osc 2, cutoff float noteDrift_[2] = {}; // per-note offsets, cents float noiseK_ = 1.0f, noiseComp_ = 1.0f; - double beats_ = 0.0, bpm_ = 120.0; + double beats_ = 0.0, bpm_ = 120.0, beatsPerSample_ = 120.0 / 60.0 / 44100.0; bool playing_ = false, beatsValid_ = false; }; diff --git a/test/engine_test.cpp b/test/engine_test.cpp index fc46728..238192d 100644 --- a/test/engine_test.cpp +++ b/test/engine_test.cpp @@ -65,6 +65,34 @@ void testMath() { std::printf(" exp2Fast %.1e, tanFast %.1e, tanhXdX %.1e to 5, %.2f to 12\n", e2, et, eh, eh12); CHECK(e2 < 2e-7 && et < 5e-7 && eh < 0.01 && eh12 < 0.2); CHECK(sf::softclip(10.0f) == 1.0f && sf::softclip(-10.0f) == -1.0f && std::fabs(sf::softclip(0.1f) - std::tanh(0.1f)) < 1e-4); + // log2Fast over many octaves; the reciprocals (NEON's estimate + Newton-Raphson on the device). + double el = 0, er1 = 0, er2 = 0; + for (int i = 0; i <= 4000; ++i) { + const float x = std::pow(2.0f, -10.0f + static_cast(i) * 0.005f); + el = std::max(el, std::fabs(sf::log2Fast(x) - std::log2(static_cast(x)))); + er2 = std::max(er2, std::fabs(static_cast(sf::recip1<2>(x)) * x - 1.0)); + er1 = std::max(er1, std::fabs(static_cast(sf::recip4<1>(sf::splat(x))[2]) * x - 1.0)); + } + std::printf(" log2Fast %.1e, reciprocal %.1e (two steps), %.1e (one)\n", el, er2, er1); + CHECK(el < 3e-6 && er2 < 1e-6 && er1 < 5e-5); + // The four-lane versions against the scalar ones (the same polynomials). + double d2 = 0, dt = 0, dh = 0; + for (int i = 0; i < 1000; i += 4) { + float x[4], w[4], h[4]; + for (int k = 0; k < 4; ++k) { + x[k] = -20.0f + 0.04f * static_cast(i + k); + w[k] = 1.5f * static_cast(i + k) / 1000.0f; + h[k] = -12.0f + 0.024f * static_cast(i + k); + } + const sf::f4 e = sf::exp2Fast4(sf::load4(x)), t = sf::tanFast4(sf::load4(w)), th = sf::tanhXdX4(sf::load4(h)); + for (int k = 0; k < 4; ++k) { + d2 = std::max(d2, static_cast(std::fabs(e[k] / sf::exp2Fast(x[k]) - 1.0f))); + if (w[k] > 0.0f) dt = std::max(dt, static_cast(std::fabs(t[k] / sf::tanFast(w[k]) - 1.0f))); + dh = std::max(dh, static_cast(std::fabs(th[k] / sf::tanhXdX(h[k]) - 1.0f))); + } + } + std::printf(" four lanes against one: exp2 %.1e, tan %.1e, tanh(x)/x %.1e\n", d2, dt, dh); + CHECK(d2 < 1e-6 && dt < 1e-6 && dh < 5e-5); } void testDecimator() { From 57064bbad606fe0f2cd93e0c78499a2272549cf0 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 5 Oct 2026 13:04:15 +0200 Subject: [PATCH 3/5] Fixes from the review: Force taps and turns, Multi trigger, MIDI, FP mode, seeds On the Force every value MPC sets is the value it last read back plus its step (PolyForce's first device run found both of these there; SubForce had the same code): - Buttons fired only on a rising edge and waited for a release. A tap toggles the read-back, and a button always reads back 0, so no release ever comes: INIT, SAVE, RANDOM, PREV/NEXT and the browser's arrows worked once per instance. Each 1 is now a press. - The preset stepper measured a turn's detents from MPC's previous value, which the last detent had moved by one item (under kQuant): a Q-Link or wheel turn moved one preset and stalled. Each detent is now measured from the plugin's own value. The test host taps and turns the way the Force does (the old surface code fails 10 checks), and its clock can run a turn's events a few ms apart (sft::Turn). Engine: Multi trigger restarted the envelopes when a key was released back to one still held (a trill attacked twice, a Duo pair's other key re-attacked on every lift); it retriggers on keys struck now. Plugin: - Poly aftertouch (what the pads send) on a sounding key counts as pressure; CC 121 resets bend, wheel, pressure and pedal. - Flush-to-zero covers the engine only: the song position, automation and display callbacks run in MPC's own FP mode. - Every instance seeds its own random numbers (noise, drift, S&H, RANDOM): two layered instances played the same noise. SF_FIXED_SEED for the tests and demos. - Diagnostics: with /tmp/subforce.trace present every setParameter is logged to /tmp/subforce.log (PolyForce's plugin/trace.*). - sfbench checks dlsym; plugin-package warns when the .so needs glibc over the catalog's 2.32. Tests: S&H must change between cycles and hold within one, Smooth must move and glide monotonically (both passed for a stuck source). Docs: the Force's input rules, diagnostics, libstdc++, the trigger and pedal behaviour, the new MIDI messages. Co-Authored-By: Claude Opus 5.5 --- Makefile | 5 +++ docs/ARCHITECTURE.md | 16 +++++++-- docs/BUILDING.md | 23 ++++++++++++- docs/USER_GUIDE.md | 12 ++++--- dsp/synth.cpp | 19 ++++++++++- dsp/synth.h | 3 ++ plugin/plugin.cpp | 81 ++++++++++++++++++++++++++++++++------------ plugin/surface.cpp | 30 ++++++++-------- plugin/surface.h | 10 ++++-- plugin/trace.cpp | 73 +++++++++++++++++++++++++++++++++++++++ plugin/trace.h | 12 +++++++ test/host.h | 24 ++++++++++--- test/keys_test.cpp | 23 +++++++++++++ test/mod_test.cpp | 39 +++++++++++++++++++++ test/plugin_test.cpp | 28 ++++++++++----- test/preset_test.cpp | 35 +++++++++++++++++++ tools/bench.cpp | 6 +++- tools/demos.cpp | 2 ++ 18 files changed, 379 insertions(+), 62 deletions(-) create mode 100644 plugin/trace.cpp create mode 100644 plugin/trace.h diff --git a/Makefile b/Makefile index 2954c64..cb58249 100644 --- a/Makefile +++ b/Makefile @@ -228,6 +228,11 @@ plugin-package: $(ARM_SO) $(SKIN) @# Everything shipped runs under BusyBox on the device: a CR in a script breaks it there. @# grep: 1 = no CR found (good); 0 = found one; 2 = it couldn't read the scripts. @grep -l "$$(printf '\r')" $(MV)/tools/release/*; r=$$?; [ $$r -eq 1 ] || { echo "error: CRLF in a shipped script, or no scripts"; exit 1; } + @# A newer toolchain's glibc: fine on the Force (MPC OS 3.x), not for a release (the catalog's limit is 2.32). + @g=$$(readelf -V $(ARM_SO) 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \ + if [ -n "$$g" ] && [ "$$(printf '%s\n2.32\n' "$$g" | sort -V | tail -1)" != 2.32 ]; then \ + echo "warning: this .so needs glibc $$g: it loads on MPC OS 3.x only, and the plugin catalog refuses it."; \ + echo " Release packages come from CI (glibc 2.31, docs/BUILDING.md#release-builds)."; fi $(PY) $(MV)/tools/release.py --so $(ARM_SO) --skin "$(SKIN_DIR)" --entry $(SURF_OUT)/pluginlist-entry.xml \ --version $(PLUGIN_VERSION) --repo Devko/SubForce --license MIT \ --about "SubForce analog-style monosynth (preview): 2 oscillators with continuous wave shape and hard sync, sub oscillator, noise, feedback, a 4-pole ladder filter (6-24 dB) with Multidrive, 2 DAHDSR envelopes, 2 mod busses, glide, Duo mode." \ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea134ba..719ed57 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,8 @@ flowchart LR |---|---| | `dsp/synth.*` | The engine: keys (priority, trigger, Duo, pedal), glide, the mod busses, drift, the control step, the 2x render loop, idling | | `dsp/osc.h` | The morphing oscillator, hard sync and the sub, band-limited with polyBLEP / polyBLAMP | -| `dsp/ladder.h` | The nonlinear transistor ladder (zero-delay feedback, four taps) | +| `dsp/ladder.h` | The nonlinear transistor ladder (zero-delay feedback, four taps), its four stages in vector lanes | +| `dsp/simd.h` | Four-float vectors: NEON on the Force, GCC's generic vectors on x86 (the tests run the same arithmetic); NEON reciprocals instead of divisions | | `dsp/halfband.h` | The 2x decimator (polyphase IIR halfband); `tools/halfband_design.py` designs it | | `dsp/env.h` | The DAHDSR envelope | | `dsp/mod.h` | The busses' sources, destinations, controls and synced rates | @@ -115,8 +116,17 @@ PolyForce's rules, device-proven in RackForce before it: meter's at most twice a second. - A value MPC sends is recorded as what MPC shows only after the plugin has acted on it, so a preset load in between never has the old value pushed back. -- Steppers move exactly one item per event, whatever delta MPC sends; MPC echoing the plugin's own - value back is ignored. A tile's release echo (~0.7 s after a tap) is ignored. +- A Force sends every Q-Link detent, data-wheel click or drag event as the value it last read back + plus its step (sd88me/mpc-vst-plugins `docs/NOTES.md`, "Input probe", MPC OS 3.9.1). Steppers + measure each event from the plugin's own value and move exactly one item, whatever the delta + (Q-Link detent 1/128, data wheel 0.01, touch drag, fast spins); MPC echoing the plugin's own value + back is ignored. +- A tap on a button toggles the value MPC read back, and a button always reads back 0, so every + tap arrives as a 1 with no release in between: each 1 is a press, and the plugin springs the + button back to 0 from the next block. (Both rules come from PolyForce's first device run: a + rising-edge button and a stepper measuring from MPC's previous value each worked once.) +- MPC sends a second toggle about 0.7 s after a tap on a tile; a revert within 1 s is ignored. +- To see what MPC sends on a device, see [diagnostics](BUILDING.md#diagnostics-on-the-device). ## Parameters and saved state diff --git a/docs/BUILDING.md b/docs/BUILDING.md index afc42a4..f1e6133 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -8,6 +8,7 @@ - [Benchmarking on the device](#benchmarking-on-the-device) - [Packaging and installing](#packaging-and-installing) - [Release builds](#release-builds) +- [Diagnostics on the device](#diagnostics-on-the-device) - [Binary compatibility](#binary-compatibility) --- @@ -118,6 +119,8 @@ sanitizers): it catches 32-bit and ARM-only paths. |---|---| | `SF_PRESET_ROOTS` | The preset roots (colon-separated list) | | `SF_DATA_DIR` | Where favorites and recent lists are kept (empty: nothing is saved) | +| `SF_FIXED_SEED` | Set: every instance the same random numbers (noise, drift, S&H, RANDOM); the tests and demos set it | +| `SF_TRACE_DIR` | Where the [diagnostics](#diagnostics-on-the-device) flag and log are (default `/tmp`) | ## Benchmarking on the device @@ -168,6 +171,20 @@ A local build with a newer distribution's cross compiler (Ubuntu 24.04: glibc 2. `__isoc23_sscanf`) needs glibc 2.38. That loads on the Force and other MPC OS 3.x devices, fine for testing, but the catalog refuses it. +## Diagnostics on the device + +To see what MPC sends when a control is touched, turned or tapped, create the flag file while MPC +runs (no restart): + +```sh +ssh root@ touch /tmp/subforce.trace +``` + +Within a second every SubForce instance appends one line per `setParameter` to `/tmp/subforce.log`: +the time, the instance, the parameter, the value MPC sent, the value it had read back before and the +plugin's value and text after. Remove the flag file to stop. The log stops growing at 2 MB; `/tmp` +is cleared when the device restarts. + ## Binary compatibility - The `.so` exports only `VSTPluginMain` (a linker version script; the build counts every defined @@ -175,4 +192,8 @@ testing, but the catalog refuses it. otherwise only show as MPC crashing on load. `-fno-gnu-unique` keeps it unloadable. - The [release build](#release-builds) needs glibc 2.31 or less, so it loads on MPC OS 2.x and 3.x. Built with a newer toolchain it needs that toolchain's glibc (Ubuntu 24.04: 2.38). The device build - prints the highest glibc version it needs. + prints the highest glibc version it needs, and `plugin-package` warns when it is over the + catalog's 2.32. +- libstdc++ is linked dynamically. GCC 11's (the release build's) needs `GLIBCXX_3.4.29` (the + floating-point `from_chars` the saved state is parsed with): MPC OS 3.x ships GCC 13's. The + catalog's checker reads only the glibc version. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index cb20147..a1b27c6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -95,14 +95,17 @@ exponentially (the times are to −60 dB). - **Priority**: which key sounds when several are down — *Last*, *Low* or *High*. Releasing a key returns to the next one by the same rule. In Duo, oscillator 1 takes the first by priority, oscillator 2 the second. -- **Trigger**: *Multi* restarts the envelopes on every new note, and on a sounding key struck again - (held by the pedal, or repeated); *Single* only when all keys were up (legato phrases glide on one +- **Trigger**: *Multi* restarts the envelopes on every key struck, a sounding key struck again too + (held by the pedal, or repeated); releasing a key hands the oscillators back to one still held + without a new attack. *Single* only when the gate was closed (legato phrases glide on one envelope). - **Glide**: *Off*, *Always*, or *Legato* (only between overlapping keys). **Type**: *Rate* (the time per octave: big leaps take longer), *Time* (every glide takes the same time), *Exp* (exponential, fast then slow, like an RC). **Osc**: which oscillators glide. - **Bend Up / Down**: the pitch bend range, 0–24 semitones each way. -- The sustain pedal keeps the last note sounding until it lifts. +- The sustain pedal keeps the last note sounding until it lifts. While it holds the gate open, the + next key plays legato, as on the hardware: Single doesn't restart the envelopes and Legato glide + glides. ## The mod busses @@ -150,6 +153,7 @@ on their demo phrase). Pick them on the BROWSE tab or step through them on KEYS. | Pitch bend | ± the bend ranges | | CC 1 (mod wheel) | a bus's depth, if its control is Mod Wheel | | Channel pressure | a bus's depth, if its control is Aftertouch | +| Poly aftertouch | the same, from the sounding key's own pressure (what MPC's pads send) | | CC 64 | sustain pedal (re-striking a held key retriggers in Multi) | | | A key is down or up: two note-ons for the same key and then one note-off end it, as on a keyboard | -| CC 120 / 123 | all sound off / all notes off | +| CC 120 / 121 / 123 | all sound off / reset all controllers (bend, wheel, pressure, pedal) / all notes off | diff --git a/dsp/synth.cpp b/dsp/synth.cpp index 79266b7..27b9342 100644 --- a/dsp/synth.cpp +++ b/dsp/synth.cpp @@ -152,7 +152,9 @@ void Synth::update(int pressed) { glideTo(glide_[0], static_cast(n1), glide && patch_.glideDest != OD_OSC2); glideTo(glide_[1], static_cast(n2), glide && patch_.glideDest != OD_OSC1); gate_ = true; - if (fresh || patch_.trigger == TR_MULTI) trigger(vel); + // Multi retriggers on a key struck, not on a release that hands the oscillators back to a + // key still held (a trill would attack twice, a Duo pair's other key on every lift). + if (fresh || (patch_.trigger == TR_MULTI && pressed >= 0)) trigger(vel); ctlLeft_ = 0; // the new pitch from the next sample on } @@ -256,6 +258,21 @@ void Synth::controller(int cc, int value) { void Synth::aftertouch(float amount) { pressure_ = clampf(amount, 0.0f, 1.0f); } +void Synth::polyAftertouch(int note, float amount) { + if (nHeld_ > 0 && (note == note1_ || (patch_.keyMode == KM_DUO && note == note2_))) aftertouch(amount); +} + +void Synth::resetControllers() { + bend_ = wheel_ = pressure_ = 0.0f; + sustain(false); +} + +void Synth::seed(uint32_t s) { + rng_ = s ? s : 1u; + noiseRng_ = s * 0x9E3779B9u + 0x2545F491u; + if (!noiseRng_) noiseRng_ = 1u; +} + void Synth::setTransport(double bpm, double beats, bool playing, bool beatsValid) { bpm_ = bpm > 1.0 ? bpm : 120.0; beatsPerSample_ = bpm_ / 60.0 / static_cast(sr_); diff --git a/dsp/synth.h b/dsp/synth.h index a88e592..9c67c16 100644 --- a/dsp/synth.h +++ b/dsp/synth.h @@ -117,6 +117,9 @@ class Synth { void reset(); // silence now: CC 120, suspend, transport stop void controller(int cc, int value); // 1 mod wheel (0..127) void aftertouch(float amount); // channel pressure, 0..1 + void polyAftertouch(int note, float amount); // a sounding key's own pressure counts as the channel's + void resetControllers(); // CC 121: bend, wheel, pressure and pedal back to rest + void seed(uint32_t s); // the random numbers (noise, drift, S&H): per instance // MPC's tempo and position (quarter notes), once per block before render(). void setTransport(double bpm, double beats, bool playing, bool beatsValid); diff --git a/plugin/plugin.cpp b/plugin/plugin.cpp index 562cd81..6e907ba 100644 --- a/plugin/plugin.cpp +++ b/plugin/plugin.cpp @@ -15,6 +15,7 @@ #include "patch_map.h" #include "state.h" #include "surface.h" +#include "trace.h" #include "../dsp/synth.h" #include "../dsp/stages.h" @@ -41,7 +42,8 @@ constexpr float kSampleRate = 44100.0f; // MPC OS always runs 44.1 kHz constexpr int kScratch = 512; // Denormals (the tails of decaying filters and envelopes) are slow on the VFP unit. Flush -// them to zero for our block only and hand MPC's worker back its own FP mode. +// them to zero for our own arithmetic only: MPC's callbacks (the song position, automation, +// display updates) run in its worker's own FP mode, which is handed back afterwards. class FlushDenormals { public: FlushDenormals() { @@ -134,7 +136,14 @@ float getParameter(AEffect* e, int32_t i) { void setParameter(AEffect* e, int32_t i, float v) { try { - self(e)->surface.set(i, v); + Surface& s = self(e)->surface; + const bool traced = i >= 0 && i < P_COUNT && tracing(); + const float before = traced ? s.get(i) : 0.0f; // what MPC last read back + s.set(i, v); + if (traced) + trace("%p set %3d %-18s %.4f read %.4f -> %.4f \"%s\"", static_cast(e), static_cast(i), + PARAM_INFO[i].key, static_cast(v), static_cast(before), static_cast(s.get(i)), + s.display(i).c_str()); } catch (...) { } } @@ -152,10 +161,12 @@ void handleMidi(Plugin* p, const RawMidi& m) { case 0xB0: if (m.d1 == 64) s.sustain(m.d2 >= 64); else if (m.d1 == 120) s.reset(); + else if (m.d1 == 121) s.resetControllers(); else if (m.d1 == 123) s.allNotesOff(); else s.controller(m.d1, m.d2); // the mod wheel (a bus's depth) break; case 0xD0: s.aftertouch(static_cast(m.d1) / 127.0f); break; + case 0xA0: s.polyAftertouch(m.d1, static_cast(m.d2) / 127.0f); break; // pads send it per key case 0xE0: // -1..1; the patch's bend-up/down ranges turn it into semitones s.pitchBend(static_cast((m.d2 << 7 | m.d1) - 8192) / 8192.0f); break; @@ -163,7 +174,28 @@ void handleMidi(Plugin* p, const RawMidi& m) { } } -void runBlock(Plugin* p, float* L, float* R, int n) { +struct Transport { + double bpm = 120.0, beats = 0.0; + bool playing = false, valid = false; +}; + +// MPC's tempo and bar position: synced mod busses follow them. A host callback, so in MPC's own +// FP mode. +Transport readTransport(Plugin* p) { + Transport tr; + if (!p->master) return tr; + const intptr_t r = p->master(&p->fx, vst::audioMasterGetTime, 0, vst::kVstTempoValid | vst::kVstPpqPosValid, nullptr, 0.0f); + if (const VstTimeInfo* t = reinterpret_cast(r)) { + // A NaN or absurd value would stall or spin a synced bus: ignore it. + if ((t->flags & vst::kVstTempoValid) && std::isfinite(t->tempo) && t->tempo >= 1.0 && t->tempo <= 1000.0) tr.bpm = t->tempo; + tr.valid = (t->flags & vst::kVstPpqPosValid) != 0 && std::isfinite(t->ppqPos) && std::fabs(t->ppqPos) < 1e9; + tr.beats = tr.valid ? t->ppqPos + p->ppqOffset * tr.bpm / 60.0 / static_cast(kSampleRate) : 0.0; + tr.playing = (t->flags & vst::kVstTransportPlaying) != 0; + } + return tr; +} + +void runBlock(Plugin* p, float* L, float* R, int n, const Transport& tr) { // The sound only changes when a parameter does: then rebuild the patch and hand it to the // engine. Looked at only when something was written since the last look. const uint32_t writes = p->surface.writes(); // before the snapshot: a later write shows next block @@ -179,21 +211,7 @@ void runBlock(Plugin* p, float* L, float* R, int n) { } } if (p->panic.exchange(false)) p->synth.reset(); - // MPC's tempo and bar position: synced mod busses follow them. - double bpm = 120.0, beats = 0.0; - bool playing = false, valid = false; - if (p->master) { - const intptr_t r = p->master(&p->fx, vst::audioMasterGetTime, 0, - vst::kVstTempoValid | vst::kVstPpqPosValid, nullptr, 0.0f); - if (const VstTimeInfo* t = reinterpret_cast(r)) { - // A NaN or absurd value would stall or spin a synced bus: ignore it. - if ((t->flags & vst::kVstTempoValid) && std::isfinite(t->tempo) && t->tempo >= 1.0 && t->tempo <= 1000.0) bpm = t->tempo; - valid = (t->flags & vst::kVstPpqPosValid) != 0 && std::isfinite(t->ppqPos) && std::fabs(t->ppqPos) < 1e9; - beats = valid ? t->ppqPos + p->ppqOffset * bpm / 60.0 / static_cast(kSampleRate) : 0.0; - playing = (t->flags & vst::kVstTransportPlaying) != 0; - } - } - p->synth.setTransport(bpm, beats, playing, valid); + p->synth.setTransport(tr.bpm, tr.beats, tr.playing, tr.valid); // Events in time order (insertion sort: no allocation; MPC already sends them sorted), // each one applied at its own sample. @@ -247,10 +265,11 @@ void hostUpdate(void* ctx) { void processReplacing(AEffect* e, float** /*in*/, float** out, int32_t n) { if (!out || !out[0] || !out[1] || n <= 0) return; Plugin* p = self(e); - FlushDenormals ftz; const double t0 = threadCpuUs(); try { - runBlock(p, out[0], out[1], n); + const Transport tr = readTransport(p); + FlushDenormals ftz; + runBlock(p, out[0], out[1], n, tr); } catch (...) { // nothing may throw into MPC: an escaping exception ends the whole process std::memset(out[0], 0, sizeof(float) * static_cast(n)); std::memset(out[1], 0, sizeof(float) * static_cast(n)); @@ -302,7 +321,7 @@ void onMidi(Plugin* p, const VstEvents* evs) { const uint8_t st = static_cast(me->midiData[0]); const uint8_t d1 = static_cast(me->midiData[1] & 0x7F), d2 = static_cast(me->midiData[2] & 0x7F); const int type = st & 0xF0; - const bool ends = type == 0x80 || (type == 0x90 && d2 == 0) || (type == 0xB0 && (d1 == 64 || d1 == 120 || d1 == 123)); + const bool ends = type == 0x80 || (type == 0x90 && d2 == 0) || (type == 0xB0 && (d1 == 64 || d1 == 120 || d1 == 121 || d1 == 123)); if (!ends && p->nMidi >= kMaxMidi - kEndReserve) continue; p->midi[p->nMidi++] = {me->deltaFrames, st, d1, d2}; } @@ -364,9 +383,29 @@ intptr_t dispatcher(AEffect* e, int32_t op, int32_t idx, intptr_t val, void* ptr } } +// Every instance its own random numbers (noise, drift, S&H, RANDOM): two layered instances +// would otherwise play the same noise, +6 dB instead of +3. SF_FIXED_SEED: the same every time +// (the tests compare instances sample for sample). +uint32_t instanceSeed(const void* p) { + static std::atomic count{0}; + const char* fixed = std::getenv("SF_FIXED_SEED"); + if (fixed && *fixed) return 0x9E3779B9u; + timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + uint32_t h = static_cast(reinterpret_cast(p)) ^ static_cast(ts.tv_nsec) ^ + (count.fetch_add(1) * 0x9E3779B9u); + h ^= h >> 16; + h *= 0x7FEB352Du; + h ^= h >> 15; + return h ? h : 1u; +} + AEffect* createPlugin(audioMasterCallback master) { Plugin* p = new Plugin(); p->master = master; + const uint32_t seed = instanceSeed(p); + p->synth.seed(seed); + p->surface.seed(seed * 0x2545F491u + 1u); AEffect* e = &p->fx; std::memset(e, 0, sizeof(*e)); diff --git a/plugin/surface.cpp b/plugin/surface.cpp index b59f03c..c3e6997 100644 --- a/plugin/surface.cpp +++ b/plugin/surface.cpp @@ -106,11 +106,12 @@ void Surface::set(int i, float n) { return; } if (k == Kind::Button) { - shown(); - const bool down = n > 0.5f; - const bool rising = down && !held_[i]; - held_[i] = down; - if (rising) { + // A tap toggles the value MPC last read back, and a button always reads back 0 (it springs + // back), so every tap arrives as a 1 with no release before the next: each 1 is a press. + // (Waiting for a release, as RackForce's rising-edge rule did, left a button dead after its + // first press on the Force: PolyForce's first device run.) A 0, a release or our own + // spring-back, does nothing. + if (n > 0.5f) { release_[i] = true; changes_.fetch_add(1, std::memory_order_release); // after the flag: notify must see it apply(i, n); @@ -177,7 +178,7 @@ void Surface::apply(int i, float n) { const auto L = presetLibrary().listing(); const int items = static_cast(L->items.size()); const int cur = stepperCur(i, *L, presetKey()); - const int pick = stepItem(i, n, stepperRange(items), items, cur); + const int pick = stepItem(n, stepperRange(items), items, cur); if (pick != cur && pick < items) loadPreset(L->items[static_cast(pick)].key); } break; @@ -244,18 +245,15 @@ int Surface::stepIndex(int i, float n, int count, int cur) { return clampi(cur + move, 0, count); } -int Surface::stepItem(int i, float n, int normRange, int items, int cur) { +int Surface::stepItem(float n, int normRange, int items, int cur) { if (items < 1 || normRange < 1) return 0; cur = clampi(cur, 0, items - 1); - const long long now = nowMs(); - const bool gesture = lastSentMs_[i] > 0 && now - lastSentMs_[i] < kGestureMs && lastN_[i] >= 0.0f; - const float mpcPrev = lastN_[i]; - lastSentMs_[i] = now; - lastN_[i] = n; - const float ours = static_cast(cur) / normRange; - if (!gesture && std::fabs(n - ours) <= kQuant) return cur; // our own value back - const float delta = n - (gesture ? mpcPrev : ours); - if (std::fabs(delta) <= kQuant) return cur; + // The Force sends the value it last read back (ours) plus its step, within one turn too + // (sd88me/mpc-vst-plugins docs/NOTES.md, "Input probe"). So the direction is n against ours. + // Against MPC's previous value, each detent after the first differed by the item the last one + // moved (1/1023, under kQuant), and a turn stalled after one item (PolyForce's device run). + const float delta = n - static_cast(cur) / normRange; + if (std::fabs(delta) <= kQuant) return cur; // our own value back // One item per event, whatever the size of MPC's step (Q-Link detent 1/128, wheel click // 0.01, a drag ~0.04, a fast spin 1-3 detents): a long list must never jump. return clampi(cur + (delta > 0 ? 1 : -1), 0, items - 1); diff --git a/plugin/surface.h b/plugin/surface.h index 12af57e..b81eede 100644 --- a/plugin/surface.h +++ b/plugin/surface.h @@ -15,7 +15,10 @@ // is 1/128 of the range, a data-wheel click 0.01, a touch drag about 0.04, and a tile tap // sends a release echo ~0.7 s later. So every stepped parameter (choices, small whole // numbers, the preset stepper) moves exactly one step per event in MPC's direction, and the -// plugin pushes the snapped value back (stepIndex / stepItem below, RackForce's rules). +// plugin pushes the snapped value back (stepIndex / stepItem below, RackForce's rules). The +// read-back is the base of every value, within a turn too: a stepper measures each detent from +// its own value, never from MPC's previous one. A button tap toggles its read-back, and a button +// always reads back 0 (it springs back): every 1 is a press, and no release ever follows. #include "library.h" #include "param_ids.h" @@ -65,6 +68,8 @@ class Surface { // Moves on every value write: unchanged since a snapshot, the snapshot is still current. uint32_t writes() const { return changes_.load(std::memory_order_acquire); } + void seed(uint32_t s) { rng_ = s ? s : 1u; } // RANDOM and RND's random numbers + static int kFine; // ranges with this many steps or more follow MPC's value // Milliseconds for telling gestures apart (null: the steady clock). Tests set one that only // moves when they say, so stepping doesn't depend on how fast the machine is. @@ -77,7 +82,7 @@ class Surface { }; void apply(int i, float n); int stepIndex(int i, float n, int count, int cur); - int stepItem(int i, float n, int normRange, int items, int cur); // one item per event + int stepItem(float n, int normRange, int items, int cur); // one item per event bool toggleBounce(int i, bool on); void browserAction(int i); // FAVORITES, RECENT, then the library's categories (from L, the listing in use). @@ -88,7 +93,6 @@ class Surface { int stepperCur(int i, const Listing& L, const std::string& key) const; // where a stepper stands // UI-thread-only stepping state (RackForce's). - bool held_[P_COUNT] = {}; long long lastSentMs_[P_COUNT] = {}; float lastN_[P_COUNT] = {}; long long toggleMs_[P_COUNT] = {}; diff --git a/plugin/trace.cpp b/plugin/trace.cpp new file mode 100644 index 0000000..bdade4a --- /dev/null +++ b/plugin/trace.cpp @@ -0,0 +1,73 @@ +#include "trace.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sf { +namespace { + +constexpr long kMaxBytes = 2L << 20; +constexpr long long kCheckMs = 1000; + +// One log for every instance (they share the process); UI threads only. +std::mutex mtx; +FILE* logFile = nullptr; +bool on = false; +long long checkedMs = 0; +bool checked = false; + +std::string traceDir() { + const char* d = std::getenv("SF_TRACE_DIR"); + return d && *d ? d : "/tmp"; +} + +long long steadyMs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +} // namespace + +bool tracing() { + std::lock_guard lk(mtx); + const long long now = steadyMs(); + if (!checked || now - checkedMs >= kCheckMs) { + checked = true; + checkedMs = now; + struct stat st{}; + on = ::stat((traceDir() + "/subforce.trace").c_str(), &st) == 0; + if (!on && logFile) { + std::fclose(logFile); + logFile = nullptr; + } + } + return on; +} + +void trace(const char* fmt, ...) { + if (!tracing()) return; + std::lock_guard lk(mtx); + if (!logFile) logFile = std::fopen((traceDir() + "/subforce.log").c_str(), "a"); + if (!logFile || std::ftell(logFile) > kMaxBytes) return; + // Wall-clock time, to line up with MPC's own log (journalctl -u acvs). + const auto t = std::chrono::system_clock::now(); + const std::time_t s = std::chrono::system_clock::to_time_t(t); + const int ms = static_cast(std::chrono::duration_cast(t.time_since_epoch()).count() % 1000); + std::tm tm{}; + localtime_r(&s, &tm); + std::fprintf(logFile, "%02d:%02d:%02d.%03d ", tm.tm_hour, tm.tm_min, tm.tm_sec, ms); + va_list ap; + va_start(ap, fmt); + std::vfprintf(logFile, fmt, ap); + va_end(ap); + std::fputc('\n', logFile); + std::fflush(logFile); +} + +} // namespace sf diff --git a/plugin/trace.h b/plugin/trace.h new file mode 100644 index 0000000..3703b8b --- /dev/null +++ b/plugin/trace.h @@ -0,0 +1,12 @@ +#pragma once +// Device diagnostics (PolyForce's): every value MPC sets and what the plugin made of it, appended +// to /subforce.log while /subforce.trace exists (dir = /tmp, or SF_TRACE_DIR). Create +// the flag file to start and remove it to stop, with MPC running: it is looked for at most once +// a second. The log stops growing at 2 MB. File I/O: never from the audio thread. + +namespace sf { + +bool tracing(); +void trace(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + +} // namespace sf diff --git a/test/host.h b/test/host.h index 90df616..bbe9aa0 100644 --- a/test/host.h +++ b/test/host.h @@ -52,10 +52,14 @@ struct Host { float get(int id) { return e->getParameter(e, id); } float value(int id) { return sf::paramValue(id, get(id)); } - // A momentary button: press, then release (as MPC sends a tap). - void press(int id) { - e->setParameter(e, id, 1.0f); - e->setParameter(e, id, 0.0f); + // A tap on a button, as a Force sends it: MPC toggles the value it last read back. A button + // reads back 0 (it springs back), so a tap is a single 1, never followed by a release. + void press(int id) { e->setParameter(e, id, get(id) > 0.5f ? 0.0f : 1.0f); } + // One Q-Link detent (dir +1 / -1), as a Force sends it: the value MPC last read back plus + // 1/128 of the range, rounded to 1/1000 (MPC OS 3.9.1, measured by sd88me/mpc-vst-plugins, + // docs/NOTES.md "Input probe"). Several in a Turn are one gesture. + void detent(int id, int dir) { + e->setParameter(e, id, std::round((get(id) + static_cast(dir) / 128.0f) * 1000.0f) / 1000.0f); } void midi(uint8_t st, uint8_t d1, uint8_t d2, int delta = 0) { @@ -134,6 +138,18 @@ struct Host { std::string fixtureDir(); // per-run temp folder (removed at exit) +// The surface's clock moves this far per host event (plugin_test.cpp): a second, so separate +// events never read as one gesture, whatever the machine's speed. +extern long long g_msPerEvent; +// Within a Turn, events come a few ms apart, as a Q-Link turn's detents or a tile's release echo. +struct Turn { + explicit Turn(long long ms = 5) : was(g_msPerEvent) { g_msPerEvent = ms; } + ~Turn() { g_msPerEvent = was; } + Turn(const Turn&) = delete; + Turn& operator=(const Turn&) = delete; + long long was; +}; + void engineTests(); // engine_test.cpp: oscillators, decimator, ladder, envelopes, stability void keyTests(); // keys_test.cpp: priority, trigger, glide, duo, pedal void modTests(); // mod_test.cpp: the mod busses diff --git a/test/keys_test.cpp b/test/keys_test.cpp index d22d8a5..1e4b995 100644 --- a/test/keys_test.cpp +++ b/test/keys_test.cpp @@ -84,6 +84,29 @@ void testPriority() { r.s.noteOn(48, 100); r.run(1323); CHECK(r.s.info().ampEnv > 0.6f); + // Back to a key still held when the newer one lifts: the oscillators move, the envelopes + // don't start again (Multi retriggers on key presses, as on the hardware). + r.run(22050); + r.s.noteOn(55, 100); + r.run(22050); + r.s.noteOff(55); + r.run(1323); + CHECK(r.s.info().note1 == 48 && std::fabs(r.s.info().ampEnv - 0.3f) < 0.01f); + } + // Duo: one key of a pair lifting doesn't re-attack the other. + { + Patch p; + p.keyMode = sf::KM_DUO; + p.aenv.attack = 0.05f; + p.aenv.decay = 0.05f; + p.aenv.sustain = 0.3f; + Rig r(p); + r.s.noteOn(48, 100); + r.s.noteOn(55, 100); + r.run(22050); + r.s.noteOff(55); + r.run(1323); + CHECK(r.s.info().note2 == 48 && std::fabs(r.s.info().ampEnv - 0.3f) < 0.01f); } // The pedal holds the last note until it lifts. { diff --git a/test/mod_test.cpp b/test/mod_test.cpp index 4a38db4..480a14d 100644 --- a/test/mod_test.cpp +++ b/test/mod_test.cpp @@ -122,6 +122,45 @@ void testSources() { bool inside = true; for (double x : v) inside = inside && std::fabs(x) <= 1.02; CHECK(inside); + // Eight cycles at 4 Hz: a new random value every cycle (not one stuck value), held across + // it (S&H) or glided to monotonically within it (Smooth). + auto cycles = [](int src, double* early, double* mid, double* late) { + Patch p = base(); + p.mod[0].src = src; + p.mod[0].rateHz = 4.0f; + p.mod[0].pitch = kOneSemi; + p.mod[0].retrig = true; + Synth s; + s.setPatch(p); + s.noteOn(57, 100); + const auto x = render(s, 2 * 44100); + for (int c = 0; c < 8; ++c) { + const double t = 0.25 * c; + early[c] = semis(x, t + 0.03, t + 0.08); + mid[c] = semis(x, t + 0.10, t + 0.15); + late[c] = semis(x, t + 0.17, t + 0.22); + } + }; + double early[8], mid[8], late[8]; + cycles(sf::MS_SAMPLE_HOLD, early, mid, late); + double lo = 9.0, hi = -9.0, held = 0.0; + for (int c = 0; c < 8; ++c) { + lo = std::min(lo, mid[c]); + hi = std::max(hi, mid[c]); + held = std::max(held, std::fabs(late[c] - early[c])); + } + std::printf(" S&H over 8 cycles: %.2f .. %.2f st, held within %.3f st\n", lo, hi, held); + CHECK(hi - lo > 0.5 && held < 0.02 && lo >= -1.02 && hi <= 1.02); + cycles(sf::MS_SMOOTH, early, mid, late); + lo = 9.0, hi = -9.0; + bool monotonic = true; + for (int c = 0; c < 8; ++c) { + lo = std::min(lo, mid[c]); + hi = std::max(hi, mid[c]); + monotonic = monotonic && (mid[c] - early[c]) * (late[c] - mid[c]) >= -1e-4; + } + std::printf(" Smooth over 8 cycles: %.2f .. %.2f st\n", lo, hi); + CHECK(hi - lo > 0.3 && monotonic && lo >= -1.02 && hi <= 1.02); } void testControl() { diff --git a/test/plugin_test.cpp b/test/plugin_test.cpp index a7e73f1..e17ee8f 100644 --- a/test/plugin_test.cpp +++ b/test/plugin_test.cpp @@ -269,8 +269,10 @@ void testMidiMapping() { h.midi(0xB0, 64, 0); h.run(kBlocksPerSec / 4); CHECK(h.run(2) == 0.0f); - // CC 1 and channel pressure reach a bus set to them: a square on pitch, one semitone. - for (int ctl : {sf::MC_MODWHEEL, sf::MC_AFTERTOUCH}) { + // CC 1, channel pressure and the sounding key's own pressure (poly aftertouch: what pads + // send) reach a bus set to them: a square on pitch, one semitone. Another key's doesn't. + for (int how = 0; how < 4; ++how) { + const int ctl = how == 0 ? sf::MC_MODWHEEL : sf::MC_AFTERTOUCH; Host m; m.bare(); m.set(sf::P_M1_SRC, sf::MS_SQUARE); @@ -281,11 +283,13 @@ void testMidiMapping() { m.on(57); m.run(kBlocksPerSec / 2); const double off = pitchHz(m.L); - if (ctl == sf::MC_MODWHEEL) m.midi(0xB0, 1, 127); - else m.midi(0xD0, 127, 0); + if (how == 0) m.midi(0xB0, 1, 127); + else if (how == 1) m.midi(0xD0, 127, 0); + else m.midi(0xA0, how == 2 ? 57 : 60, 127); m.run(4); m.run(kBlocksPerSec / 2); - CHECK(std::fabs(off - 220.0) < 0.5 && std::fabs(pitchHz(m.L) / 220.0 - std::pow(2.0, 1.0 / 12.0)) < 0.003); + const double up = how == 3 ? 1.0 : std::pow(2.0, 1.0 / 12.0); + CHECK(std::fabs(off - 220.0) < 0.5 && std::fabs(pitchHz(m.L) / 220.0 - up) < 0.003); } // Bend down by its own range. h.set(sf::P_BEND_DN, 12); @@ -295,6 +299,11 @@ void testMidiMapping() { h.run(8); h.run(kBlocksPerSec / 2); CHECK(std::fabs(pitchHz(h.L) - 110.0) < 0.4); + // CC 121 (reset all controllers): the bend is back at rest. + h.midi(0xB0, 121, 0); + h.run(8); + h.run(kBlocksPerSec / 2); + CHECK(std::fabs(pitchHz(h.L) - 220.0) < 0.5); } void testStress() { @@ -335,6 +344,8 @@ void testStress() { } // namespace +long long sft::g_msPerEvent = 1000; + int main() { using namespace sft; const std::string root = fixtureDir(); @@ -342,11 +353,12 @@ int main() { std::filesystem::create_directories(root + "/data"); setenv("SF_PRESET_ROOTS", (root + "/presets").c_str(), 1); setenv("SF_DATA_DIR", (root + "/data").c_str(), 1); - // Every host event a second apart: stepping never mistakes two of them for one turn, whatever - // the machine's speed (and qemu's), so every run steps the same way. + setenv("SF_FIXED_SEED", "1", 1); // every instance the same random numbers: they are compared + // Host events a second apart unless a test says otherwise (sft::Turn): stepping never + // mistakes two of them for one turn, whatever the machine's speed (and qemu's). sf::Surface::clock = [] { static long long t = 0; - return t += 1000; + return t += g_msPerEvent; }; engineTests(); diff --git a/test/preset_test.cpp b/test/preset_test.cpp index ef4380e..3e0a6e6 100644 --- a/test/preset_test.cpp +++ b/test/preset_test.cpp @@ -201,6 +201,41 @@ void testStepping() { // Continuous knobs follow MPC as they are. h.setN(sf::P_F_RES, 0.37f); CHECK(h.get(sf::P_F_RES) == 0.37f); + // A Q-Link turn on the preset stepper, as the Force sends it (the read-back plus 1/128 per + // detent, a few ms apart): a preset per detent the whole turn long, as many as NEXT taps. (It + // used to measure each detent from MPC's previous value, and stalled after one preset.) + { + Host a; + a.press(sf::P_PRE_INIT); + const auto L = sf::presetLibrary().listing(); + const int at = L->find("builtin:Init"); + auto label = [&L](int k) { return "PRESET " + L->label(L->items[static_cast(k)].key); }; + CHECK(at >= 0 && at + 6 < static_cast(L->items.size())); + { + Turn turn; + for (int k = 0; k < 6; ++k) a.detent(sf::P_PRESET, +1); + } + CHECK(a.display(sf::P_PRESET) == label(at + 6)); + { + Turn turn; + for (int k = 0; k < 2; ++k) a.detent(sf::P_PRESET, -1); + } + CHECK(a.display(sf::P_PRESET) == label(at + 4)); + } + // A tile tap's release echo (~0.7 s later) is not a second tap; a tap a second later is. + { + Host t; + t.press(sf::P_PRE_INIT); // a preset to favorite + t.setN(sf::P_FAV, 1.0f); + CHECK(t.get(sf::P_FAV) > 0.5f); + { + Turn echo(700); + t.setN(sf::P_FAV, 0.0f); + } + CHECK(t.get(sf::P_FAV) > 0.5f); + t.setN(sf::P_FAV, 0.0f); + CHECK(t.get(sf::P_FAV) < 0.5f); + } } void testRandomize() { diff --git a/tools/bench.cpp b/tools/bench.cpp index a0c16d8..692d27d 100644 --- a/tools/bench.cpp +++ b/tools/bench.cpp @@ -104,7 +104,11 @@ using StageFn = int (*)(double*, const char**, int); Result runCase(void* lib, int seconds, const char* name, int mode, StageFn stages) { auto entry = reinterpret_cast(dlsym(lib, "VSTPluginMain")); - AEffect* e = entry(master); + AEffect* e = entry ? entry(master) : nullptr; + if (!e) { + std::fprintf(stderr, "no VSTPluginMain, or it made no plugin\n"); + std::exit(1); + } e->dispatcher(e, vst::effOpen, 0, 0, nullptr, 0.0f); if (mode >= 2) heavy(e); if (mode == 3) { diff --git a/tools/demos.cpp b/tools/demos.cpp index 9a1b1ac..f603777 100644 --- a/tools/demos.cpp +++ b/tools/demos.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -274,6 +275,7 @@ float volumeOf(const std::string& text) { // what the preset sets, or the para } // namespace int main(int argc, char** argv) { + setenv("SF_FIXED_SEED", "1", 1); // the same noise and drift on every run: the levels match g_time.sampleRate = kSr; g_time.tempo = 120.0; if (argc == 4 && !std::strcmp(argv[1], "--match")) { From af36ed2c8b70139bdce4dcf4d7cfc864a5eaeab6 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 5 Oct 2026 13:35:14 +0200 Subject: [PATCH 4/5] Sound: the Sub 37's feedback, resonance, Multidrive, envelopes and noise; 30 more presets Where the Sub 37 / Subsequent 37 manuals say how the panel behaves, SubForce now does the same: - Feedback: the mixer's own output back into its feedback channel (the manual's block diagram: a mixer-only loop before the filter), through that channel's overload (a cubic clipper), AC coupled at 150 Hz and band-limited at 7 kHz. Tuned on renders: +1 dB and more drive at half the knob, unity loop gain at 85%, grit, then the chaos of an overdriven loop (+6 dB, upper harmonics +9 dB); no subharmonic motorboating. It was the post-VCA output (the Minimoog trick). - Resonance: self-oscillation past 70% of the knob ("settings above 7"), 4.6 at full as before. - Multidrive's second stage is asymmetric: tube-like even harmonics in the middle of its range (a triangle's 2nd harmonic at -20 dB at 50%), subtle low, toward symmetric hard clipping at full. - Envelopes: the attack is linear (the Sub 37's default), and Loop runs delay, attack, hold, decay and release ("will loop continuously"); with sustain at 0 it is the D-A-H-D cycle it was. - Noise colour: white -> pink (Kellet's economy filter at 88.2 kHz, the Sub 37's noise, the new default) -> dark, the same loudness at every colour (from the filters' responses), a 30 Hz high-pass against rumble. Noise and the feedback loop only run while their level is up (the heavy bench patch pays for them, Init doesn't). Presets: the factory presets' resonance is converted to keep their ladder feedback; Growl, Screamer and Noise Sweep retuned for the new loop and noise (median change of the 27 renders: 1.0 dB per third octave). 30 new ones from Sub 37 practice and Stephan Bodzin's documented use of it (rolling 16th basslines, fifth basslines, legato smears, resonant sequences, acid, big leads, Duo pads, risers), two new categories (Sequence, Pad) with demo phrases; all 57 level-matched to -18 LUFS. Tests: the resonance edge (65% silent, 76% sings), bass loss under it, Multidrive's even harmonics, the feedback's level and grit, the noise colours within 0.3 dB, the loop, the linear attack. Docs: the user guide (and a melodic techno section), performance, architecture, roadmap. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 ++++-- docs/ARCHITECTURE.md | 18 ++-- docs/PERFORMANCE.md | 61 ++++++++----- docs/ROADMAP.md | 28 ++++-- docs/USER_GUIDE.md | 74 ++++++++++++---- dsp/env.h | 35 +++++--- dsp/synth.cpp | 85 +++++++++++++++---- dsp/synth.h | 23 +++-- plugin/patch_map.cpp | 6 +- presets/Factory/01_Templates/01_Init.sfp | 2 +- presets/Factory/01_Templates/02_Init_Bass.sfp | 4 +- presets/Factory/01_Templates/03_Init_Lead.sfp | 4 +- presets/Factory/01_Templates/04_Init_Duo.sfp | 2 +- presets/Factory/02_Bass/01_Rubber_Bass.sfp | 4 +- presets/Factory/02_Bass/02_Sub_Thump.sfp | 4 +- presets/Factory/02_Bass/03_Funk_Pluck.sfp | 4 +- presets/Factory/02_Bass/04_Acid_Squelch.sfp | 4 +- presets/Factory/02_Bass/05_Growl.sfp | 6 +- presets/Factory/02_Bass/07_Sync_Bass.sfp | 4 +- presets/Factory/02_Bass/08_Pedal_Boom.sfp | 2 +- presets/Factory/02_Bass/09_Fifth_Bass.sfp | 4 +- presets/Factory/02_Bass/10_Horizon_Drift.sfp | 27 ++++++ .../Factory/02_Bass/11_Rolling_Sixteen.sfp | 19 +++++ presets/Factory/02_Bass/12_Offbeat_Knock.sfp | 18 ++++ presets/Factory/02_Bass/13_Fifth_Engine.sfp | 21 +++++ presets/Factory/02_Bass/14_Glide_Smear.sfp | 20 +++++ presets/Factory/02_Bass/15_Sub_Pressure.sfp | 16 ++++ presets/Factory/02_Bass/16_Grit_Roller.sfp | 27 ++++++ .../Factory/02_Bass/17_Foundation_Bass.sfp | 18 ++++ presets/Factory/03_Lead/01_Classic_Lead.sfp | 4 +- presets/Factory/03_Lead/02_Sync_Scream.sfp | 4 +- presets/Factory/03_Lead/03_Hollow_Square.sfp | 4 +- presets/Factory/03_Lead/04_Fat_Fifth.sfp | 4 +- presets/Factory/03_Lead/05_Whistle.sfp | 2 +- presets/Factory/03_Lead/06_Duo_Lead.sfp | 4 +- presets/Factory/03_Lead/07_Screamer.sfp | 6 +- presets/Factory/03_Lead/08_Afterglow_Lead.sfp | 23 +++++ presets/Factory/03_Lead/09_Resonant_Cry.sfp | 28 ++++++ presets/Factory/03_Lead/10_Swell_Lead.sfp | 30 +++++++ presets/Factory/03_Lead/11_Solo_Brass.sfp | 24 ++++++ presets/Factory/03_Lead/12_Breath_Flute.sfp | 24 ++++++ presets/Factory/03_Lead/13_Tearing_Sync.sfp | 25 ++++++ presets/Factory/03_Lead/14_Fuzz_Square.sfp | 19 +++++ presets/Factory/04_Keys/01_Pluck.sfp | 2 +- presets/Factory/04_Keys/02_Clav_Bite.sfp | 4 +- presets/Factory/04_Keys/03_Brass_Stab.sfp | 4 +- presets/Factory/04_Keys/04_Knuckle_Pluck.sfp | 20 +++++ presets/Factory/04_Keys/05_Glass_Arpeggio.sfp | 23 +++++ presets/Factory/04_Keys/06_Sync_Arpeggio.sfp | 19 +++++ presets/Factory/04_Keys/07_Duo_Intervals.sfp | 18 ++++ presets/Factory/05_FX/01_Laser_Zap.sfp | 4 +- presets/Factory/05_FX/02_Wobble.sfp | 4 +- presets/Factory/05_FX/04_Noise_Sweep.sfp | 6 +- presets/Factory/05_FX/05_Riser_Engine.sfp | 21 +++++ presets/Factory/05_FX/06_Downlifter.sfp | 21 +++++ presets/Factory/05_FX/07_Impact_Boom.sfp | 24 ++++++ presets/Factory/05_FX/08_Feedback_Howl.sfp | 24 ++++++ presets/Factory/05_FX/09_Data_Burble.sfp | 14 +++ presets/Factory/05_FX/10_Loop_Ticker.sfp | 20 +++++ .../Factory/06_Sequence/01_Resonant_Steps.sfp | 22 +++++ .../Factory/06_Sequence/02_Ladder_Acid.sfp | 19 +++++ presets/Factory/07_Pad/01_Slow_Bloom_Duo.sfp | 32 +++++++ presets/Factory/07_Pad/02_Tidal_Loop.sfp | 18 ++++ presets/Factory/07_Pad/03_Hollow_Haze.sfp | 26 ++++++ surface/params.json | 2 +- surface/surface.py | 2 +- test/engine_test.cpp | 59 +++++++++++-- test/keys_test.cpp | 2 +- test/plugin_test.cpp | 2 +- tools/demos.cpp | 21 ++++- 70 files changed, 1048 insertions(+), 153 deletions(-) create mode 100644 presets/Factory/02_Bass/10_Horizon_Drift.sfp create mode 100644 presets/Factory/02_Bass/11_Rolling_Sixteen.sfp create mode 100644 presets/Factory/02_Bass/12_Offbeat_Knock.sfp create mode 100644 presets/Factory/02_Bass/13_Fifth_Engine.sfp create mode 100644 presets/Factory/02_Bass/14_Glide_Smear.sfp create mode 100644 presets/Factory/02_Bass/15_Sub_Pressure.sfp create mode 100644 presets/Factory/02_Bass/16_Grit_Roller.sfp create mode 100644 presets/Factory/02_Bass/17_Foundation_Bass.sfp create mode 100644 presets/Factory/03_Lead/08_Afterglow_Lead.sfp create mode 100644 presets/Factory/03_Lead/09_Resonant_Cry.sfp create mode 100644 presets/Factory/03_Lead/10_Swell_Lead.sfp create mode 100644 presets/Factory/03_Lead/11_Solo_Brass.sfp create mode 100644 presets/Factory/03_Lead/12_Breath_Flute.sfp create mode 100644 presets/Factory/03_Lead/13_Tearing_Sync.sfp create mode 100644 presets/Factory/03_Lead/14_Fuzz_Square.sfp create mode 100644 presets/Factory/04_Keys/04_Knuckle_Pluck.sfp create mode 100644 presets/Factory/04_Keys/05_Glass_Arpeggio.sfp create mode 100644 presets/Factory/04_Keys/06_Sync_Arpeggio.sfp create mode 100644 presets/Factory/04_Keys/07_Duo_Intervals.sfp create mode 100644 presets/Factory/05_FX/05_Riser_Engine.sfp create mode 100644 presets/Factory/05_FX/06_Downlifter.sfp create mode 100644 presets/Factory/05_FX/07_Impact_Boom.sfp create mode 100644 presets/Factory/05_FX/08_Feedback_Howl.sfp create mode 100644 presets/Factory/05_FX/09_Data_Burble.sfp create mode 100644 presets/Factory/05_FX/10_Loop_Ticker.sfp create mode 100644 presets/Factory/06_Sequence/01_Resonant_Steps.sfp create mode 100644 presets/Factory/06_Sequence/02_Ladder_Acid.sfp create mode 100644 presets/Factory/07_Pad/01_Slow_Bloom_Duo.sfp create mode 100644 presets/Factory/07_Pad/02_Tidal_Loop.sfp create mode 100644 presets/Factory/07_Pad/03_Hollow_Haze.sfp diff --git a/README.md b/README.md index 7232275..f0b8a90 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,16 @@ DSP and presets are all SubForce's own. ## Highlights - **Two oscillators** with a continuously variable wave (triangle → saw → square → narrow pulse), - 32' to 2', hard sync, a square **sub oscillator** (−1 or −2 octaves) and **noise** -- **Mixer with feedback** — the output fed back into the mixer, for grit and howl — and the - classic habit of overdriving the filter when the levels are high -- **4-pole transistor ladder filter**: 6, 12, 18 or 24 dB, resonance up to self-oscillation, the - ladder's bass loss, **Multidrive**, keyboard tracking up to 200% -- **Two DAHDSR envelopes** (filter, amp) with velocity, keyboard tracking, loop and reset + 32' to 2', hard sync, a square **sub oscillator** (−1 or −2 octaves) and **noise** (white, pink + or dark) +- **Mixer with feedback** — the mixer's output back into it, as on the Sub 37: thicker, then + gritty, then the chaos of an overdriven loop — and the classic habit of overdriving the filter + when the levels are high +- **4-pole transistor ladder filter**: 6, 12, 18 or 24 dB, resonance self-oscillating past 70% of + the knob, the ladder's bass loss, **Multidrive** (asymmetric, tube-like warmth to hard clipping), + keyboard tracking up to 200% +- **Two DAHDSR envelopes** (filter, amp) with a linear attack, velocity, keyboard tracking, reset, + and a loop that runs through the release as the hardware's does - **Two mod busses**: triangle, square, saw, ramp, S&H, smooth random or the filter EG, free or locked to MPC's tempo, to pitch, cutoff and one more destination, scaled by the mod wheel, pressure or velocity @@ -29,8 +33,11 @@ DSP and presets are all SubForce's own. **glide** (rate, time or exponential) - **Band-limited and oversampled**: polyBLEP oscillators and the whole voice at 2× (88.2 kHz) with a halfband decimator; worst aliasing −53 dB up to C7, hard sync −65 dB -- **27 factory presets** in 5 categories, level-matched; user presets, Init and Randomize -- **Light on the CPU**: one voice, about 0.5% of a block on x86 (device numbers to come) +- **57 factory presets** in 7 categories, level-matched, many of them for + [melodic techno](docs/USER_GUIDE.md#melodic-techno) (rolling basslines, resonant sequences, + big leads); user presets, Init and Randomize +- **Light on the CPU**: one voice, about 2.8% of a block on the Force (3.6% with everything on), + with NEON where the work is parallel Effects are deliberately left out: use MPC's insert effects on the track. @@ -89,7 +96,9 @@ make plugin-package # dist/SubForce--mpc-armv7.zip | Stage | | |---|---| | Phase 0 — engine, plugin, pages, presets, tests, package | ✅ | -| On the device: install, play every page, `make bench-device` | 🔜 | +| Release build in CI (glibc 2.31, profile-guided, catalog-checked) | ✅ | +| On the device: installs, loads and benches (`make bench-device`) | ✅ | +| On the device: play every page | 🔜 | | v0.1 — first release, parameter list frozen | ⬜ | Details in the [roadmap](docs/ROADMAP.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 719ed57..7f18bb6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,19 +68,24 @@ flowchart LR O1[Osc 1] --> M[Mixer] S[Sub] --> M O2[Osc 2 · sync] --> M - N[Noise] --> M + N[Noise · white..pink..dark] --> M + M -->|feedback: overload, 150 Hz-7 kHz| M M -->|Multidrive gain| L[Ladder 6/12/18/24] - L --> D[Drive stage] --> V[VCA] --> DEC[2x decimator] --> OUT[Out L = R] - V -->|feedback, DC-blocked| M + L --> D[Drive stage · asymmetric] --> V[VCA] --> DEC[2x decimator] --> OUT[Out L = R] ``` +The feedback is the Sub 37's: the mixer's own output back into its feedback channel, one high-rate +sample late, through that channel's overload (a cubic clipper), AC coupled at 150 Hz and +band-limited at 7 kHz like an analog stage; its loop gain reaches unity at 85% of the knob. + Everything from the oscillators to the VCA runs at **88.2 kHz** (2x), in one loop per sample: 1. **Every 8 samples (control step)**: glide, both mod busses, drift; the targets of every control value (the oscillators' phase increments and waves, the cutoff, resonance, drive, mixer levels, VCA gain). Each glides linearly to its target over the next 8 samples, so nothing steps. -2. **Every sample (44.1 kHz)**: both envelopes, the cutoff they move (`exp2` and `tan` per sample, - so a 1 ms filter EG snaps), the oscillator shapes. +2. **Every sample (44.1 kHz)**: both envelopes and the cutoff they move (`exp2` and `tan` per + sample, so a 1 ms filter EG snaps), four at a time and ahead of the audio for each control run; + the oscillator shapes (once per run while the wave knobs hold still). 3. **Twice per sample (88.2 kHz)**: oscillators, sub and noise, the mixer with the feedback, the ladder, the drive stage, the VCA. Cutoff and VCA move halfway on the first half-step. 4. The halfband decimator folds the two samples into one; a 5 Hz DC blocker; the volume. @@ -89,7 +94,8 @@ The oscillators run one high-rate sample late (11 µs): a discontinuity between corrects both, so hard sync and the keyboard reset are exact to the sub-sample. When the amp EG has finished and the output has died away, the engine stops rendering (the -oscillators keep their free-running phase, the filter EG its release); a new note wakes it. +oscillators keep their free-running phase, the filter EG its release, the control grid keeps time: +glides, busses, drift); a new note wakes it, every control value starting at its target. ## Threads and real-time rules diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 5b1adac..8edf0dd 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -18,30 +18,47 @@ full (both oscillators, sub, noise, feedback, sync, full Multidrive, Duo) run th ## Measurements -`make bench`, x86 (only to prove the bench works; the Force is far slower per sample): +`make bench-device` on the Force (Cortex-A17 at 1.8 GHz, MPC running), percent of the 2902 µs block, +the profile-guided build: -| Case | avg | p99 | max | -|---|---|---|---| -| idle (no note) | 0.05% | 0.05% | 0.2% | -| Init, one held note | 0.5% | 0.8% | 1.4% | -| heavy patch, Duo | 0.5% | 0.7% | 0.7% | -| heavy, retrig + glide every 50 ms | 0.5% | 0.7% | 1.2% | +| Case | Phase 0 avg / p99 | now avg / p99 | +|---|---|---| +| idle (no note) | 0.38% / 0.62% | 0.17% / 0.36% | +| Init, one held note | 3.64% / 4.09% | 2.79% / 3.31% | +| heavy patch, Duo (noise and feedback on) | 3.70% / 4.19% | 3.55% / 4.07% | +| heavy, retrig + glide every 50 ms | 3.75% / 4.28% | 3.60% / 4.15% | -The profiling build puts about 15 µs per block in the voice and 1.5 µs in the control steps. +The heavy patch now runs the Sub 37's feedback loop (the mixer's output back into it), which is +serial by nature; Init leaves noise and feedback off and pays for neither. -**On the device: to be measured** (`make bench-device`). The estimate from PolyForce's numbers (one -PolyForce voice, about 45 k ARM instructions per block, measured about 1% of a block) is a few -percent for SubForce's one voice at 2x. +The profiling build puts about 73 µs per block in the voice (Init) and 8–17 µs in the control +steps (it reads the clock between stages, so it reads higher than the plain build). + +`make bench` (x86) only proves the bench works; the Force is far slower per sample. ## Why it is cheap - **One voice.** Everything PolyForce spends on voice management and vectorising across voices isn't needed. -- **Idle costs nothing:** when the amp EG has finished and the output died away, the engine stops - rendering until the next note. -- **Control rate:** modulation and glide every 8 samples, gliding in between; only the envelopes and - the cutoff they drive are computed per sample (that is what makes a 1 ms filter EG snap). -- **Short polynomials** instead of libm for `exp2`, `tan` and `tanh(x)/x`; a polyphase IIR +- **No divisions waiting on each other.** VFP's divider takes ~18 cycles and accepts one every + ~14 (measured on the device); the Phase 0 voice queued 14 of them per sample. Now: + - **The ladder** (`dsp/ladder.h`, 162 → 103 ns a tick): the four stages' nonlinear gains and + their 1 / (1 + f t) are four NEON lanes with reciprocal estimates refined by Newton-Raphson; the + loop is solved as an affine chain (each stage's output a + b · y3) and closed with one + division; inputs, outputs and integrators are one vector step each. + - **The oscillators** take each stretch's length instead of dividing for it; the two divisions + left (at a wrap, at a pulse edge) are reciprocals. + - **The cutoff coefficients** (`exp2` and `tan`) are worked out four at a time for each control + run, ahead of the audio. + - Measured on this core: a NEON q-register op issues every 2 cycles (a 64-bit datapath), so the + serial parts stay in VFP; an all-NEON ladder (a prefix scan) was slower. +- **Idle costs next to nothing:** when the amp EG has finished and the output died away, the engine + stops rendering, and the control grid only keeps time (glides, busses, drift) until a note wakes + it; block sizes still never change the sound. +- **Control rate:** modulation and glide every 8 samples, gliding in between; no libm calls + (`log2Fast`, `exp2Fast`, the cutoff note cached), multiplications instead of divisions. +- **Pay for what is on:** the pink filter and the feedback loop only run while their level is up. +- **Short polynomials** instead of libm for `exp2`, `log2`, `tan` and `tanh(x)/x`; a polyphase IIR decimator (8 multiplies per output sample) instead of a long FIR. - **Profile-guided** device build, trained under `qemu-arm` on a spread of patches and the factory presets. @@ -56,11 +73,12 @@ From the test suite (`make test` prints these): | Pulse width swept by a bus | every edge corrected: the largest step between two samples is 1.5 (an uncorrected edge is 2) | | Worst alias, hard sync | −65 dB | | Decimator | passband flat to 20 kHz within 0.001 dB, stopband from 24.2 kHz at −85 dB | -| Self-oscillation | from about 90% resonance; tracks the cutoff 15–50 cents flat (110 Hz–7 kHz), as a ladder does | +| Self-oscillation | past 70% of the knob, as the Sub 37's (65%: none; 76%: rms 0.07); tracks the cutoff 15–50 cents flat (110 Hz–7 kHz), as a ladder does | | Slopes | 5.4 / 10.9 / 16.3 / 21.7 dB per octave between 880 Hz and 1.76 kHz with a 400 Hz cutoff (6/12/18/24 nominal, reached further up) | -| Bass loss | 85% resonance: the passband drops by more than 8 dB, the ladder's 1 / (1 + r) | -| Multidrive | 0 → 100%: +4 dB louder, much denser | -| Noise colour | white to dark within 2 dB (the part of white noise above 22 kHz that the decimator removes is made up for) | +| Bass loss | 65% resonance (just under the edge): the passband drops by 13 dB, the ladder's 1 / (1 + r) | +| Multidrive | 0 → 100%: +4 dB louder, much denser; at 50% a triangle's 2nd harmonic at −20 dB (asymmetric, tube-like), none clean | +| Feedback | 0 → 100%: +6 dB, the 9th harmonic +9 dB against the fundamental; no subharmonic motorboating (< −85 dB) | +| Noise colour | white, pink (−10 dB a decade, Kellet's filter), dark: within 0.3 dB of each other through the open filter | ## Considered and left out @@ -71,3 +89,6 @@ From the test suite (`make test` prints these): pitch computed per high-rate sample. Possible, at a cost; not in Phase 0. - **Compensating the bass loss.** The ladder's thinning with resonance is kept, as on the hardware; Multidrive and the mixer make up for it. +- **Hand-written assembly for the ladder.** GCC moves some vector lanes through core registers; a + hand-scheduled ladder might save another ~30 ns a tick (~8% of the voice). Not worth giving up + the one C++ source the x86 tests also run. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 52127e2..ef85f59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,17 +12,33 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred ## What's next -- 🔜 **On the device:** install (`make plugin-install`), play every page, check the Q-Link sets and - the preset browser; `make bench-device`, and add the numbers to - [Performance](PERFORMANCE.md#measurements). +- 🔜 **On the device:** play every page, check the Q-Link sets and the preset browser (installs, + loads and benches: [Performance](PERFORMANCE.md#measurements)). - 🔜 **Automation on the device:** whether MPC plays recorded automation of the stepped controls (octaves, slopes, modes) back through `setParameter`, and from which thread; the stepping logic treats events under 300 ms apart as one turn. -- 🔜 **Listening pass** on real speakers: every factory preset, the filter's drive and resonance - range, the feedback's character, glide feel; tune voicing constants (`dsp/synth.cpp`: input gain, - drive span, feedback gain, output gain) and the presets from it. +- 🔜 **Listening pass** on real speakers, against a Sub 37 if one is at hand: every factory + preset, the new feedback loop's range, Multidrive's asymmetry, the linear attack; tune voicing + constants (`dsp/synth.cpp`: input gain, drive span and bias, feedback gain and clip, output gain) + and the presets from it. - ⬜ **v0.1**, the first release: parameter list frozen (append-only from then on). +## Phase 1 + +✅ (2026-10-05) + +- ✅ Release build in CI: armhf in `arm32v7/gcc:11-bullseye` (glibc 2.31), profile-guided, the ARM + suite against the shipped objects, catalog-checked; releases from `vX.Y.Z` tags +- ✅ Performance on the Force: NEON ladder (four stages in vector lanes, the loop as an affine + chain), no divisions in the oscillators, four-lane cutoff coefficients, cheaper control and idle: + Init 3.64% → 2.79% of the block, idle 0.38% → 0.17% +- ✅ Fixes: buttons and the preset stepper on the Force (PolyForce's device-run rules), Multi + trigger on key releases, poly aftertouch, CC 121, flush-to-zero scope, per-instance random seeds, + device diagnostics (`/tmp/subforce.trace`) +- ✅ Sub 37 behaviour, from its manuals: the mixer's own feedback loop, resonance self-oscillating + past 70%, asymmetric Multidrive, linear attack, loop through the release, pink noise +- ✅ 30 more factory presets (57 in 7 categories), many for melodic techno + ## Phase 0 ✅ (2026-10-05) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a1b27c6..fa5e2e5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -6,6 +6,7 @@ - [Keys, glide and Duo](#keys-glide-and-duo) - [The mod busses](#the-mod-busses) - [Presets](#presets) +- [Melodic techno](#melodic-techno) - [MIDI](#midi) --- @@ -14,8 +15,10 @@ One voice, played like an analog monosynth: two oscillators and a sub into a mixer, a 4-pole transistor ladder, two envelopes and two mod busses, with the panel's habits kept — push the mixer -and the filter overdrives, turn the resonance up and the bass thins out, take it past 90% and the -filter sings on its own. +and the filter overdrives, turn the resonance up and the bass thins out, take it past 70% and the +filter sings on its own. Where the Sub 37's manual says how its panel behaves, SubForce does the +same: the feedback loop, the resonance knob, Multidrive's character, the envelopes' attack and +loop, the pink noise. ## The screen @@ -57,19 +60,24 @@ All oscillators are band-limited (polyBLEP) and run at twice the sample rate. ### Mixer Osc 1, Sub, Osc 2, Noise and **Feedback** levels. The knobs have an audio taper. Several sources -up high drive the filter harder — on purpose. **Feedback** sends the synth's own output (after the -VCA) back into the mixer: a little thickens and growls, a lot with resonance howls. **Noise -Colour** darkens the noise from white. +up high drive the filter harder — on purpose. **Feedback** takes the mixer's own output back into +the mixer, as the Sub 37's FEEDBACK knob does with nothing in EXT IN: up to about 85% it thickens +and pushes the filter harder, past that the loop runs over unity and gets gritty, and the last +tenth is the chaos of an overdriven loop (+5 dB louder at full: turn the volume down). With +resonance it howls. **Noise Colour** goes from white (0) through pink (the middle, the Sub 37's +noise, the default) to dark (1), at the same loudness. ### Filter A 4-pole transistor ladder, modelled with its nonlinearities. -- **Cutoff** 20 Hz–20 kHz. **Resonance**: from about 90% the filter oscillates by itself, a sine at - the cutoff (play it with Key Track at 100%; it tracks the keyboard, a little flat at the top of the - resonance, like the circuit). Resonance also thins the bass, as on the hardware. -- **Multidrive**: drives the ladder and a second stage after it; from warm to fuzzy. Louder too, - but by a few dB, not a jump. +- **Cutoff** 20 Hz–20 kHz. **Resonance**: past 70% the filter oscillates by itself, a sine at the + cutoff, as the Sub 37's "settings above 7" (play it with Key Track at 100%; it tracks the + keyboard, a little flat at the top of the resonance, like the circuit). Resonance also thins the + bass, as on the hardware: about 13 dB just under the edge. +- **Multidrive**: drives the ladder and a second stage after it, as the Sub 37's OTA and FET stages + between the filter and the VCA: asymmetric, tube-like warmth (even harmonics) in the middle of + its range, toward hard clipping at full. Louder too, but by a few dB, not a jump. - **Slope**: 6, 12, 18 or 24 dB per octave (the outputs of the ladder's four stages). - **EG Amount**: the filter EG's sweep, up to ±10 octaves (the value shows octaves). - **Key Track**: 0–200%; 100% makes the cutoff follow the keyboard exactly (around middle C). @@ -79,11 +87,12 @@ A 4-pole transistor ladder, modelled with its nonlinearities. ### Envelopes Both are **DAHDSR**: Delay, Attack, Hold, Decay, Sustain, Release (attack to 10 s, the others to -10 s, delay and hold from 0). The curves are an analog EG's: attack charges like a capacitor aiming -past the top (toward 1.2) and stops at 1, the convex rise of a real attack; decay and release fall -exponentially (the times are to −60 dB). +10 s, delay and hold from 0). As the Sub 37's: the attack is linear (its default curve), decay and +release fall exponentially (the times are to −60 dB). -- **Loop**: while the key is held the envelope cycles delay → attack → hold → decay. +- **Loop**: while the key is held the envelope cycles delay → attack → hold → decay → release and + round again, the release stage included, as on the hardware. With Sustain at 0 that is a plain + D-A-H-D cycle; with Sustain up, decay falls to it and release takes it the rest of the way. - **Reset**: a new note's attack starts from 0. Off, it starts from wherever the envelope is (smooth legato, the analog way). - **Velocity** and **Key Track** as above; the amp EG's are on the AMP tab. @@ -130,8 +139,9 @@ triangle to Wave 1 with the wave near square. ## Presets -27 factory presets in five categories (Templates, Bass, Lead, Keys, FX), all level-matched (−18 LUFS -on their demo phrase). Pick them on the BROWSE tab or step through them on KEYS. +57 factory presets in seven categories (Templates, Bass, Lead, Keys, FX, Sequence, Pad), all +level-matched (−18 LUFS on their demo phrase). Pick them on the BROWSE tab or step through them on +KEYS. - **SAVE** writes `User NNN.sfp` to `/Presets/User/` (there is no text entry on the device, so presets are numbered; a number is never used twice). Rename them on a computer; the @@ -145,6 +155,38 @@ on their demo phrase). Pick them on the BROWSE tab or step through them on KEYS. - A preset file is plain text (`subforce 1` and `key=value` lines of real values), the same as an MPC project stores. +## Melodic techno + +The Sub 37 is all over melodic techno, Stephan Bodzin's above all: two of them in his studio, a +Subsequent 37 on stage, "the backbone of his music and his live set" — basslines sequenced from +the computer, leads played by hand, and constant rides of cutoff, resonance, drive and glide. +These presets are built on what is documented about that way of playing (no artist's patch is +copied; the names are SubForce's own): + +| Preset | What it is | Play it | +|---|---|---| +| **Bass / Rolling Sixteen** | Saw and sub, a 160 ms filter snap, KB Reset: the driving 16th bassline | 16ths from the sequencer, A1–D2; open FEG Decay through the build | +| **Bass / Horizon Drift** | Two detuned saws, the sub, feedback and resonance holding a growl under a low cutoff | Long legato roots, Bb0–F2; ride Cutoff and Resonance (FILTER Q-Links) | +| **Bass / Fifth Engine** | Saw plus a saw a fifth up, sub, feedback: the one-finger power chord | Legato, Bb0–C2; ride Osc 2 Freq between +7 and 0 | +| **Bass / Glide Smear** | Constant-rate glide (big leaps slide longer) on a singing bass | Overlap keys to slide; ride Glide Time in the phrase | +| **Bass / Grit Roller** | Multidrive and feedback; velocity drives it harder | Play the velocity: soft is round, hard is torn | +| **Sequence / Resonant Steps** | Resonance just under the edge, a bar-locked filter sweep | The Force's arpeggiator or sequencer at 1/16, C2–C4 | +| **Sequence / Ladder Acid** | An acid line through the round 24 dB ladder, legato slides | 16ths, overlap for slides, accents for the squelch | +| **Lead / Afterglow Lead** | Two detuned saws, a little feedback, exp legato glide, wheel vibrato | C4–C6, legato, into a long reverb | +| **Lead / Resonant Cry** | Resonance at the edge, feedback and drive; aftertouch opens it | Lean into the keys (pressure), wheel for vibrato | +| **Lead / Swell Lead** | Opens over a second as it is held; single trigger | Slow phrases, long notes | +| **Pad / Slow Bloom Duo** | Duo: two keys, two oscillators, a slow bloom | Hold two-note intervals | +| **Pad / Tidal Loop** | A looping filter EG breathing every ~4 s | Hold one note | +| **FX / Riser Engine** | A 4-bar synced rise of pitch, cutoff and feedback | Start it 4 bars before the drop | + +The rest of the new ones are the Sub 37's classics: Foundation Bass, Sub Pressure, Offbeat Knock, +Knuckle Pluck, Glass Arpeggio, Sync Arpeggio, Duo Intervals, Solo Brass, Breath Flute, Tearing +Sync, Fuzz Square, Hollow Haze, Downlifter, Impact Boom, Feedback Howl, Data Burble, Loop Ticker. + +Tips: 121–125 BPM; basslines live between about A0 and F2; put Cutoff, Resonance, Multidrive and +EG Amount on the FILTER Q-Links and ride them; use MPC's delay and reverb on the track (SubForce has +no effects of its own, on purpose). + ## MIDI | Message | Does | diff --git a/dsp/env.h b/dsp/env.h index 00cac37..b7601d7 100644 --- a/dsp/env.h +++ b/dsp/env.h @@ -1,10 +1,13 @@ #pragma once -// The DAHDSR envelope: Delay, Attack, Hold, Decay, Sustain, Release, as analog RC segments. -// Attack charges toward 1.2 and stops at 1.0 (the convex rise of a real attack); decay settles -// exponentially on the sustain level; release falls to -80 dB. Loop: while the key is held the -// envelope cycles delay -> attack -> hold -> decay, turning back once decay is near sustain. +// The DAHDSR envelope: Delay, Attack, Hold, Decay, Sustain, Release, as the Sub 37's: a linear +// attack (its default; EXP ATTACK is an option there), decay settling exponentially on the +// sustain level, release falling to -80 dB. Loop, while the key is held: delay -> attack -> hold +// -> decay -> release, and round again, the release stage included as on the hardware ("delay, +// attack, hold, decay, and release stages will loop continuously"): with sustain at 0 it is +// D-A-H-D; with sustain up, decay falls to it and release takes it the rest of the way. #include "fastmath.h" +#include #include #include @@ -17,7 +20,8 @@ struct EnvTimes { struct EnvCoef { int delayN = 0, holdN = 0; // samples - float att = 1.0f, dec = 1.0f, rel = 1.0f, sus = 0.5f; // per-sample one-pole steps + float att = 1.0f; // per-sample attack step (linear: 1 in `attack`) + float dec = 1.0f, rel = 1.0f, sus = 0.5f; // per-sample one-pole steps bool loop = false; }; @@ -29,7 +33,7 @@ inline EnvCoef envCoef(const EnvTimes& e, float sr, float scale) { EnvCoef c; c.delayN = static_cast(std::lround(clampf(e.delay * scale, 0.0f, 60.0f) * sr)); c.holdN = static_cast(std::lround(clampf(e.hold * scale, 0.0f, 60.0f) * sr)); - c.att = onePole(e.attack * scale / 1.7918f, sr); // ln(1.2 / 0.2): the curve crosses 1.0 at `attack` + c.att = 1.0f / (std::max(e.attack * scale, 1e-5f) * sr); // 0 to 1 in `attack` c.dec = onePole(e.decay * scale / 6.9078f, sr); // ln(1000): 60 dB of the way at `decay` c.rel = onePole(e.release * scale / 6.9078f, sr); c.sus = clampf(e.sustain, 0.0f, 1.0f); @@ -42,16 +46,19 @@ enum EnvStage : uint8_t { E_IDLE, E_DELAY, E_ATTACK, E_HOLD, E_DECAY, E_RELEASE struct Env { EnvStage stage = E_IDLE; float v = 0.0f; - int count = 0; // samples left in Delay / Hold + int count = 0; // samples left in Delay / Hold + bool looping = false; // in a loop's release stage (the key still held) // A new note. reset: the attack starts from 0, else from where the envelope is (analog). void trigger(const EnvCoef& c, bool reset) { if (reset) v = 0.0f; stage = c.delayN > 0 ? E_DELAY : E_ATTACK; count = c.delayN; + looping = false; } void release() { if (stage != E_IDLE) stage = E_RELEASE; + looping = false; } float tick(const EnvCoef& c) { @@ -61,7 +68,7 @@ struct Env { if (--count <= 0) stage = E_ATTACK; break; case E_ATTACK: - v += (1.2f - v) * c.att; + v += c.att; if (v >= 1.0f) { v = 1.0f; stage = c.holdN > 0 ? E_HOLD : E_DECAY; @@ -73,14 +80,18 @@ struct Env { break; case E_DECAY: v += (c.sus - v) * c.dec; - if (c.loop && v - c.sus < 0.01f) { // near the floor: round again - stage = c.delayN > 0 ? E_DELAY : E_ATTACK; - count = c.delayN; + if (c.loop && v - c.sus < 0.01f) { // on the sustain level: the loop's release + stage = E_RELEASE; + looping = true; } break; case E_RELEASE: v -= v * c.rel; - if (v < 1e-4f) { + if (looping && v < 0.01f) { // -40 dB: round again (the key is still held) + stage = c.delayN > 0 ? E_DELAY : E_ATTACK; + count = c.delayN; + looping = false; + } else if (v < 1e-4f) { v = 0.0f; stage = E_IDLE; } diff --git a/dsp/synth.cpp b/dsp/synth.cpp index 27b9342..2ba32cc 100644 --- a/dsp/synth.cpp +++ b/dsp/synth.cpp @@ -15,7 +15,14 @@ namespace { constexpr float kInGain = 0.5f; // mixer -> ladder at Multidrive 0 (one oscillator at full: mild warmth) constexpr float kDriveSpan = 7.0f; // Multidrive 1: 8x that -constexpr float kFeedbackGain = 1.6f; // the feedback level knob at full +// The feedback loop (the mixer's output back into it), tuned on renders of a saw through it: from +// a slight thickening (+1 dB at half the knob) and more drive into the filter, through grit, to the +// chaos of a loop over unity gain in the last tenth (+5 dB, the upper harmonics +15 dB). +constexpr float kFeedbackGain = 1.4f; // the loop's gain at full: unity at 85% of the knob +constexpr float kFeedbackClip = 0.7f; // where the loop's own stage (the EXT IN level amp) saturates +constexpr float kFeedbackHp = 150.0f; // Hz: AC coupled: no bass builds up around the loop +constexpr float kFeedbackLp = 7000.0f; // Hz: the loop's bandwidth, an analog stage's +constexpr float kDriveBias = 0.3f; // Multidrive's asymmetry at its middle (tube-like); subtle low, none at the ends constexpr float kOutGain = 1.2f; // engine output at 0 dB volume constexpr float kMaxCutoff = 0.40f; // of the 2x rate (35 kHz): the ladder's coefficient stays sane constexpr float kMinCutoff = 8.0f; // Hz @@ -30,6 +37,19 @@ inline float taper(float k) { inline float noteOf(float hz) { return 69.0f + 12.0f * std::log2(std::max(hz, 1.0f) / 440.0f); } +// Pink noise at the 2x rate: Paul Kellet's "economy" filter (three one-poles and a direct term, +// within about 1 dB of -3 dB/oct from 10 Hz up), its corners moved to 88.2 kHz. +constexpr float kPinkPole[3] = {0.998824309f, 0.981325634f, 0.754983444f}; +constexpr float kPinkGain[3] = {0.049552129f, 0.149655561f, 0.599829761f}; +constexpr float kPinkDirect = 0.1848f; +constexpr float kNoiseHp = 0.002134856f; // a one-pole high-pass at 30 Hz: no infrasonic rumble +// The noise level that keeps every colour as loud as white was (RMS through the wide-open ladder, +// the decimator and the output's DC blocker), colour 0..1 in 16 steps: white, toward pink at 0.5, +// then darker. From the filters' responses. +constexpr float kNoiseComp[17] = {2.000000f, 1.728349f, 1.428936f, 1.182656f, 0.994840f, 0.852474f, 0.742894f, + 0.656807f, 0.587788f, 0.599895f, 0.621496f, 0.652295f, 0.691527f, 0.739680f, + 0.800082f, 0.879364f, 0.987116f}; + } // namespace Synth::Synth(float sampleRate) @@ -63,10 +83,14 @@ void Synth::setPatch(const Patch& p) { } fc_ = envCoef(p.fenv, sr_, kbScale(p.fenv.kb)); ac_ = envCoef(p.aenv, sr_, kbScale(p.aenv.kb)); - noiseK_ = exp2Fast(-6.0f * clampf(p.noiseColor, 0.0f, 1.0f)); // white .. a ~220 Hz one-pole - // The same loudness at every colour: the one-pole's power (k / (2 - k) of white's), and the - // part of a bright noise above 22 kHz that the decimator takes away (up to half of white's). - noiseComp_ = std::sqrt((2.0f - noiseK_) / noiseK_) * (1.0f + noiseK_); + // Noise colour: white crossfading to pink up to 0.5, then pink through a one-pole down to ~220 + // Hz; the same RMS at every colour (kNoiseComp). + const float nc = clampf(p.noiseColor, 0.0f, 1.0f); + noisePink_ = std::min(1.0f, 2.0f * nc); + noiseK_ = nc > 0.5f ? exp2Fast(-6.0f * (2.0f * nc - 1.0f)) : 1.0f; + const float at = nc * 16.0f; + const int i0 = std::min(static_cast(at), 15); + noiseComp_ = kNoiseComp[i0] + (kNoiseComp[i0 + 1] - kNoiseComp[i0]) * (at - static_cast(i0)); } float Synth::kbScale(float kb) const { return exp2Fast(-clampf(kb, 0.0f, 1.0f) * static_cast(note1_ - 60) / 12.0f); } @@ -398,6 +422,8 @@ void Synth::control() { glide_[1].pitch + 12.0f * static_cast(p.osc[1].octave) + p.osc2Semis + bend + pitchMod[1] + 0.01f * driftCents[1]}; const float drive = clampf(p.drive + driveMod, 0.0f, 1.0f); const float driveGain = 1.0f + kDriveSpan * drive * drive; + const float sd = sinCycle(0.5f * drive); // sin(pi drive) + driveBias_ = kDriveBias * sd * sd; // no ramp: it only shapes the clipper const float aVel = 1.0f - p.aenv.vel + p.aenv.vel * vel_; const float target[] = { @@ -407,7 +433,7 @@ void Synth::control() { clampf(p.osc[1].wave + waveMod[1], 0.0f, 1.0f), cutNote_ + p.keyTrack * (glide_[0].pitch - 60.0f) + cutMod + driftCut, envSemis(p.envAmount) * fVel_, - kResMax * clampf(p.res + resMod, 0.0f, 1.0f), + resFeedback(clampf(p.res + resMod, 0.0f, 1.0f)), kInGain * driveGain, 0.5f + drive, // Multidrive's second stage: into the clipper... 2.0f / (1.0f + 2.0f * drive), // ...and out of it @@ -453,9 +479,9 @@ void Synth::goSilent() { idleSteps_ = 0; ladder_.reset(); dec_.reset(); - fbIn_ = fbX1_ = fbY1_ = 0.0f; + fbIn_ = fbX1_ = fbY1_ = fbLp_ = 0.0f; dcX1_ = dcY1_ = 0.0f; - noiseLp_ = 0.0f; + noiseLp_ = noiseHp_ = pink_[0] = pink_[1] = pink_[2] = 0.0f; aePrev_ = 0.0f; } @@ -532,8 +558,16 @@ void Synth::renderRun(float* out, int n) { const int tap = tap_, tapFrom = tapFrom_; const int subOct = patch_.subOctave == SO_TWO ? 2 : 1; const bool sync = patch_.sync; - const float nk = noiseK_, nc = noiseComp_; - const float fbR = 1.0f - 2.0f * kPi * 15.0f * invOsr_; // feedback DC blocker, 15 Hz + const float nk = noiseK_, np = noisePink_, nc = noiseComp_; + const float fbR = 1.0f - 2.0f * kPi * kFeedbackHp * invOsr_; // the feedback loop's AC coupling + const float fbK = 1.0f - std::exp(-2.0f * kPi * kFeedbackLp * invOsr_); // the loop's bandwidth + // Multidrive's second stage: softclip(g x + b) - softclip(b), tube-like (even harmonics) at + // moderate drive, toward symmetric hard clipping at full. + const float bias = driveBias_, biasOut = softclip(bias); + // Noise and feedback only cost when they are up (their level ramps sit at exactly 0 otherwise). + const bool noiseOn = lvl_[3].v != 0.0f || lvl_[3].d != 0.0f; + const bool fbOn = lvl_[4].v != 0.0f || lvl_[4].d != 0.0f; + if (!fbOn) fbIn_ = fbLp_ = fbX1_ = fbY1_ = 0.0f; // off: it starts clean when it comes up const float dcR = 1.0f - 2.0f * kPi * 5.0f * invSr_; // output DC blocker, 5 Hz // A still wave knob (no sweep, no bus on it): its shape once, not every sample. const bool morph1 = wave_[0].d != 0.0f, morph2 = wave_[1].d != 0.0f; @@ -569,17 +603,36 @@ void Synth::renderRun(float* out, int n) { vs = sub_.tick(w1, x1, subOct); } const float white = randBipolar(noiseRng_); - noiseLp_ += (white - noiseLp_) * nk; - const float mix = l0 * v1 + l1 * vs + l2 * v2 + l3 * noiseLp_ + l4 * fbIn_; + float mix = l0 * v1 + l1 * vs + l2 * v2; + if (noiseOn) { + pink_[0] = kPinkPole[0] * pink_[0] + kPinkGain[0] * white; + pink_[1] = kPinkPole[1] * pink_[1] + kPinkGain[1] * white; + pink_[2] = kPinkPole[2] * pink_[2] + kPinkGain[2] * white; + const float pink = pink_[0] + pink_[1] + pink_[2] + kPinkDirect * white; + const float src = white + (pink - white) * np; + noiseHp_ += (src - noiseHp_) * kNoiseHp; + noiseLp_ += (src - noiseHp_ - noiseLp_) * nk; + mix += l3 * noiseLp_; + } + // The mixer, its feedback channel taking the mixer's own output back in (one sample + // late): through that channel's overload (a cubic: no division in the loop), AC + // coupled and band-limited, as the Sub 37's FEEDBACK knob does with nothing in EXT IN. + // Over unity loop gain it saturates: grit, then the howl of an overdriven loop. + if (fbOn) { + mix += l4 * fbIn_; + // c - c^3 / 3: slope 1 at 0, flat at +-1, where it reads 2/3 of the scale: kFeedbackClip. + const float c = clampf(mix * (1.0f / (1.5f * kFeedbackClip)), -1.0f, 1.0f); + fbLp_ += (1.5f * kFeedbackClip * c * (1.0f - (1.0f / 3.0f) * c * c) - fbLp_) * fbK; + fbY1_ = fbLp_ - fbX1_ + fbR * fbY1_; + fbX1_ = fbLp_; + fbIn_ = fbY1_; + } float y[4]; // The circuit's own noise floor (-80 dB): what starts a self-oscillating filter with // every source down, as on the hardware. ladder_.tick(mix * gain + kThermal * white, fk, r, y); const float yt = fade ? y[tap] + (y[tapFrom] - y[tap]) * xf : y[tap]; - const float v = softclip(yt * postIn) * postOut * amp; // Multidrive's second stage, VCA - fbY1_ = v - fbX1_ + fbR * fbY1_; // the feedback path: DC blocked, back into the mixer next sample - fbX1_ = v; - fbIn_ = fbY1_; + const float v = (softclip(yt * postIn + bias) - biasOut) * postOut * amp; // Multidrive's second stage, VCA hi[k] = v; } const float lo = dec_.process(hi[0], hi[1]); diff --git a/dsp/synth.h b/dsp/synth.h index 9c67c16..a5538c2 100644 --- a/dsp/synth.h +++ b/dsp/synth.h @@ -2,9 +2,9 @@ // The SubForce engine: one analog-style voice in the spirit of a classic American monosynth. // // Osc 1 (+ square sub) ─┐ -// Osc 2 (hard sync) ────┤ mixer ─► Multidrive ─► 4-pole ladder (6/12/18/24 dB) ─► drive ─► VCA ─┬─► out -// Noise ────────────────┤ ▲ │ -// └── feedback ◄──────────────────────────────────────────────────────────┘ +// Osc 2 (hard sync) ────┤ mixer ─┬─► Multidrive ─► 4-pole ladder (6/12/18/24 dB) ─► drive ─► VCA ─► out +// Noise (white..pink..dark) ────┤ │ +// └── feedback ◄┘ (the mixer's own output back into it, as on the Sub 37) // // Mono, or Duo (paraphonic: each oscillator its own key, one filter and VCA). Two DAHDSR // envelopes (filter, amp), two mod busses (an LFO or the filter envelope to pitch, cutoff and @@ -43,6 +43,13 @@ enum SubOctave : int { SO_ONE, SO_TWO }; constexpr int kOctaveMin = -2; // 32' .. 2' (8' = 0) constexpr int kOctaveMax = 2; constexpr float kResMax = 4.6f; // ladder feedback at full resonance (self-oscillation from ~4) +constexpr float kResEdge = 0.7f; // the knob where it reaches 4: "settings above 7 cause the filter + // to self-oscillate" (the Sub 37's manual) + +// Resonance knob 0..1 -> ladder feedback: 0..4 up to kResEdge, on to kResMax at full. +inline float resFeedback(float k) { + return k < kResEdge ? 4.0f * k / kResEdge : 4.0f + (kResMax - 4.0f) * (k - kResEdge) / (1.0f - kResEdge); +} struct OscPatch { int octave = 0; // kOctaveMin..kOctaveMax @@ -75,7 +82,7 @@ struct Patch { float osc2Semis = 0.0f; // Osc 2 frequency against osc 1, -7..+7 semitones bool sync = false; // Osc 2 hard-synced to osc 1 int subOctave = SO_ONE; - float noiseColor = 0.0f; // 0 white .. 1 dark + float noiseColor = 0.5f; // 0 white .. 0.5 pink (the Sub 37's) .. 1 dark bool kbReset = false; // oscillators restart their cycle at each new note float drift = 0.25f; // 0..1 analog pitch and cutoff drift // Mixer, 0..1 (audio taper). Several sources up high drive the filter, as on the hardware. @@ -224,8 +231,9 @@ class Synth { Env fenv_, aenv_; EnvCoef fc_, ac_; uint32_t rng_ = 0x2545F491u, noiseRng_ = 0x9E3779B9u; - float noiseLp_ = 0.0f; - float fbIn_ = 0.0f, fbX1_ = 0.0f, fbY1_ = 0.0f; // feedback: DC-blocked VCA output, one sample late + float noiseLp_ = 0.0f, noiseHp_ = 0.0f, pink_[3] = {}; // noiseHp_: the 30 Hz high-pass's low part + float fbIn_ = 0.0f, fbX1_ = 0.0f, fbY1_ = 0.0f; // feedback: the mixer's output, overloaded, + float fbLp_ = 0.0f; // band-limited and DC-blocked, a sample late float dcX1_ = 0.0f, dcY1_ = 0.0f; // output DC blocker float fPrev_ = 0.1f, aePrev_ = 0.0f; // last base sample's cutoff coefficient and VCA bool fPrevValid_ = false; // fPrev_ is from this note's sound (not before a silence) @@ -246,7 +254,8 @@ class Synth { float driftNow_[3] = {}; Drift drift_[3]; // osc 1, osc 2, cutoff float noteDrift_[2] = {}; // per-note offsets, cents - float noiseK_ = 1.0f, noiseComp_ = 1.0f; + float noiseK_ = 1.0f, noisePink_ = 1.0f, noiseComp_ = 1.0f; // colour: dark one-pole, pink mix, level + float driveBias_ = 0.0f; // Multidrive's asymmetry (its tube-like even harmonics) double beats_ = 0.0, bpm_ = 120.0, beatsPerSample_ = 120.0 / 60.0 / 44100.0; bool playing_ = false, beatsValid_ = false; }; diff --git a/plugin/patch_map.cpp b/plugin/patch_map.cpp index c0b65d1..a21177d 100644 --- a/plugin/patch_map.cpp +++ b/plugin/patch_map.cpp @@ -141,9 +141,11 @@ std::string paramDisplay(int id, float n) { std::snprintf(b, sizeof b, "%+.2f oct", oct); break; } - case Fmt::Noise: + case Fmt::Noise: // white .. pink (the Sub 37's) at the middle .. dark if (v < 0.005f) return "White"; - std::snprintf(b, sizeof b, "Dark %.0f%%", v * 100.0f); + if (std::fabs(v - 0.5f) < 0.005f) return "Pink"; + if (v < 0.5f) std::snprintf(b, sizeof b, "Pink %.0f%%", 200.0f * v); + else std::snprintf(b, sizeof b, "Dark %.0f%%", 200.0f * v - 100.0f); break; default: return {}; } diff --git a/presets/Factory/01_Templates/01_Init.sfp b/presets/Factory/01_Templates/01_Init.sfp index 9dae6b2..a7497d3 100644 --- a/presets/Factory/01_Templates/01_Init.sfp +++ b/presets/Factory/01_Templates/01_Init.sfp @@ -1,2 +1,2 @@ subforce 1 -volume=-4.6 +volume=-4.5 diff --git a/presets/Factory/01_Templates/02_Init_Bass.sfp b/presets/Factory/01_Templates/02_Init_Bass.sfp index e6ce81a..229cafe 100644 --- a/presets/Factory/01_Templates/02_Init_Bass.sfp +++ b/presets/Factory/01_Templates/02_Init_Bass.sfp @@ -1,8 +1,8 @@ subforce 1 -volume=-2.2 +volume=-1.9 mix_sub=0.45 f_cut=250 -f_res=0.15 +f_res=0.121 f_drive=0.3 f_env=0.4 fe_d=0.3 diff --git a/presets/Factory/01_Templates/03_Init_Lead.sfp b/presets/Factory/01_Templates/03_Init_Lead.sfp index dc3bcc4..c0214ec 100644 --- a/presets/Factory/01_Templates/03_Init_Lead.sfp +++ b/presets/Factory/01_Templates/03_Init_Lead.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-2.3 +volume=-2.0 mix_o1=0.7 mix_o2=0.7 o2_freq=0.08 f_cut=1600 -f_res=0.25 +f_res=0.201 f_drive=0.35 f_env=0.25 fe_a=0.005 diff --git a/presets/Factory/01_Templates/04_Init_Duo.sfp b/presets/Factory/01_Templates/04_Init_Duo.sfp index d3e7b60..12e51e0 100644 --- a/presets/Factory/01_Templates/04_Init_Duo.sfp +++ b/presets/Factory/01_Templates/04_Init_Duo.sfp @@ -1,5 +1,5 @@ subforce 1 -volume=-6.8 +volume=-7.2 kmode=1 prio=1 mix_o1=0.7 diff --git a/presets/Factory/02_Bass/01_Rubber_Bass.sfp b/presets/Factory/02_Bass/01_Rubber_Bass.sfp index 52d3621..0288895 100644 --- a/presets/Factory/02_Bass/01_Rubber_Bass.sfp +++ b/presets/Factory/02_Bass/01_Rubber_Bass.sfp @@ -1,11 +1,11 @@ subforce 1 -volume=1.0 +volume=1.7 o2_oct=1 o2_wave=0.6667 mix_o2=0.55 o2_freq=-0.05 f_cut=110 -f_res=0.55 +f_res=0.443 f_drive=0.45 f_env=0.5 fe_a=0.001 diff --git a/presets/Factory/02_Bass/02_Sub_Thump.sfp b/presets/Factory/02_Bass/02_Sub_Thump.sfp index 1d1d560..d101a57 100644 --- a/presets/Factory/02_Bass/02_Sub_Thump.sfp +++ b/presets/Factory/02_Bass/02_Sub_Thump.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-1.1 +volume=-0.5 o1_wave=0.6667 mix_o1=0.7 mix_sub=0.75 f_cut=140 -f_res=0.1 +f_res=0.08 f_drive=0.35 f_env=0.35 fe_d=0.16 diff --git a/presets/Factory/02_Bass/03_Funk_Pluck.sfp b/presets/Factory/02_Bass/03_Funk_Pluck.sfp index 99febdd..4092823 100644 --- a/presets/Factory/02_Bass/03_Funk_Pluck.sfp +++ b/presets/Factory/02_Bass/03_Funk_Pluck.sfp @@ -1,9 +1,9 @@ subforce 1 -volume=4.6 +volume=4.8 mix_o2=0.4 o2_freq=0.1 f_cut=170 -f_res=0.62 +f_res=0.499 f_drive=0.3 f_env=0.62 fe_d=0.13 diff --git a/presets/Factory/02_Bass/04_Acid_Squelch.sfp b/presets/Factory/02_Bass/04_Acid_Squelch.sfp index a86ac87..e9d2f58 100644 --- a/presets/Factory/02_Bass/04_Acid_Squelch.sfp +++ b/presets/Factory/02_Bass/04_Acid_Squelch.sfp @@ -1,8 +1,8 @@ subforce 1 -volume=-0.9 +volume=-0.4 f_slope=2 f_cut=260 -f_res=0.82 +f_res=0.66 f_drive=0.55 f_env=0.55 fe_d=0.2 diff --git a/presets/Factory/02_Bass/05_Growl.sfp b/presets/Factory/02_Bass/05_Growl.sfp index 57123e3..0a1337d 100644 --- a/presets/Factory/02_Bass/05_Growl.sfp +++ b/presets/Factory/02_Bass/05_Growl.sfp @@ -1,13 +1,13 @@ subforce 1 -volume=-5.2 +volume=-5.1 mix_o1=0.75 mix_o2=0.7 o2_freq=-0.12 mix_sub=0.45 -mix_fb=0.45 +mix_fb=0.85 f_drive=0.75 f_cut=420 -f_res=0.35 +f_res=0.282 f_env=0.35 fe_d=0.35 fe_s=0.2 diff --git a/presets/Factory/02_Bass/07_Sync_Bass.sfp b/presets/Factory/02_Bass/07_Sync_Bass.sfp index 0ed8041..f17efe5 100644 --- a/presets/Factory/02_Bass/07_Sync_Bass.sfp +++ b/presets/Factory/02_Bass/07_Sync_Bass.sfp @@ -1,5 +1,5 @@ subforce 1 -volume=-1.4 +volume=-1.0 o2_sync=1 mix_o1=0.35 mix_o2=0.8 @@ -11,7 +11,7 @@ m1_ctl=0 fe_d=0.25 fe_s=0.1 f_cut=700 -f_res=0.3 +f_res=0.241 f_env=0.25 f_drive=0.4 ae_s=0.9 diff --git a/presets/Factory/02_Bass/08_Pedal_Boom.sfp b/presets/Factory/02_Bass/08_Pedal_Boom.sfp index 9346800..2a31bbb 100644 --- a/presets/Factory/02_Bass/08_Pedal_Boom.sfp +++ b/presets/Factory/02_Bass/08_Pedal_Boom.sfp @@ -7,7 +7,7 @@ mix_o2=0.7 o2_freq=0.1 mix_sub=0.35 f_cut=180 -f_res=0.3 +f_res=0.241 f_env=0.4 fe_a=0.008 fe_d=0.7 diff --git a/presets/Factory/02_Bass/09_Fifth_Bass.sfp b/presets/Factory/02_Bass/09_Fifth_Bass.sfp index cc1da5f..d9b63f9 100644 --- a/presets/Factory/02_Bass/09_Fifth_Bass.sfp +++ b/presets/Factory/02_Bass/09_Fifth_Bass.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-1.4 +volume=-1.0 mix_o1=0.75 mix_o2=0.55 o2_freq=7 f_cut=300 -f_res=0.25 +f_res=0.201 f_env=0.45 fe_d=0.25 fe_s=0.1 diff --git a/presets/Factory/02_Bass/10_Horizon_Drift.sfp b/presets/Factory/02_Bass/10_Horizon_Drift.sfp new file mode 100644 index 0000000..cb13492 --- /dev/null +++ b/presets/Factory/02_Bass/10_Horizon_Drift.sfp @@ -0,0 +1,27 @@ +subforce 1 +volume=-3.4 +mix_o1=0.75 +mix_o2=0.7 +o2_freq=-0.06 +mix_sub=0.55 +mix_fb=0.63 +drift=0.35 +f_cut=300 +f_res=0.362 +f_drive=0.35 +f_kb=0.35 +fe_a=0.03 +fe_d=1.5 +fe_s=0.35 +fe_r=0.8 +fe_vel=0.2 +ae_a=0.005 +ae_s=1 +ae_r=0.4 +ae_vel=0.15 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.06 +m1_rate=0.2 +m1_filter=0.45 diff --git a/presets/Factory/02_Bass/11_Rolling_Sixteen.sfp b/presets/Factory/02_Bass/11_Rolling_Sixteen.sfp new file mode 100644 index 0000000..0227082 --- /dev/null +++ b/presets/Factory/02_Bass/11_Rolling_Sixteen.sfp @@ -0,0 +1,19 @@ +subforce 1 +volume=4.1 +mix_sub=0.5 +f_cut=200 +f_res=0.282 +f_drive=0.4 +f_env=0.45 +fe_a=0.001 +fe_d=0.16 +fe_s=0 +fe_r=0.08 +fe_vel=0.5 +ae_a=0.001 +ae_d=0.22 +ae_s=0.35 +ae_r=0.05 +ae_vel=0.25 +kb_reset=1 +drift=0.15 diff --git a/presets/Factory/02_Bass/12_Offbeat_Knock.sfp b/presets/Factory/02_Bass/12_Offbeat_Knock.sfp new file mode 100644 index 0000000..084e9fc --- /dev/null +++ b/presets/Factory/02_Bass/12_Offbeat_Knock.sfp @@ -0,0 +1,18 @@ +subforce 1 +volume=5.7 +o1_wave=0.45 +mix_o1=1 +mix_sub=0.5 +f_cut=450 +f_res=0.201 +f_drive=0.45 +f_env=0.45 +fe_a=0.001 +fe_d=0.15 +fe_s=0 +ae_a=0.001 +ae_d=0.35 +ae_s=0.12 +ae_r=0.06 +ae_vel=0.2 +kb_reset=1 diff --git a/presets/Factory/02_Bass/13_Fifth_Engine.sfp b/presets/Factory/02_Bass/13_Fifth_Engine.sfp new file mode 100644 index 0000000..311f0b4 --- /dev/null +++ b/presets/Factory/02_Bass/13_Fifth_Engine.sfp @@ -0,0 +1,21 @@ +subforce 1 +volume=-2.5 +o2_freq=7 +mix_o2=0.5 +mix_sub=0.45 +mix_fb=0.57 +f_cut=300 +f_res=0.322 +f_drive=0.45 +f_env=0.35 +f_kb=0.45 +fe_d=0.45 +fe_s=0.2 +fe_vel=0.4 +ae_s=0.9 +ae_r=0.12 +ae_vel=0.2 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.05 diff --git a/presets/Factory/02_Bass/14_Glide_Smear.sfp b/presets/Factory/02_Bass/14_Glide_Smear.sfp new file mode 100644 index 0000000..ba1a88b --- /dev/null +++ b/presets/Factory/02_Bass/14_Glide_Smear.sfp @@ -0,0 +1,20 @@ +subforce 1 +volume=-0.5 +mix_o2=0.6 +o2_wave=0.5 +o2_freq=0.12 +mix_sub=0.35 +f_cut=280 +f_res=0.241 +f_drive=0.3 +f_env=0.4 +fe_a=0.004 +fe_d=0.35 +fe_s=0.25 +fe_vel=0.35 +ae_s=0.95 +ae_r=0.1 +trig=1 +glide_mode=2 +glide_type=0 +glide=0.12 diff --git a/presets/Factory/02_Bass/15_Sub_Pressure.sfp b/presets/Factory/02_Bass/15_Sub_Pressure.sfp new file mode 100644 index 0000000..1f79f97 --- /dev/null +++ b/presets/Factory/02_Bass/15_Sub_Pressure.sfp @@ -0,0 +1,16 @@ +subforce 1 +volume=-4.9 +o1_wave=0 +mix_sub=0.6 +f_cut=700 +f_kb=1 +f_drive=0.25 +f_env=0.15 +fe_d=0.08 +fe_s=0 +ae_d=0.6 +ae_s=0.9 +ae_r=0.07 +ae_vel=0.1 +kb_reset=1 +drift=0.1 diff --git a/presets/Factory/02_Bass/16_Grit_Roller.sfp b/presets/Factory/02_Bass/16_Grit_Roller.sfp new file mode 100644 index 0000000..31b88ac --- /dev/null +++ b/presets/Factory/02_Bass/16_Grit_Roller.sfp @@ -0,0 +1,27 @@ +subforce 1 +volume=-1.6 +o1_wave=0.55 +mix_o1=0.85 +o2_oct=1 +mix_o2=0.6 +o2_freq=-0.08 +mix_sub=0.3 +mix_fb=0.69 +f_cut=260 +f_res=0.241 +f_drive=0.75 +f_env=0.4 +fe_a=0.001 +fe_d=0.22 +fe_s=0.15 +fe_vel=0.5 +ae_a=0.001 +ae_d=0.4 +ae_s=0.6 +ae_r=0.05 +ae_vel=0.25 +kb_reset=1 +m2_src=6 +m2_dest=5 +m2_amt=0.25 +m2_ctl=3 diff --git a/presets/Factory/02_Bass/17_Foundation_Bass.sfp b/presets/Factory/02_Bass/17_Foundation_Bass.sfp new file mode 100644 index 0000000..8d74bda --- /dev/null +++ b/presets/Factory/02_Bass/17_Foundation_Bass.sfp @@ -0,0 +1,18 @@ +subforce 1 +volume=-2.4 +o2_oct=1 +mix_o2=0.75 +o2_freq=0.05 +mix_sub=0.4 +f_cut=250 +f_res=0.161 +f_drive=0.3 +f_env=0.45 +fe_a=0.001 +fe_d=0.5 +fe_r=0.2 +ae_a=0.001 +ae_d=0.8 +ae_s=0.85 +ae_r=0.12 +trig=1 diff --git a/presets/Factory/03_Lead/01_Classic_Lead.sfp b/presets/Factory/03_Lead/01_Classic_Lead.sfp index 59b5757..44b5a9b 100644 --- a/presets/Factory/03_Lead/01_Classic_Lead.sfp +++ b/presets/Factory/03_Lead/01_Classic_Lead.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-5.5 +volume=-5.0 o2_oct=1 mix_o2=0.6 o2_freq=0.05 f_cut=1400 -f_res=0.3 +f_res=0.241 f_drive=0.45 f_env=0.3 fe_a=0.01 diff --git a/presets/Factory/03_Lead/02_Sync_Scream.sfp b/presets/Factory/03_Lead/02_Sync_Scream.sfp index 9d20928..0d0cf87 100644 --- a/presets/Factory/03_Lead/02_Sync_Scream.sfp +++ b/presets/Factory/03_Lead/02_Sync_Scream.sfp @@ -1,5 +1,5 @@ subforce 1 -volume=-7.9 +volume=-7.7 o2_sync=1 mix_o1=0.2 mix_o2=0.85 @@ -13,7 +13,7 @@ fe_a=0.02 fe_d=0.8 fe_s=0.3 f_cut=3500 -f_res=0.3 +f_res=0.241 f_drive=0.6 f_env=0.15 ae_s=1 diff --git a/presets/Factory/03_Lead/03_Hollow_Square.sfp b/presets/Factory/03_Lead/03_Hollow_Square.sfp index 1fb1fdb..67153dc 100644 --- a/presets/Factory/03_Lead/03_Hollow_Square.sfp +++ b/presets/Factory/03_Lead/03_Hollow_Square.sfp @@ -1,12 +1,12 @@ subforce 1 -volume=-6.1 +volume=-6.0 o1_wave=0.6667 mix_o1=0.7 o2_wave=0.6667 o2_oct=3 mix_o2=0.35 f_cut=2200 -f_res=0.2 +f_res=0.161 f_slope=1 f_env=0.2 fe_d=0.4 diff --git a/presets/Factory/03_Lead/04_Fat_Fifth.sfp b/presets/Factory/03_Lead/04_Fat_Fifth.sfp index 1d434ef..85f7682 100644 --- a/presets/Factory/03_Lead/04_Fat_Fifth.sfp +++ b/presets/Factory/03_Lead/04_Fat_Fifth.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-5.1 +volume=-4.6 mix_o1=0.75 mix_o2=0.6 o2_freq=7 f_cut=1300 -f_res=0.35 +f_res=0.282 f_drive=0.5 f_env=0.35 fe_d=0.5 diff --git a/presets/Factory/03_Lead/05_Whistle.sfp b/presets/Factory/03_Lead/05_Whistle.sfp index ddbcc99..3079e70 100644 --- a/presets/Factory/03_Lead/05_Whistle.sfp +++ b/presets/Factory/03_Lead/05_Whistle.sfp @@ -2,7 +2,7 @@ subforce 1 volume=-2.7 mix_o1=0 mix_noise=0.15 -noise_color=0.3 +noise_color=0.5 f_res=1 f_kb=1 f_cut=269.6 diff --git a/presets/Factory/03_Lead/06_Duo_Lead.sfp b/presets/Factory/03_Lead/06_Duo_Lead.sfp index b3eedb5..c45f8d9 100644 --- a/presets/Factory/03_Lead/06_Duo_Lead.sfp +++ b/presets/Factory/03_Lead/06_Duo_Lead.sfp @@ -1,12 +1,12 @@ subforce 1 -volume=-3.9 +volume=-3.8 kmode=1 prio=1 mix_o1=0.7 o2_wave=0.6667 mix_o2=0.6 f_cut=1800 -f_res=0.25 +f_res=0.201 f_env=0.3 fe_d=0.5 fe_s=0.4 diff --git a/presets/Factory/03_Lead/07_Screamer.sfp b/presets/Factory/03_Lead/07_Screamer.sfp index 3ae1e8f..43e10be 100644 --- a/presets/Factory/03_Lead/07_Screamer.sfp +++ b/presets/Factory/03_Lead/07_Screamer.sfp @@ -1,8 +1,8 @@ subforce 1 -volume=-5.8 -mix_fb=0.6 +volume=-9.5 +mix_fb=0.95 f_drive=0.8 -f_res=0.6 +f_res=0.58 f_cut=1200 f_env=0.3 fe_d=0.5 diff --git a/presets/Factory/03_Lead/08_Afterglow_Lead.sfp b/presets/Factory/03_Lead/08_Afterglow_Lead.sfp new file mode 100644 index 0000000..b5d89aa --- /dev/null +++ b/presets/Factory/03_Lead/08_Afterglow_Lead.sfp @@ -0,0 +1,23 @@ +subforce 1 +volume=-9.3 +mix_o2=0.8 +o2_freq=0.12 +mix_sub=0.25 +mix_fb=0.57 +drift=0.4 +f_cut=1300 +f_res=0.241 +f_drive=0.45 +f_kb=0.7 +fe_a=0.01 +fe_d=0.8 +fe_s=0.5 +fe_r=0.5 +ae_a=0.005 +ae_s=1 +ae_r=0.45 +trig=1 +glide_mode=2 +glide_type=2 +m1_rate=5.2 +m1_pitch=0.13 diff --git a/presets/Factory/03_Lead/09_Resonant_Cry.sfp b/presets/Factory/03_Lead/09_Resonant_Cry.sfp new file mode 100644 index 0000000..2445372 --- /dev/null +++ b/presets/Factory/03_Lead/09_Resonant_Cry.sfp @@ -0,0 +1,28 @@ +subforce 1 +volume=-6.3 +o2_oct=1 +mix_o2=0.55 +o2_freq=0.05 +mix_fb=0.66 +f_cut=800 +f_res=0.644 +f_drive=0.6 +f_env=0.25 +f_kb=1 +fe_a=0.08 +fe_d=1.2 +fe_s=0.6 +fe_r=0.6 +fe_vel=0.4 +ae_a=0.004 +ae_s=1 +ae_r=0.35 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.07 +m1_rate=5.5 +m1_pitch=0.14 +m2_src=6 +m2_filter=0.55 +m2_ctl=2 diff --git a/presets/Factory/03_Lead/10_Swell_Lead.sfp b/presets/Factory/03_Lead/10_Swell_Lead.sfp new file mode 100644 index 0000000..6c899e1 --- /dev/null +++ b/presets/Factory/03_Lead/10_Swell_Lead.sfp @@ -0,0 +1,30 @@ +subforce 1 +volume=-2.3 +mix_o1=0.75 +mix_o2=0.75 +o2_wave=0.5 +o2_freq=-0.1 +f_cut=600 +f_res=0.322 +f_drive=0.35 +f_env=0.45 +f_kb=0.8 +fe_a=0.9 +fe_d=2.5 +fe_s=0.55 +fe_r=1.2 +fe_vel=0.2 +ae_a=0.12 +ae_s=1 +ae_r=1 +ae_vel=0.2 +trig=1 +glide_mode=1 +glide_type=2 +glide=0.15 +m1_rate=4.8 +m1_pitch=0.12 +m2_rate=0.15 +m2_dest=1 +m2_amt=0.12 +drift=0.45 diff --git a/presets/Factory/03_Lead/11_Solo_Brass.sfp b/presets/Factory/03_Lead/11_Solo_Brass.sfp new file mode 100644 index 0000000..06fbff0 --- /dev/null +++ b/presets/Factory/03_Lead/11_Solo_Brass.sfp @@ -0,0 +1,24 @@ +subforce 1 +volume=-5.7 +mix_o2=0.75 +o2_freq=-0.07 +f_cut=550 +f_res=0.121 +f_drive=0.35 +f_env=0.42 +f_kb=0.7 +fe_a=0.07 +fe_d=0.6 +fe_s=0.5 +fe_r=0.25 +fe_vel=0.5 +ae_a=0.03 +ae_d=0.6 +ae_s=0.9 +ae_r=0.2 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.04 +m1_rate=5.5 +m1_pitch=0.14 diff --git a/presets/Factory/03_Lead/12_Breath_Flute.sfp b/presets/Factory/03_Lead/12_Breath_Flute.sfp new file mode 100644 index 0000000..6e0ad26 --- /dev/null +++ b/presets/Factory/03_Lead/12_Breath_Flute.sfp @@ -0,0 +1,24 @@ +subforce 1 +volume=-1.9 +o1_wave=0 +mix_noise=0.18 +noise_color=0.5 +f_slope=1 +f_cut=1600 +f_res=0.08 +f_drive=0.1 +f_env=0.15 +f_kb=1 +fe_a=0.06 +fe_s=0.6 +ae_a=0.06 +ae_d=0.4 +ae_s=0.85 +ae_r=0.18 +ae_vel=0.5 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.03 +m1_pitch=0.12 +drift=0.2 diff --git a/presets/Factory/03_Lead/13_Tearing_Sync.sfp b/presets/Factory/03_Lead/13_Tearing_Sync.sfp new file mode 100644 index 0000000..9280034 --- /dev/null +++ b/presets/Factory/03_Lead/13_Tearing_Sync.sfp @@ -0,0 +1,25 @@ +subforce 1 +volume=-7.9 +o2_sync=1 +mix_o1=0.3 +mix_o2=0.85 +o2_freq=7 +m1_rate=0.6 +m1_pitch=0.54 +m1_pdest=2 +m2_rate=5.5 +m2_pitch=0.13 +m2_ctl=2 +f_cut=3000 +f_res=0.161 +f_drive=0.55 +f_env=0.15 +fe_d=0.6 +fe_s=0.4 +ae_a=0.003 +ae_s=1 +ae_r=0.2 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.05 diff --git a/presets/Factory/03_Lead/14_Fuzz_Square.sfp b/presets/Factory/03_Lead/14_Fuzz_Square.sfp new file mode 100644 index 0000000..138405c --- /dev/null +++ b/presets/Factory/03_Lead/14_Fuzz_Square.sfp @@ -0,0 +1,19 @@ +subforce 1 +volume=-10.0 +o1_wave=0.6667 +mix_o1=0.9 +o2_oct=1 +o2_wave=0.6667 +mix_o2=0.6 +o2_freq=0.04 +f_cut=1100 +f_res=0.201 +f_drive=0.95 +fe_s=0.35 +ae_s=1 +trig=1 +glide_mode=2 +glide_type=0 +glide=0.06 +m1_rate=6 +m1_pitch=0.15 diff --git a/presets/Factory/04_Keys/01_Pluck.sfp b/presets/Factory/04_Keys/01_Pluck.sfp index e7a7aff..f2a8541 100644 --- a/presets/Factory/04_Keys/01_Pluck.sfp +++ b/presets/Factory/04_Keys/01_Pluck.sfp @@ -5,7 +5,7 @@ o2_wave=0.6667 mix_o2=0.6 o2_freq=0.07 f_cut=520 -f_res=0.4 +f_res=0.322 f_env=0.55 fe_d=0.3 fe_s=0 diff --git a/presets/Factory/04_Keys/02_Clav_Bite.sfp b/presets/Factory/04_Keys/02_Clav_Bite.sfp index 2e6c173..53f22bf 100644 --- a/presets/Factory/04_Keys/02_Clav_Bite.sfp +++ b/presets/Factory/04_Keys/02_Clav_Bite.sfp @@ -1,8 +1,8 @@ subforce 1 -volume=4.3 +volume=4.6 o1_wave=0.85 f_cut=900 -f_res=0.25 +f_res=0.201 f_env=0.45 fe_d=0.1 fe_s=0.05 diff --git a/presets/Factory/04_Keys/03_Brass_Stab.sfp b/presets/Factory/04_Keys/03_Brass_Stab.sfp index 722450f..e56ec1e 100644 --- a/presets/Factory/04_Keys/03_Brass_Stab.sfp +++ b/presets/Factory/04_Keys/03_Brass_Stab.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-4.3 +volume=-3.7 mix_o1=0.7 mix_o2=0.7 o2_freq=-0.1 f_cut=420 -f_res=0.15 +f_res=0.121 f_drive=0.4 f_env=0.45 fe_a=0.05 diff --git a/presets/Factory/04_Keys/04_Knuckle_Pluck.sfp b/presets/Factory/04_Keys/04_Knuckle_Pluck.sfp new file mode 100644 index 0000000..ca6da6c --- /dev/null +++ b/presets/Factory/04_Keys/04_Knuckle_Pluck.sfp @@ -0,0 +1,20 @@ +subforce 1 +volume=0.5 +mix_o2=0.7 +o2_freq=0.08 +f_cut=380 +f_res=0.282 +f_drive=0.3 +f_env=0.6 +f_kb=0.8 +fe_a=0.001 +fe_d=0.25 +fe_s=0 +fe_r=0.25 +fe_vel=0.6 +ae_a=0.001 +ae_d=0.9 +ae_s=0 +ae_r=0.35 +kb_reset=1 +drift=0.3 diff --git a/presets/Factory/04_Keys/05_Glass_Arpeggio.sfp b/presets/Factory/04_Keys/05_Glass_Arpeggio.sfp new file mode 100644 index 0000000..3537a75 --- /dev/null +++ b/presets/Factory/04_Keys/05_Glass_Arpeggio.sfp @@ -0,0 +1,23 @@ +subforce 1 +volume=3.1 +o1_wave=0.78 +o2_oct=3 +mix_o2=0.4 +f_slope=1 +f_cut=900 +f_res=0.241 +f_env=0.45 +f_kb=1 +fe_a=0.001 +fe_d=0.2 +fe_s=0 +fe_kb=0.5 +fe_vel=0.4 +ae_a=0.001 +ae_s=0 +ae_r=0.3 +ae_kb=0.4 +kb_reset=1 +m2_rate=0.3 +m2_dest=2 +m2_amt=0.1 diff --git a/presets/Factory/04_Keys/06_Sync_Arpeggio.sfp b/presets/Factory/04_Keys/06_Sync_Arpeggio.sfp new file mode 100644 index 0000000..f633bad --- /dev/null +++ b/presets/Factory/04_Keys/06_Sync_Arpeggio.sfp @@ -0,0 +1,19 @@ +subforce 1 +volume=3.7 +o2_sync=1 +mix_o1=0.2 +mix_o2=0.85 +m2_src=6 +m2_pitch=0.65 +m2_pdest=2 +fe_a=0.001 +fe_d=0.18 +fe_s=0 +f_cut=2500 +f_res=0.161 +f_kb=0.8 +ae_a=0.001 +ae_d=0.6 +ae_s=0 +ae_r=0.25 +kb_reset=1 diff --git a/presets/Factory/04_Keys/07_Duo_Intervals.sfp b/presets/Factory/04_Keys/07_Duo_Intervals.sfp new file mode 100644 index 0000000..9d5a296 --- /dev/null +++ b/presets/Factory/04_Keys/07_Duo_Intervals.sfp @@ -0,0 +1,18 @@ +subforce 1 +volume=4.0 +kmode=1 +prio=1 +mix_o1=0.7 +mix_o2=0.7 +o2_wave=0.5 +f_cut=700 +f_res=0.201 +f_env=0.4 +f_kb=0.6 +fe_d=0.6 +fe_s=0.2 +ae_a=0.003 +ae_d=1.2 +ae_s=0.5 +ae_r=0.4 +drift=0.35 diff --git a/presets/Factory/05_FX/01_Laser_Zap.sfp b/presets/Factory/05_FX/01_Laser_Zap.sfp index c0495c6..46ffd21 100644 --- a/presets/Factory/05_FX/01_Laser_Zap.sfp +++ b/presets/Factory/05_FX/01_Laser_Zap.sfp @@ -1,5 +1,5 @@ subforce 1 -volume=-2.5 +volume=-2.4 o1_wave=0.6667 mix_o1=0.8 m1_src=6 @@ -10,7 +10,7 @@ fe_d=0.25 fe_s=0 fe_loop=1 f_cut=2500 -f_res=0.55 +f_res=0.443 f_env=0.2 ae_s=1 ae_r=0.1 diff --git a/presets/Factory/05_FX/02_Wobble.sfp b/presets/Factory/05_FX/02_Wobble.sfp index 9071898..f99cf0e 100644 --- a/presets/Factory/05_FX/02_Wobble.sfp +++ b/presets/Factory/05_FX/02_Wobble.sfp @@ -1,9 +1,9 @@ subforce 1 -volume=-2.8 +volume=-2.3 mix_o1=0.75 mix_sub=0.6 f_cut=220 -f_res=0.6 +f_res=0.483 f_drive=0.6 f_env=0 m1_src=0 diff --git a/presets/Factory/05_FX/04_Noise_Sweep.sfp b/presets/Factory/05_FX/04_Noise_Sweep.sfp index 7791d06..ca6756b 100644 --- a/presets/Factory/05_FX/04_Noise_Sweep.sfp +++ b/presets/Factory/05_FX/04_Noise_Sweep.sfp @@ -1,10 +1,10 @@ subforce 1 -volume=-3.8 +volume=-1.7 mix_o1=0 mix_noise=0.8 -noise_color=0.2 +noise_color=0 f_cut=150 -f_res=0.8 +f_res=0.644 f_env=0.8 fe_a=1.5 fe_d=2.5 diff --git a/presets/Factory/05_FX/05_Riser_Engine.sfp b/presets/Factory/05_FX/05_Riser_Engine.sfp new file mode 100644 index 0000000..9205b49 --- /dev/null +++ b/presets/Factory/05_FX/05_Riser_Engine.sfp @@ -0,0 +1,21 @@ +subforce 1 +volume=3.5 +mix_o1=0.6 +mix_noise=0.6 +noise_color=0.15 +mix_fb=0.6 +f_cut=900 +f_res=0.483 +f_drive=0.4 +f_env=0 +ae_a=0.5 +ae_s=1 +ae_r=1.5 +m2_src=3 +m2_sync=1 +m2_div=1 +m2_trig=1 +m2_pitch=0.6 +m2_filter=0.85 +m2_dest=8 +m2_amt=0.3 diff --git a/presets/Factory/05_FX/06_Downlifter.sfp b/presets/Factory/05_FX/06_Downlifter.sfp new file mode 100644 index 0000000..316bc02 --- /dev/null +++ b/presets/Factory/05_FX/06_Downlifter.sfp @@ -0,0 +1,21 @@ +subforce 1 +volume=-2.3 +o1_oct=3 +mix_o1=0.5 +mix_noise=0.7 +noise_color=0.3 +f_cut=1500 +f_res=0.523 +f_env=0 +ae_a=0.005 +ae_s=1 +ae_r=0.8 +ae_vel=0 +m2_src=2 +m2_sync=1 +m2_div=2 +m2_trig=1 +m2_pitch=0.7 +m2_filter=0.8 +m2_dest=9 +m2_amt=0.5 diff --git a/presets/Factory/05_FX/07_Impact_Boom.sfp b/presets/Factory/05_FX/07_Impact_Boom.sfp new file mode 100644 index 0000000..524b19f --- /dev/null +++ b/presets/Factory/05_FX/07_Impact_Boom.sfp @@ -0,0 +1,24 @@ +subforce 1 +volume=-0.4 +o1_wave=0 +o1_oct=1 +mix_o1=0.9 +mix_sub=0.5 +mix_noise=0.35 +noise_color=0.5 +f_cut=400 +f_res=0.08 +f_drive=0.5 +f_env=0.5 +fe_a=0.001 +fe_d=0.25 +fe_s=0 +ae_a=0.001 +ae_d=2.5 +ae_s=0 +ae_r=1.5 +ae_vel=0.3 +kb_reset=1 +drift=0 +m2_src=6 +m2_pitch=0.5 diff --git a/presets/Factory/05_FX/08_Feedback_Howl.sfp b/presets/Factory/05_FX/08_Feedback_Howl.sfp new file mode 100644 index 0000000..90167d0 --- /dev/null +++ b/presets/Factory/05_FX/08_Feedback_Howl.sfp @@ -0,0 +1,24 @@ +subforce 1 +volume=-6.3 +mix_o1=0.7 +mix_fb=0.97 +f_cut=700 +f_res=0.604 +f_drive=0.7 +fe_a=0.3 +fe_d=2 +fe_s=0.5 +f_kb=1 +ae_a=0.01 +ae_s=1 +ae_r=0.6 +m1_src=5 +m1_rate=0.8 +m1_filter=0.5 +m2_rate=0.2 +m2_dest=8 +m2_amt=0.15 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.2 diff --git a/presets/Factory/05_FX/09_Data_Burble.sfp b/presets/Factory/05_FX/09_Data_Burble.sfp new file mode 100644 index 0000000..ce5566f --- /dev/null +++ b/presets/Factory/05_FX/09_Data_Burble.sfp @@ -0,0 +1,14 @@ +subforce 1 +volume=-2.2 +o1_wave=0.6667 +f_cut=1200 +f_res=0.563 +f_env=0 +f_kb=1 +ae_s=1 +ae_r=0.3 +m2_src=4 +m2_sync=1 +m2_div=12 +m2_filter=0.75 +m2_pitch=0.5 diff --git a/presets/Factory/05_FX/10_Loop_Ticker.sfp b/presets/Factory/05_FX/10_Loop_Ticker.sfp new file mode 100644 index 0000000..6c20b1d --- /dev/null +++ b/presets/Factory/05_FX/10_Loop_Ticker.sfp @@ -0,0 +1,20 @@ +subforce 1 +volume=2.0 +o1_wave=0.6667 +mix_o1=0.9 +mix_noise=0.55 +noise_color=0.5 +f_cut=1800 +f_res=0.362 +f_env=0.4 +f_kb=0.8 +fe_a=0.001 +fe_d=0.22 +fe_s=0 +fe_loop=1 +ae_a=0.001 +ae_d=0.24 +ae_s=0 +ae_r=0.1 +ae_loop=1 +f_drive=0.45 diff --git a/presets/Factory/06_Sequence/01_Resonant_Steps.sfp b/presets/Factory/06_Sequence/01_Resonant_Steps.sfp new file mode 100644 index 0000000..3d63c3d --- /dev/null +++ b/presets/Factory/06_Sequence/01_Resonant_Steps.sfp @@ -0,0 +1,22 @@ +subforce 1 +volume=2.9 +mix_o2=0.45 +o2_freq=0.07 +f_cut=450 +f_res=0.58 +f_drive=0.5 +f_env=0.35 +f_kb=0.7 +fe_a=0.001 +fe_d=0.18 +fe_s=0.05 +fe_vel=0.6 +ae_a=0.001 +ae_d=0.3 +ae_s=0.25 +ae_r=0.08 +ae_vel=0.3 +kb_reset=1 +m2_sync=1 +m2_div=2 +m2_filter=0.35 diff --git a/presets/Factory/06_Sequence/02_Ladder_Acid.sfp b/presets/Factory/06_Sequence/02_Ladder_Acid.sfp new file mode 100644 index 0000000..17b110c --- /dev/null +++ b/presets/Factory/06_Sequence/02_Ladder_Acid.sfp @@ -0,0 +1,19 @@ +subforce 1 +volume=-0.2 +mix_o1=0.85 +f_cut=300 +f_res=0.628 +f_drive=0.6 +f_env=0.6 +fe_a=0.001 +fe_d=0.28 +fe_s=0 +fe_r=0.06 +fe_vel=0.8 +ae_a=0.001 +ae_s=0.6 +ae_r=0.04 +ae_vel=0.15 +trig=1 +glide_mode=2 +glide=0.05 diff --git a/presets/Factory/07_Pad/01_Slow_Bloom_Duo.sfp b/presets/Factory/07_Pad/01_Slow_Bloom_Duo.sfp new file mode 100644 index 0000000..f1f157a --- /dev/null +++ b/presets/Factory/07_Pad/01_Slow_Bloom_Duo.sfp @@ -0,0 +1,32 @@ +subforce 1 +volume=-1.2 +kmode=1 +prio=1 +o1_wave=0.2 +o2_wave=0.2 +mix_o1=0.7 +mix_o2=0.7 +drift=0.5 +f_cut=900 +f_res=0.161 +f_env=0.25 +f_kb=0.6 +fe_a=1.5 +fe_d=3 +fe_s=0.5 +fe_r=2.5 +fe_vel=0.1 +ae_a=1.2 +ae_d=2 +ae_s=1 +ae_r=2.5 +ae_vel=0.1 +trig=1 +glide_mode=1 +glide_type=2 +glide=0.2 +m1_pitch=0.1 +m2_rate=0.12 +m2_dest=1 +m2_amt=0.15 +m2_filter=0.25 diff --git a/presets/Factory/07_Pad/02_Tidal_Loop.sfp b/presets/Factory/07_Pad/02_Tidal_Loop.sfp new file mode 100644 index 0000000..8e1221c --- /dev/null +++ b/presets/Factory/07_Pad/02_Tidal_Loop.sfp @@ -0,0 +1,18 @@ +subforce 1 +volume=0.2 +mix_o1=0.75 +mix_o2=0.7 +o2_freq=0.15 +f_cut=350 +f_res=0.402 +f_env=0.5 +f_kb=0.7 +fe_a=2.4 +fe_d=2.4 +fe_s=0 +fe_loop=1 +ae_a=0.6 +ae_s=1 +ae_r=2 +drift=0.4 +trig=1 diff --git a/presets/Factory/07_Pad/03_Hollow_Haze.sfp b/presets/Factory/07_Pad/03_Hollow_Haze.sfp new file mode 100644 index 0000000..69e49c5 --- /dev/null +++ b/presets/Factory/07_Pad/03_Hollow_Haze.sfp @@ -0,0 +1,26 @@ +subforce 1 +volume=-7.5 +o1_wave=0.8 +o2_wave=0.75 +mix_o1=0.75 +mix_o2=0.65 +o2_freq=-0.12 +f_slope=1 +f_cut=1400 +f_res=0.121 +f_env=0.15 +fe_a=0.8 +fe_d=2 +fe_s=0.7 +ae_a=0.5 +ae_s=1 +ae_r=1.8 +m1_pitch=0.1 +m2_rate=0.25 +m2_dest=1 +m2_amt=0.12 +drift=0.5 +trig=1 +glide_mode=2 +glide_type=2 +glide=0.12 diff --git a/surface/params.json b/surface/params.json index d8ad37d..632c83c 100644 --- a/surface/params.json +++ b/surface/params.json @@ -145,7 +145,7 @@ "name": "Noise Colour", "min": 0, "max": 1, - "default": 0.0, + "default": 0.5, "display": "string" }, { diff --git a/surface/surface.py b/surface/surface.py index 1d22fbc..0d01aca 100644 --- a/surface/surface.py +++ b/surface/surface.py @@ -131,7 +131,7 @@ def popup_flag(of): num("mix_o2", "Osc 2 Level", "lin", 0, 1, 0, "pct") num("mix_noise", "Noise Level", "lin", 0, 1, 0, "pct") num("mix_fb", "Feedback", "lin", 0, 1, 0, "pct") -num("noise_color", "Noise Colour", "lin", 0, 1, 0, "noise") +num("noise_color", "Noise Colour", "lin", 0, 1, 0.5, "noise") # 0 white, 0.5 pink (the Sub 37's), 1 dark # --- filter (dsp/ladder.h) --- SLOPES = ["6 dB", "12 dB", "18 dB", "24 dB"] # dsp/synth.h Slope diff --git a/test/engine_test.cpp b/test/engine_test.cpp index 238192d..5ec8f1d 100644 --- a/test/engine_test.cpp +++ b/test/engine_test.cpp @@ -45,6 +45,20 @@ std::vector play(const Patch& p, int note, size_t n, size_t skip = 11025, double noteHzD(double note) { return 440.0 * std::pow(2.0, (note - 69.0) / 12.0); } +// The amplitude of the component at `hz` (a Hann-windowed correlation at 44.1 kHz). +double toneAmp(const std::vector& x, double hz) { + double re = 0.0, im = 0.0, wsum = 0.0; + const double n = static_cast(x.size()); + for (size_t i = 0; i < x.size(); ++i) { + const double w = 0.5 - 0.5 * std::cos(2.0 * M_PI * static_cast(i) / n); + const double ph = 2.0 * M_PI * hz * static_cast(i) / 44100.0; + re += w * x[i] * std::cos(ph); + im += w * x[i] * std::sin(ph); + wsum += w; + } + return 2.0 * std::sqrt(re * re + im * im) / wsum; +} + void testMath() { std::printf("== fast math\n"); double e2 = 0, et = 0, eh = 0, eh12 = 0; @@ -241,9 +255,15 @@ void testLadder() { std::printf(" resonance 100%%, cutoff %5.0f Hz: oscillates at %.1f Hz (%+.0f ct), rms %.3f\n", hz, f, cents, rms(x)); CHECK(std::fabs(cents) < 60.0 && rms(x) > 0.03 && rms(x) < 0.5); } - p.res = 0.8f; // below the edge: no oscillation of its own + // The edge at 70% of the knob, as the Sub 37's "settings above 7 cause the filter to + // self-oscillate": under it, no oscillation of its own; over it, it sings. p.cutoffHz = 1000.0f; - CHECK(rms(play(p, 60, 22050, 44100)) < 0.001); + p.res = 0.65f; + const double under = rms(play(p, 60, 22050, 44100)); + p.res = 0.76f; + const double over = rms(play(p, 60, 22050, 44100)); + std::printf(" resonance 65%%: rms %.5f, 76%%: rms %.3f\n", under, over); + CHECK(under < 0.001 && over > 0.03); // Key track 100%: the oscillation follows the keyboard. p.res = 1.0f; p.keyTrack = 1.0f; @@ -267,8 +287,9 @@ void testLadder() { s.cutoffHz = 3000.0f; s.res = 0.0f; const double clean = rms(play(s, 45, 16384)); - s.res = 0.85f; + s.res = 0.65f; // just under the edge const double thin = rms(play(s, 45, 16384)); + std::printf(" bass loss at 65%% resonance: %.1f dB\n", 20 * std::log10(clean / thin)); CHECK(20 * std::log10(clean / thin) > 8.0); // Multidrive: louder and denser, never more than a few dB. Patch d = plain(); @@ -279,6 +300,33 @@ void testLadder() { const double d1 = rms(play(d, 36, 16384)); std::printf(" Multidrive 0 -> 100%%: %+.1f dB\n", 20 * std::log10(d1 / d0)); CHECK(d1 > d0 && 20 * std::log10(d1 / d0) < 9.0); + // Multidrive's asymmetry, the Sub 37's "tube-like warmth": a triangle (odd harmonics only) + // picks up even ones at moderate drive, none clean. + auto evenDb = [](float drive) { + Patch t = plain(); + t.osc[0].wave = 0.0f; + t.drive = drive; + const auto x = play(t, 45, 44100, 22050); // A2, 110 Hz + return 20.0 * std::log10(toneAmp(x, 220.0) / toneAmp(x, 110.0)); + }; + const double e0 = evenDb(0.0f), e5 = evenDb(0.5f); + std::printf(" 2nd harmonic: %.1f dB clean, %.1f dB at Multidrive 50%%\n", e0, e5); + CHECK(e0 < -60.0 && e5 > -45.0); + // Feedback, the mixer's output back into it: louder and grittier (more upper harmonics) as it + // comes up, bounded. + auto fb = [](float level, double* hf) { + Patch t = plain(); + t.cutoffHz = 2000.0f; + t.mixFeedback = level; + const auto x = play(t, 36, 44100, 22050); + *hf = toneAmp(x, 65.41 * 9) / toneAmp(x, 65.41); // the 9th harmonic against the fundamental + return rms(x); + }; + double h0 = 0, h1 = 0; + const double f0 = fb(0.0f, &h0), f1 = fb(1.0f, &h1); + std::printf(" feedback 0 -> 100%%: %+.1f dB, 9th harmonic %+.1f dB against the fundamental\n", + 20 * std::log10(f1 / f0), 20 * std::log10(h1 / h0)); + CHECK(f1 > f0 && 20 * std::log10(f1 / f0) < 15.0 && 20 * std::log10(h1 / h0) > 3.0 && std::isfinite(f1)); } void testEnvelopes() { @@ -414,14 +462,15 @@ void testIdleAndStability() { hard.render(a.data(), junk.data(), 22050); soft.render(b.data(), junk.data(), 22050); CHECK(std::fabs(20.0 * std::log10(rms(a, 11025) / rms(b, 11025)) - 20.0 * std::log10(127.0 / 32.0)) < 0.5); - // Noise: about the same loudness at every colour. + // Noise: about the same loudness at every colour, white through pink to dark. double lo = 1e9, hi = 0.0; - for (float c : {0.0f, 0.25f, 0.5f, 1.0f}) { + for (float c : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f}) { Patch n = plain(); n.mixOsc1 = 0.0f; n.mixNoise = 0.8f; n.noiseColor = c; const double r = rms(play(n, 60, 22050)); + std::printf(" noise colour %.2f: rms %.3f\n", c, r); lo = std::min(lo, r); hi = std::max(hi, r); } diff --git a/test/keys_test.cpp b/test/keys_test.cpp index 1e4b995..ae15860 100644 --- a/test/keys_test.cpp +++ b/test/keys_test.cpp @@ -83,7 +83,7 @@ void testPriority() { r.run(44100); r.s.noteOn(48, 100); r.run(1323); - CHECK(r.s.info().ampEnv > 0.6f); + CHECK(r.s.info().ampEnv > 0.5f); // 30 ms into the linear 50 ms attack // Back to a key still held when the newer one lifts: the oscillators move, the envelopes // don't start again (Multi retriggers on key presses, as on the hardware). r.run(22050); diff --git a/test/plugin_test.cpp b/test/plugin_test.cpp index e17ee8f..6068b6f 100644 --- a/test/plugin_test.cpp +++ b/test/plugin_test.cpp @@ -140,7 +140,7 @@ void testBasics() { CHECK(namesOk && autoOk); CHECK(h.display(sf::P_F_CUT) == "2.00 kHz" && h.display(sf::P_F_SLOPE) == "24 dB"); CHECK(h.display(sf::P_O1_WAVE) == "Saw" && h.display(sf::P_O1_OCT) == "8'"); - CHECK(h.display(sf::P_O2_FREQ) == "0.00 st" && h.display(sf::P_NOISE_COLOR) == "White"); + CHECK(h.display(sf::P_O2_FREQ) == "0.00 st" && h.display(sf::P_NOISE_COLOR) == "Pink"); h.set(sf::P_O1_WAVE, 1.0f); CHECK(h.display(sf::P_O1_WAVE) == "Pulse 6%"); h.set(sf::P_O1_WAVE, 0.5f); diff --git a/tools/demos.cpp b/tools/demos.cpp index f603777..15cf85d 100644 --- a/tools/demos.cpp +++ b/tools/demos.cpp @@ -9,8 +9,9 @@ // R128: K-weighting, 400 ms blocks, -70 LUFS and -10 LU gates) // // Phrases by category: Bass and Templates, a 16th-note line with legato steps (glide and Single -// trigger show); Lead, a legato melody with the mod wheel up on the long notes; Keys, an 8th-note -// arpeggio; anything else (FX), held notes. 120 BPM. The files are stereo, L = R, as the plugin +// trigger show); Sequence, a melodic techno 16th-note sequence with accents and slides; Lead, a +// legato melody with the mod wheel up on the long notes; Keys, an 8th-note arpeggio; Pad, held +// two-note chords (Duo takes both keys); anything else (FX), held notes. 120 BPM. The files are stereo, L = R, as the plugin // plays: the loudness is that of the stereo pair. #include "../plugin/vst2.h" #include "factory_presets.h" @@ -63,6 +64,22 @@ std::vector phrase(const std::string& category, double& beats) { for (int k = 0; k < 16; ++k) note(ev, line[k] - (bar ? 2 : 0), bar * 4.0 + k * 0.25, len[k], k % 4 == 0 ? 120 : 85); beats = 8.5; + } else if (category == "Sequence") { + // A minor, then G: two bars of 16ths, accents on the beats, two slides a bar (len > 0.25). + static const int seq[] = {45, 57, 52, 57, 48, 57, 52, 55, 45, 57, 52, 60, 48, 57, 55, 52}; + static const double len[] = {0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.3, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.3, 0.2}; + for (int bar = 0; bar < 2; ++bar) + for (int k = 0; k < 16; ++k) + note(ev, seq[k] - (bar ? 2 : 0), bar * 4.0 + k * 0.25, len[k], k % 4 == 0 ? 120 : 80 + (k % 2) * 15); + beats = 8.5; + } else if (category == "Pad") { + note(ev, 48, 0.0, 3.8, 100); + note(ev, 55, 0.0, 3.8, 100); + note(ev, 53, 4.0, 3.8, 100); + note(ev, 60, 4.0, 3.8, 100); + ev.push_back({4.0, 0xB0, 1, 90}); + ev.push_back({7.8, 0xB0, 1, 0}); + beats = 10.0; } else if (category == "Lead") { static const int mel[] = {60, 63, 67, 70, 72, 70, 67, 75}; static const double at[] = {0, 1, 1.5, 2, 3, 5, 5.5, 6}; From 36478b4001d0ddd5d7657e3b697f10f15a38d496 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 5 Oct 2026 14:19:54 +0200 Subject: [PATCH 5/5] Release prep: docs reviewed, changelog-driven release notes, page previews For the first public preview (0.0.1, the plugin catalog's beta channel): - CHANGELOG.md; the release workflow takes the tag's "## X.Y.Z" section as the release notes (the catalog shows them) and fails before publishing if there is none. - docs/img: the pages rendered offline from the skin (OSC, FILTER, AMP, MOD, KEYS); FILTER heads the README, the user guide shows them all. - README: what has been checked on a Force (MPC OS 3.9), MPC OS 3.x as the requirement (release builds use glibc up to 2.27 but need GCC 11's libstdc++; 2.x untested), installing from Releases, where presets live, uninstalling, the changelog, the status. - Docs reviewed against the code: the 0.x version rule (param_compat stays 0 while the list may change; append-only from v0.1), how CI releases from tags, binary compatibility, the test and demo-phrase tables, the status line, the browser (FAVORITES, RECENT, RND, the internal drive), MIDI channels, figures (+6 dB feedback; the subharmonic check now in the suite: -101 dB). - The product names of the original stay out of the docs and comments, as the project's decision says; "the original" is defined once and used throughout. - The feedback loop's coefficient is worked out once, in the constructor. - Vendored release.py, a marked local patch: INSTALL.md no longer puts a slash after user-data files. The manifest's about/requires text updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 12 ++- CHANGELOG.md | 27 +++++ Makefile | 4 +- README.md | 78 +++++++++------ docs/ARCHITECTURE.md | 24 +++-- docs/BUILDING.md | 71 ++++++++----- docs/PERFORMANCE.md | 34 ++++--- docs/ROADMAP.md | 23 +++-- docs/USER_GUIDE.md | 99 +++++++++++-------- docs/img/amp.png | Bin 0 -> 26634 bytes docs/img/filter.png | Bin 0 -> 34290 bytes docs/img/keys.png | Bin 0 -> 36814 bytes docs/img/mod.png | Bin 0 -> 41388 bytes docs/img/osc.png | Bin 0 -> 35357 bytes dsp/env.h | 4 +- dsp/synth.cpp | 8 +- dsp/synth.h | 7 +- plugin/patch_map.cpp | 2 +- surface/surface.py | 2 +- test/engine_test.cpp | 18 ++-- test/keys_test.cpp | 2 +- third_party/mpc-vst-plugins/README.md | 10 +- third_party/mpc-vst-plugins/tools/release.py | 3 +- 23 files changed, 271 insertions(+), 157 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/img/amp.png create mode 100644 docs/img/filter.png create mode 100644 docs/img/keys.png create mode 100644 docs/img/mod.png create mode 100644 docs/img/osc.png diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1348df9..1e7cb8b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ # glibc 2.31) under QEMU, as the catalog's own ports do, profile-guided, and the test suite runs against the # objects the .so is linked from. The sanitizer suite runs on x86. The zip is checked with the catalog's # checker and kept as an artifact; a vX.Y.Z tag also publishes it as a GitHub release (a prerelease while the -# version is 0.x: the catalog's beta channel). +# version is 0.x: the catalog's beta channel), with CHANGELOG.md's section for the version as its notes. name: build on: @@ -57,10 +57,18 @@ jobs: name: SubForce-mpc-armv7 path: dist/*-mpc-armv7.zip if-no-files-found: error + # The release's notes are CHANGELOG.md's section for the tag's version ("## X.Y.Z"): the catalog shows + # them. A tag without a section fails here, before anything is published. + - name: Release notes + if: startsWith(github.ref, 'refs/tags/v') + run: | + v="${GITHUB_REF_NAME#v}" + awk -v v="$v" '/^## /{p = ($2 == v); next} p' CHANGELOG.md > release-notes.md + grep -q '[^[:space:]]' release-notes.md || { echo "CHANGELOG.md has no section '## $v'"; exit 1; } - name: Release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: files: dist/*-mpc-armv7.zip prerelease: ${{ startsWith(github.ref_name, 'v0.') }} - generate_release_notes: true + body_path: release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b98d79d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +Releases are built by CI from a `vX.Y.Z` tag (see [Building](docs/BUILDING.md#release-builds)); the +section for the tag's version becomes the release's notes. While the version is 0.x the parameter list +may still change between releases, and releases are prereleases (the plugin catalog's beta channel). + +## 0.0.1 + +The first public preview. + +- **Engine:** two morphing oscillators (triangle → saw → square → narrow pulse), 32' to 2', hard sync, + a square sub (−1 or −2 octaves), noise from white through pink to dark, analog drift. A mixer with + its own feedback loop (thicker, then gritty, then the chaos of an overdriven loop). A nonlinear + 4-pole transistor ladder (6/12/18/24 dB) that self-oscillates past 70% of the resonance knob and + thins the bass as it rises, with Multidrive (asymmetric, tube-like warmth to hard clipping). Two + DAHDSR envelopes with a linear attack, velocity, key tracking, reset and a loop through the + release. Two mod busses (7 sources, free or locked to MPC's tempo; pitch, cutoff and one of 10 + more destinations; mod wheel, aftertouch or velocity). Mono or Duo, note priority, multi or + single trigger, glide (rate, time or exponential). The whole voice at 2× with a halfband + decimator. +- **57 factory presets** in seven categories (Templates, Bass, Lead, Keys, FX, Sequence, Pad), many + for melodic techno; all level-matched. User presets, favorites, a browser, Init and Randomize. +- **Touchscreen pages** (OSC, FILTER, AMP, MOD, BROWSE, KEYS) with a Q-Link set each. +- **On the Force:** about 2.8% of a block for one held note (3.6% with everything on), measured on + the device; the ladder runs on NEON. +- **Builds:** armhf against glibc 2.31 (loads on MPC OS 2.x and 3.x; the pages need 3.x), + profile-guided, checked with the plugin catalog's `catalog_check.py`. diff --git a/Makefile b/Makefile index cb58249..6dd1777 100644 --- a/Makefile +++ b/Makefile @@ -235,8 +235,8 @@ plugin-package: $(ARM_SO) $(SKIN) echo " Release packages come from CI (glibc 2.31, docs/BUILDING.md#release-builds)."; fi $(PY) $(MV)/tools/release.py --so $(ARM_SO) --skin "$(SKIN_DIR)" --entry $(SURF_OUT)/pluginlist-entry.xml \ --version $(PLUGIN_VERSION) --repo Devko/SubForce --license MIT \ - --about "SubForce analog-style monosynth (preview): 2 oscillators with continuous wave shape and hard sync, sub oscillator, noise, feedback, a 4-pole ladder filter (6-24 dB) with Multidrive, 2 DAHDSR envelopes, 2 mod busses, glide, Duo mode." \ - --requires "root SSH (MockbaMod)" \ + --about "SubForce analog-style monosynth (preview): 2 oscillators with continuous wave shape and hard sync, sub oscillator, white, pink or dark noise, a mixer feedback loop, a 4-pole ladder filter (6-24 dB) with Multidrive, 2 DAHDSR envelopes, 2 mod busses, glide, Duo mode, 57 presets." \ + --requires "root SSH (MockbaMod); MPC OS 3.x for the pages" \ --user-data Presets --user-data preset_favorites.txt --user-data preset_recent.txt \ -o dist diff --git a/README.md b/README.md index f0b8a90..10fe0a4 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,34 @@ **An analog-style monosynth that runs natively inside MPC on the Akai Force.** SubForce is a VST2 instrument for MPC OS's built-in plugin host, with its own touchscreen pages -and Q-Link sets. It tips its hat to a certain *37-key* American paraphonic monosynth, built on the -same plugin groundwork as its sibling [PolyForce](https://github.com/Devko/PolyForce). The name, -DSP and presets are all SubForce's own. +and Q-Link sets. It tips its hat to a classic 37-key American paraphonic analog monosynth (*the +original* in these docs), built on the same plugin groundwork as its sibling +[PolyForce](https://github.com/Devko/PolyForce). The name, DSP and presets are all SubForce's own. > [!NOTE] -> **Preview.** The engine, plugin, touchscreen pages and factory presets are complete and pass the -> full test suite on x86 and under ARM emulation, but this build has not been verified on hardware -> yet. The plugin ID (`SbFc`), the file name (`subforce.so`) and the parameter list may still change -> before v0.1. +> **Preview (0.0.x).** The engine, plugin, touchscreen pages and factory presets are complete and +> pass the full test suite on x86 and under ARM emulation. On a Force (MPC OS 3.9) the release build +> installs, loads and benches at about 3% of a block; not every page has been played on the device +> yet. The parameter list may still change before v0.1: sounds are saved by name and survive that, +> but recorded automation (stored by parameter index) could then move a different control. + +![SubForce's FILTER page](docs/img/filter.png) + +*The FILTER page, rendered offline from the skin (on the device MPC fills in the values).* ## Highlights - **Two oscillators** with a continuously variable wave (triangle → saw → square → narrow pulse), 32' to 2', hard sync, a square **sub oscillator** (−1 or −2 octaves) and **noise** (white, pink or dark) -- **Mixer with feedback** — the mixer's output back into it, as on the Sub 37: thicker, then +- **Mixer with feedback** — the mixer's output back into it, as on the original: thicker, then gritty, then the chaos of an overdriven loop — and the classic habit of overdriving the filter when the levels are high - **4-pole transistor ladder filter**: 6, 12, 18 or 24 dB, resonance self-oscillating past 70% of the knob, the ladder's bass loss, **Multidrive** (asymmetric, tube-like warmth to hard clipping), keyboard tracking up to 200% - **Two DAHDSR envelopes** (filter, amp) with a linear attack, velocity, keyboard tracking, reset, - and a loop that runs through the release as the hardware's does + and a loop that runs through the release as the original's does - **Two mod busses**: triangle, square, saw, ramp, S&H, smooth random or the filter EG, free or locked to MPC's tempo, to pitch, cutoff and one more destination, scaled by the mod wheel, pressure or velocity @@ -35,7 +40,7 @@ DSP and presets are all SubForce's own. halfband decimator; worst aliasing −53 dB up to C7, hard sync −65 dB - **57 factory presets** in 7 categories, level-matched, many of them for [melodic techno](docs/USER_GUIDE.md#melodic-techno) (rolling basslines, resonant sequences, - big leads); user presets, Init and Randomize + big leads); user presets, favorites, a browser, Init and Randomize - **Light on the CPU**: one voice, about 2.8% of a block on the Force (3.6% with everything on), with NEON where the work is parallel @@ -43,43 +48,51 @@ Effects are deliberately left out: use MPC's insert effects on the track. ## Listen -`make demos` renders every factory preset and a filter sweep through the plugin's own entry -points into `build/demos-out/` (WAV), the way MPC plays it — no device needed. +From source, on Linux or WSL: `make demos` renders every factory preset and a filter sweep through +the plugin's own entry points into `build/demos-out/` (WAV, and all of them back to back as +`tour.wav`), the way MPC plays it — no device needed. ## Documentation | Document | What's in it | |---|---| -| [User guide](docs/USER_GUIDE.md) | The pages, the sound engine, presets, MIDI | -| [Building](docs/BUILDING.md) | Toolchain, make targets, tests, device bench, packaging | +| [User guide](docs/USER_GUIDE.md) | The pages, the sound engine, presets, melodic techno, MIDI | +| [Building](docs/BUILDING.md) | Toolchain, make targets, tests, device bench, packaging, release builds | | [Architecture](docs/ARCHITECTURE.md) | Source layout, the signal path, threads and real-time rules, saved state | | [Performance](docs/PERFORMANCE.md) | CPU budget, measurements, the DSP's measured quality | | [Roadmap](docs/ROADMAP.md) | What's done, what's next, decisions | +| [Changelog](CHANGELOG.md) | What changed in each release | ## Requirements - An **Akai Force**. Other first-generation (32-bit ARM) MPC OS devices may work but are untested. - **Root SSH access** to the device (for example through MockbaMod). Stock MPC OS has no way to install third-party plugins. -- **MPC OS 3.x** for the touchscreen pages. Release builds need glibc 2.31 or less, so MPC OS 2.x - loads them too, but it doesn't draw third-party plugin pages yet. A local build with a newer cross - compiler needs glibc 2.38 (MPC OS 3.x only; see [Building](docs/BUILDING.md#release-builds)). +- **MPC OS 3.x** (tested on 3.9). Release builds use glibc symbols up to 2.27 only, but they also + need GCC 11's libstdc++ (`GLIBCXX_3.4.29`), and MPC OS 2.x doesn't draw third-party plugin pages: + 2.x is untested. A local build with a newer cross toolchain needs glibc 2.38 (3.x only; see + [Building](docs/BUILDING.md#release-builds)). ## Installation -Use a release package (`SubForce--mpc-armv7.zip`), or build one with -`make plugin-package` (see [Building](docs/BUILDING.md)). Unzip it and follow the `INSTALL.md` -inside. In short: +Download a release package (`SubForce--mpc-armv7.zip`) from +[Releases](https://github.com/Devko/SubForce/releases), or build one with `make plugin-package` +(see [Building](docs/BUILDING.md)). Unzip it and follow the `INSTALL.md` inside. In short: ```sh scp -r SubForce- root@:/tmp/ -ssh root@ sh /tmp/SubForce-/install.sh +ssh -t root@ sh /tmp/SubForce-/install.sh ``` -The installer **stops MPC** (save your project first), copies the plugin into `/sdcard/Synths`, -backs up and edits `MPC.settings`, and starts MPC again. Running it again upgrades in place and -keeps your own presets and favorites. Then add **SubForce** to a track from MPC's instrument -plugins. +The installer asks for confirmation (`-y` skips it), **stops MPC** (save your project first), +copies the plugin to `/sdcard/Synths/Devko - VST - SubForce/`, backs up and edits `MPC.settings`, +and starts MPC again. Running it again upgrades in place and keeps your own presets (in +`Presets/User/` inside that folder) and favorites. Then add **SubForce** to a track from MPC's +instrument plugins. + +To uninstall, run the package's `uninstall.sh` the same way: it stops MPC, removes the plugin and +its `MPC.settings` entry (after a backup) and starts MPC again; your own presets and favorites stay +in the plugin folder (delete it to remove them too). ## Building from source @@ -91,15 +104,19 @@ make arm-plugin # build/arm/subforce.so for the device make plugin-package # dist/SubForce--mpc-armv7.zip ``` +Release packages come from CI (glibc 2.31, profile-guided, checked with the plugin catalog's own +checker): see [Building](docs/BUILDING.md#release-builds). + ## Status | Stage | | |---|---| -| Phase 0 — engine, plugin, pages, presets, tests, package | ✅ | +| Phases 0 and 1 — engine, plugin, pages, 57 presets, tests, package, the original's behaviour | ✅ | | Release build in CI (glibc 2.31, profile-guided, catalog-checked) | ✅ | | On the device: installs, loads and benches (`make bench-device`) | ✅ | +| 0.0.1 — first public preview (the plugin catalog's beta channel) | 🔜 | | On the device: play every page | 🔜 | -| v0.1 — first release, parameter list frozen | ⬜ | +| v0.1 — parameter list frozen (append-only from then on) | ⬜ | Details in the [roadmap](docs/ROADMAP.md). @@ -114,7 +131,8 @@ licenses (below). [PolyForce](https://github.com/Devko/PolyForce), MIT. - The ladder's cheap nonlinear zero-delay solution: Teemu "mystran" Voipio's "cheap non-linear zero-delay filters" (KVR forum, 2012). The decimator's structure and - coefficient formulas: Laurent de Soras's HIIR (WTFPL). + coefficient formulas: Laurent de Soras's HIIR (WTFPL). Pink noise: Paul Kellet's "economy" + filter. - Skin generator, previews and installer: [sd88me/mpc-vst-plugins](https://github.com/sd88me/mpc-vst-plugins) (MIT), vendored in `third_party/mpc-vst-plugins` with a few small, marked patches. @@ -122,5 +140,5 @@ licenses (below). Font License 1.1 (`surface/fonts/OFL.txt`). SubForce is an independent project, not affiliated with or endorsed by Moog Music, Akai -Professional / inMusic or Steinberg. Akai, Force and MPC are trademarks of inMusic Brands; VST is a -trademark of Steinberg Media Technologies GmbH. +Professional / inMusic or Steinberg, nor by any artist named in its documentation. Akai, Force and +MPC are trademarks of inMusic Brands; VST is a trademark of Steinberg Media Technologies GmbH. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f18bb6..cb92f6e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,8 +26,8 @@ flowchart LR - **`surface/surface.py`** is the single source of the parameter list and the touchscreen pages. It writes `params.json`, `layout.conf`, `vst.json`, `build/param_ids.h` (ids, value curves, - limits) and `build/factory_presets.h` (the factory presets, embedded), after checking the layout - and every preset (keys, ranges, names). + limits), `build/factory_presets.h` (the factory presets, embedded) and `build/skin_style.json` + (for `skin_polish.py`), after checking the layout and every preset (keys, ranges, names). - **`dsp/`** is the engine: no VST, no files, no threads. It renders a `Patch` for the keys it is given. - **`plugin/`** is everything between the engine and MPC: VST2 entry points, MIDI, parameters, @@ -44,7 +44,7 @@ flowchart LR | `dsp/halfband.h` | The 2x decimator (polyphase IIR halfband); `tools/halfband_design.py` designs it | | `dsp/env.h` | The DAHDSR envelope | | `dsp/mod.h` | The busses' sources, destinations, controls and synced rates | -| `dsp/fastmath.h` | exp2, tan, tanh(x)/x, softclip, random numbers | +| `dsp/fastmath.h` | exp2, log2, tan, tanh(x)/x, softclip, floor, random numbers | | `dsp/stages.h` | Stage timers for the profiling build (`-DSF_STAGE_TIMING`) | | `plugin/plugin.cpp` | VST2 glue: MIDI with sample offsets, transport, chunk state, denormal flush, CPU meter | | `plugin/surface.*` | The touchscreen side: parameter values, stepping, the preset browser, pushes to MPC | @@ -53,13 +53,16 @@ flowchart LR | `plugin/presets.*` | Factory and user presets | | `plugin/state.*` | The state text shared by projects and preset files | | `plugin/paths.*` | Plugin folder, preset roots, data folder, atomic file writes | +| `plugin/trace.*` | Device diagnostics: every `setParameter` logged while `/tmp/subforce.trace` exists ([Building](BUILDING.md#diagnostics-on-the-device)) | | `plugin/vst2.h` | A hand-written slice of the VST2 ABI (no Steinberg SDK) | +| `surface/skin_polish.py` | Redraws the knob strips, trigger buttons and stepper arrows after the skin generator | | `presets/Factory/` | Factory presets: `NN_Category/NN_Name.sfp`, a folder per browser category | | `test/` | The test suite (see [Building](BUILDING.md#tests)); `host.h` is a fake MPC host | | `tools/bench.cpp` | `sfbench`, the CPU bench: `dlopen()`s the `.so` like MPC and times every block | | `tools/pgo_train.cpp` | The trainer for the profile-guided build (runs under `qemu-arm`) | | `tools/demos.cpp` | Renders the presets to WAV, level-matches them (BS.1770 loudness) | -| `third_party/mpc-vst-plugins/` | Vendored skin generator and installer (MIT), with marked local patches | +| `third_party/mpc-vst-plugins/` | Vendored skin generator, installer and catalog checker (MIT), with marked local patches | +| `.github/workflows/build.yml` | CI: the test suites, the glibc 2.31 device build, the package and its catalog check; releases from `vX.Y.Z` tags | ## Signal path @@ -74,7 +77,7 @@ flowchart LR L --> D[Drive stage · asymmetric] --> V[VCA] --> DEC[2x decimator] --> OUT[Out L = R] ``` -The feedback is the Sub 37's: the mixer's own output back into its feedback channel, one high-rate +The feedback is the original's: the mixer's own output back into its feedback channel, one high-rate sample late, through that channel's overload (a cubic clipper), AC coupled at 150 Hz and band-limited at 7 kHz like an analog stage; its loop gain reaches unity at 85% of the knob. @@ -113,7 +116,7 @@ glides, busses, drift); a new note wakes it, every control value starting at its ## Talking to MPC -PolyForce's rules, device-proven in RackForce before it: +PolyForce's rules, device-proven on the Force: - MPC only notices value changes the plugin makes (lit browser tiles, the stepper, snapped steps) when they are pushed with `audioMasterAutomate`, and only re-reads texts after @@ -123,7 +126,8 @@ PolyForce's rules, device-proven in RackForce before it: - A value MPC sends is recorded as what MPC shows only after the plugin has acted on it, so a preset load in between never has the old value pushed back. - A Force sends every Q-Link detent, data-wheel click or drag event as the value it last read back - plus its step (sd88me/mpc-vst-plugins `docs/NOTES.md`, "Input probe", MPC OS 3.9.1). Steppers + plus its step ([sd88me/mpc-vst-plugins `docs/NOTES.md`](https://github.com/sd88me/mpc-vst-plugins/blob/main/docs/NOTES.md), + "Input probe", MPC OS 3.9.1). Steppers measure each event from the plugin's own value and move exactly one item, whatever the delta (Q-Link detent 1/128, data wheel 0.01, touch drag, fast spins); MPC echoing the plugin's own value back is ignored. @@ -136,9 +140,9 @@ PolyForce's rules, device-proven in RackForce before it: ## Parameters and saved state -- **Parameters** are free to change until v0.1, then **append-only**: MPC projects store values by - index. Sound parameters (kind `synth`) are saved and automatable; the surface's own values (the - stepper, tiles, Rand Amount) are not. +- **Parameters** may still change during 0.x (the previews); from v0.1 they are **append-only**: + MPC projects store values by index. Sound parameters (kind `synth`) are saved and automatable; + the surface's own values (the stepper, tiles, Rand Amount) are not. - **Saved state** (projects and `.sfp` preset files) is the text format `subforce 1`: `key=value` lines of *real* values (Hz, seconds, semitones…) plus, in a project, the preset key. Ranges can change without remapping saved projects. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index f1e6133..574e4ff 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -34,7 +34,8 @@ On Ubuntu 24.04, for example: sudo apt install g++ g++-arm-linux-gnueabihf make python3 python3-pil qemu-user ``` -`PY` must be the Python that Pillow is installed for (with Ubuntu's `python3-pil`, `/usr/bin/python3.12`). +`PY` must be a Python that has Pillow: the default `python3` works with Ubuntu's `python3-pil`; set it +for a virtual environment. ## Quick start @@ -60,18 +61,25 @@ changes; it checks the layout and every factory preset before writing anything. | `test` | The ASan/UBSan suite | | `test-arm` | The same suite built for the Force's CPU, run under `qemu-arm` | | `test-arm-pgo` | The suite linked against the profile-guided objects the shipped `.so` is made of | -| `demos` | Render every factory preset (a phrase per category) and a filter sweep to `build/demos-out/*.wav` (stereo, L = R, as the plugin plays) | +| `demos` | Render every factory preset (a phrase per category, below) and a filter sweep to `build/demos-out/*.wav` (stereo, L = R, as the plugin plays), and all of them back to back as `tour.wav` | | `preset-levels` | Set every factory preset's volume for `PRESET_LUFS` (default −18) on its demo phrase | | `bench` | x86 bench: only proves the bench and the profiling build work | | `arm-plugin` | `build/arm/subforce.so`; profile-guided when `qemu-arm` is installed | +| `arm-bench` | `build/arm/sfbench`, the CPU bench for the device | | `arm-bench-stages` | `build/arm/subforce_stages.so`, the profiling build (never shipped) | | `bench-device` | Run the CPU bench on a device (see [below](#benchmarking-on-the-device)) | | `plugin-package` | `dist/SubForce--mpc-armv7.zip` with the installer | -| `plugin-install` | Package, copy to the device and install (stops and restarts MPC) | +| `plugin-install` | Package, copy to the device and install without asking (`install.sh -y`: stops and restarts MPC) | | `clean` | Remove `build/` and `surface/build/` | `make` on its own runs the tests and builds the device `.so` and the x86 profiling build. +The demo phrases (`tools/demos.cpp`, 120 BPM), which `preset-levels` also matches the loudness on: +Templates and Bass, a 16th-note line with legato steps; Sequence, a 16th-note sequence with accents +and slides; Lead, a legato melody with the mod wheel; Keys, an 8th-note arpeggio; Pad, held two-note +chords; FX and any other category, held notes. A new category folder plays the FX phrase unless +`tools/demos.cpp` gets one for it. + ## Make variables | Variable | Meaning | @@ -82,7 +90,7 @@ changes; it checks the layout and every factory preset before writing anything. | `PGO` | `auto` (default): profile-guided when ARM programs can run here (`qemu-arm`, or natively); `1`: always; `0`: plain build | | `ARM_PREFIX` | The device toolchain's prefix (default `arm-linux-gnueabihf-`); empty for a native ARM build | | `ARM_RUN` | How ARM programs run here (default `qemu-arm -L /usr/arm-linux-gnueabihf`); empty on ARM | -| `PLUGIN_VERSION` | Version in the package name | +| `PLUGIN_VERSION` | Release version (default `0.0.1`): the zip's name, its `INSTALL.md` and the catalog manifest; CI sets it from the `vX.Y.Z` tag | | `BENCH_ARGS` | `sfbench` arguments for `bench-device` (default `-s 3`) | | `PRESET_LUFS` | The loudness `preset-levels` matches the factory presets to | @@ -99,16 +107,17 @@ PY = /usr/bin/python3.12 `make test` measures the engine directly and drives the whole plugin through its VST2 entry points against a fake MPC host (`test/host.h`), under AddressSanitizer and UndefinedBehaviorSanitizer (any -undefined behaviour fails the run). The tests give the surface a clock that moves a second per host -event, so stepping never depends on the machine's speed: +undefined behaviour fails the run). The host taps buttons and turns Q-Links the way a Force sends +them, and the tests give the surface a clock that moves a second per host event (a few ms within a +turn, `sft::Turn`), so stepping never depends on the machine's speed: | File | Covers | |---|---| -| `test/engine_test.cpp` | The math helpers' error bounds; the decimator's passband and stopband; every wave shape's aliasing, pitch and DC; a swept pulse width; sync, the sub (and its octave switch), the keyboard reset; the ladder's self-oscillation, slopes, bass loss, key tracking and drive; envelope timing, loop, reset, velocity; noise colour loudness; idling; stability with everything at full | -| `test/keys_test.cpp` | Note priority, multi and single trigger, re-striking a sounding key, the pedal, more keys than remembered, Duo, mode changes with keys down, glide (Rate, Time, Exp; Always, Legato; which oscillators; from the note's own sample) | +| `test/engine_test.cpp` | The math helpers' error bounds; the decimator's passband and stopband; every wave shape's aliasing, pitch and DC; a swept pulse width; sync, the sub (and its octave switch), the keyboard reset; the ladder's self-oscillation (the edge at 70%: 65% silent, 76% sings), slopes, bass loss, key tracking and drive; Multidrive's even harmonics; the mixer's feedback loop (level, grit, no subharmonics); envelope timing (the linear attack), loop through the release, reset, velocity; noise colour loudness (white, pink, dark); idling; stability with everything at full | +| `test/keys_test.cpp` | Note priority, multi and single trigger (Multi not retriggering when a release hands back to a held key, in Mono and Duo), re-striking a sounding key, the pedal, more keys than remembered, Duo, mode changes with keys down, glide (Rate, Time, Exp; Always, Legato; which oscillators; from the note's own sample) | | `test/mod_test.cpp` | The busses: every source, depth, rate, sync (free and locked to the bar), mod wheel / velocity / pressure, the filter EG as a source, every destination, Other Rate (on a locked bus too) | -| `test/preset_test.cpp` | Saved state round trips and bad input, presets (init, save, step, the ends, after RANDOM, missing files), user numbering, files appearing and renamed while running, the browser, favorites, stepping and the values pushed back, randomize, every factory preset playing | -| `test/plugin_test.cpp` | The VST2 basics, MIDI timing and mapping (pedal, mod wheel, pressure, bend both ways), pitch, octaves, CC 120 / 123, suspend, `process()` against `processReplacing`, floods of events and random patches | +| `test/preset_test.cpp` | Saved state round trips and bad input, presets (init, save, step, the ends, after RANDOM, missing files), user numbering, files appearing and renamed while running, the browser, favorites, stepping and the values pushed back (a Force Q-Link turn on the preset stepper: one preset per detent; a tile's release echo), randomize, every factory preset playing | +| `test/plugin_test.cpp` | The VST2 basics, MIDI timing and mapping (pedal, mod wheel, channel pressure and poly aftertouch on the sounding key only, bend both ways), pitch, octaves, CC 120 / 121 / 123, suspend, `process()` against `processReplacing`, floods of events and random patches | `make test-arm` runs the same suite cross-compiled for the Force's CPU under `qemu-arm` (no sanitizers): it catches 32-bit and ARM-only paths. @@ -148,21 +157,28 @@ device, so the build refuses CRLF line endings in them. make plugin-install FORCE=root@ ``` -Packages, copies the package to the device and runs its installer: it **stops MPC** (save your -project first), backs up and edits `MPC.settings`, and starts MPC again. A reinstall keeps the -user's presets and favorites/recent lists. +Packages, copies the package to the device and runs its installer without asking (`-y`): it **stops +MPC** (save your project first), backs up and edits `MPC.settings`, and starts MPC again. A reinstall +keeps the user's presets and favorites/recent lists. ## Release builds -Releases are built by CI (`.github/workflows/build.yml`) on every push, the way the plugin catalog's -own ports are built: the device build runs in `arm32v7/gcc:11-bullseye` (GCC 11, glibc 2.31) under -QEMU, profile-guided, with the test suite run against the objects the `.so` is linked from; the -sanitizer suite runs on x86. The zip is checked with the catalog's own checker -(`third_party/mpc-vst-plugins/tools/catalog_check.py --catalog`) and kept as the run's artifact. +CI (`.github/workflows/build.yml`) builds, tests and checks the release package on every push and +pull request, the way the plugin catalog's own ports are built: the device build runs in +`arm32v7/gcc:11-bullseye` (GCC 11, glibc 2.31) under QEMU, profile-guided, with the test suite run +against the objects the `.so` is linked from; the sanitizer suite runs on x86. The zip is checked +with the catalog's own checker (`third_party/mpc-vst-plugins/tools/catalog_check.py --catalog`) and +kept as the run's artifact (`SubForce-mpc-armv7`). + +Pushing a tag `vX.Y.Z` sets `PLUGIN_VERSION` from it and publishes the zip as a GitHub release, a +prerelease for `v0.*` (the catalog's beta channel), with `CHANGELOG.md`'s `## X.Y.Z` section as its +notes; a tag without that section fails before anything is published. To release: add the section, +then `git tag vX.Y.Z && git push origin vX.Y.Z`. The catalog finds new releases by itself (nightly). -Pushing a tag `vX.Y.Z` also publishes it as a GitHub release, a prerelease while the version is 0.x -(the catalog's beta channel). The catalog reads the major version as the parameter list's -compatibility: bump X whenever parameter indices change. +The catalog reads the major version as the parameter list's compatibility (`param_compat` = X). 0.x +releases are previews: parameter indices may still change between them, under the same +`param_compat` 0. From v0.1 the list is append-only; should indices ever have to change after that, +bump X. The same build outside CI, in an ARM environment: `make ARM_PREFIX= ARM_RUN= PGO=1 plugin-package` (`ARM_PREFIX` empty: the native compiler; `ARM_RUN` empty: ARM programs run directly). @@ -190,10 +206,11 @@ is cleared when the device restarts. - The `.so` exports only `VSTPluginMain` (a linker version script; the build counts every defined dynamic symbol and fails otherwise) and links with `--no-undefined`: an unresolved symbol would otherwise only show as MPC crashing on load. `-fno-gnu-unique` keeps it unloadable. -- The [release build](#release-builds) needs glibc 2.31 or less, so it loads on MPC OS 2.x and 3.x. - Built with a newer toolchain it needs that toolchain's glibc (Ubuntu 24.04: 2.38). The device build - prints the highest glibc version it needs, and `plugin-package` warns when it is over the - catalog's 2.32. +- The [release build](#release-builds) is linked against glibc 2.31 and needs symbols up to + GLIBC_2.27 only. Built with a newer toolchain it needs that toolchain's glibc (Ubuntu 24.04: + 2.38). The device build prints the highest glibc version it needs, and `plugin-package` warns when + it is over the catalog's 2.32. - libstdc++ is linked dynamically. GCC 11's (the release build's) needs `GLIBCXX_3.4.29` (the - floating-point `from_chars` the saved state is parsed with): MPC OS 3.x ships GCC 13's. The - catalog's checker reads only the glibc version. + floating-point `from_chars` the saved state is parsed with): MPC OS 3.x ships GCC 13's, so it is + there; whether MPC OS 2.x has it is unknown (2.x is untested, and doesn't draw the pages anyway). + The catalog's checker reads only the glibc version. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 8edf0dd..f0b52c0 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -13,22 +13,23 @@ MPC renders 128-frame blocks: **2902 µs per block**, per plugin instance. A plugin passes at **p99 ≤ 15%** and **max ≤ 50%** of the block (warns up to 35% / 80%), the rule PolyForce uses. -SubForce is one voice, so its cost hardly depends on the patch: a single sine and everything at -full (both oscillators, sub, noise, feedback, sync, full Multidrive, Duo) run the same loop. +SubForce is one voice, so its cost depends little on the patch: Init (one saw) and everything at +full differ by under 1% of the block; only noise and the feedback loop add work, and only while +they are up. ## Measurements -`make bench-device` on the Force (Cortex-A17 at 1.8 GHz, MPC running), percent of the 2902 µs block, -the profile-guided build: +`make bench-device` on the Force (Cortex-A17 at 1.8 GHz, MPC OS 3.9, MPC running; 2026-10-05), +percent of the 2902 µs block, the profile-guided build: -| Case | Phase 0 avg / p99 | now avg / p99 | +| Case | Phase 0 avg / p99 | 0.0.1 avg / p99 | |---|---|---| | idle (no note) | 0.38% / 0.62% | 0.17% / 0.36% | | Init, one held note | 3.64% / 4.09% | 2.79% / 3.31% | | heavy patch, Duo (noise and feedback on) | 3.70% / 4.19% | 3.55% / 4.07% | | heavy, retrig + glide every 50 ms | 3.75% / 4.28% | 3.60% / 4.15% | -The heavy patch now runs the Sub 37's feedback loop (the mixer's output back into it), which is +The heavy patch now runs the original's feedback loop (the mixer's output back into it), which is serial by nature; Init leaves noise and feedback off and pays for neither. The profiling build puts about 73 µs per block in the voice (Init) and 8–17 µs in the control @@ -56,8 +57,10 @@ steps (it reads the clock between stages, so it reads higher than the plain buil stops rendering, and the control grid only keeps time (glides, busses, drift) until a note wakes it; block sizes still never change the sound. - **Control rate:** modulation and glide every 8 samples, gliding in between; no libm calls - (`log2Fast`, `exp2Fast`, the cutoff note cached), multiplications instead of divisions. -- **Pay for what is on:** the pink filter and the feedback loop only run while their level is up. + (`log2Fast`, `exp2Fast`, the cutoff note and the fixed coefficients worked out ahead), + multiplications instead of divisions. +- **Pay for what is on:** the noise filters (pink, dark, the 30 Hz high-pass) and the feedback loop + only run while the noise or feedback level is up. - **Short polynomials** instead of libm for `exp2`, `log2`, `tan` and `tanh(x)/x`; a polyphase IIR decimator (8 multiplies per output sample) instead of a long FIR. - **Profile-guided** device build, trained under `qemu-arm` on a spread of patches and the factory @@ -69,26 +72,27 @@ From the test suite (`make test` prints these): | What | Measured | |---|---| -| Worst alias, any wave shape, C4..C7 | −53 dB against the strongest partial (triangle about −93 dB, saw −64 dB or lower, the narrowest pulse the worst) | +| Worst alias, any wave shape, C4..C7 | −53 dB against the strongest partial (the narrowest pulse the worst) | | Pulse width swept by a bus | every edge corrected: the largest step between two samples is 1.5 (an uncorrected edge is 2) | | Worst alias, hard sync | −65 dB | | Decimator | passband flat to 20 kHz within 0.001 dB, stopband from 24.2 kHz at −85 dB | -| Self-oscillation | past 70% of the knob, as the Sub 37's (65%: none; 76%: rms 0.07); tracks the cutoff 15–50 cents flat (110 Hz–7 kHz), as a ladder does | +| Self-oscillation | past 70% of the knob, as the original's (65%: none; 76%: rms 0.07); tracks the cutoff 15–50 cents flat (110 Hz–7 kHz), as a ladder does | | Slopes | 5.4 / 10.9 / 16.3 / 21.7 dB per octave between 880 Hz and 1.76 kHz with a 400 Hz cutoff (6/12/18/24 nominal, reached further up) | | Bass loss | 65% resonance (just under the edge): the passband drops by 13 dB, the ladder's 1 / (1 + r) | | Multidrive | 0 → 100%: +4 dB louder, much denser; at 50% a triangle's 2nd harmonic at −20 dB (asymmetric, tube-like), none clean | -| Feedback | 0 → 100%: +6 dB, the 9th harmonic +9 dB against the fundamental; no subharmonic motorboating (< −85 dB) | -| Noise colour | white, pink (−10 dB a decade, Kellet's filter), dark: within 0.3 dB of each other through the open filter | +| Feedback | 0 → 100%: +6 dB, the 9th harmonic +9 dB against the fundamental; no motorboating (an octave under the note: −101 dB) | +| Noise colour | white, pink (−10 dB a decade, Paul Kellet's filter), dark: within 0.3 dB of each other through the open filter | ## Considered and left out - **4x oversampling.** 2x with polyBLEP oscillators already puts aliasing far below the signal; 4x would double the voice's cost for the ladder's own nonlinear harmonics, which the halfband - removes above 24 kHz anyway. A quality switch can come later if the device bench leaves room. + removes above 24 kHz anyway. The device bench leaves room (3.6% of a block with everything on); + a quality switch can come if listening shows a need. - **Audio-rate modulation** (oscillator 2 or noise as a bus source for FM): needs the cutoff and - pitch computed per high-rate sample. Possible, at a cost; not in Phase 0. + pitch computed per high-rate sample. Possible, at a cost; not in 0.0.1. - **Compensating the bass loss.** The ladder's thinning with resonance is kept, as on the - hardware; Multidrive and the mixer make up for it. + original; Multidrive and the mixer make up for it. - **Hand-written assembly for the ladder.** GCC moves some vector lanes through core registers; a hand-scheduled ladder might save another ~30 ns a tick (~8% of the voice). Not worth giving up the one C++ source the x86 tests also run. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ef85f59..edab6a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,6 +3,7 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred - [What's next](#whats-next) +- [Phase 1](#phase-1) - [Phase 0](#phase-0) - [Planned](#planned) - [Deferred and not planned](#deferred-and-not-planned) @@ -12,16 +13,18 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred ## What's next +- 🔜 **0.0.1**, the first public preview: tag `v0.0.1` → GitHub prerelease → the plugin catalog's + beta channel ([sd88me/mpc-vst-plugins](https://github.com/sd88me/mpc-vst-plugins)). - 🔜 **On the device:** play every page, check the Q-Link sets and the preset browser (installs, loads and benches: [Performance](PERFORMANCE.md#measurements)). - 🔜 **Automation on the device:** whether MPC plays recorded automation of the stepped controls (octaves, slopes, modes) back through `setParameter`, and from which thread; the stepping logic treats events under 300 ms apart as one turn. -- 🔜 **Listening pass** on real speakers, against a Sub 37 if one is at hand: every factory +- 🔜 **Listening pass** on real speakers, against the original if one is at hand: every factory preset, the new feedback loop's range, Multidrive's asymmetry, the linear attack; tune voicing constants (`dsp/synth.cpp`: input gain, drive span and bias, feedback gain and clip, output gain) and the presets from it. -- ⬜ **v0.1**, the first release: parameter list frozen (append-only from then on). +- ⬜ **v0.1**: parameter list frozen (append-only from then on). ## Phase 1 @@ -35,7 +38,7 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred - ✅ Fixes: buttons and the preset stepper on the Force (PolyForce's device-run rules), Multi trigger on key releases, poly aftertouch, CC 121, flush-to-zero scope, per-instance random seeds, device diagnostics (`/tmp/subforce.trace`) -- ✅ Sub 37 behaviour, from its manuals: the mixer's own feedback loop, resonance self-oscillating +- ✅ The original's behaviour, from its manuals: the mixer's own feedback loop, resonance self-oscillating past 70%, asymmetric Multidrive, linear attack, loop through the release, pink noise - ✅ 30 more factory presets (57 in 7 categories), many for melodic techno @@ -45,7 +48,8 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred - ✅ Engine: two morphing polyBLEP oscillators (triangle → saw → square → 6% pulse), 32'–2', hard sync, square sub (−1 / −2 oct), noise with colour, keyboard reset, drift -- ✅ Mixer with feedback (post-VCA, DC-blocked) and the hot-mixer overdrive +- ✅ Mixer with feedback (post-VCA, DC-blocked; replaced in Phase 1 by the mixer's own loop) and the + hot-mixer overdrive - ✅ Nonlinear transistor ladder, zero-delay feedback, 6/12/18/24 dB taps, self-oscillation, bass loss, Multidrive (input gain + a second stage), key tracking to 200% - ✅ 2x oversampled voice with a polyphase IIR halfband decimator @@ -57,8 +61,8 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred - ✅ Plugin from PolyForce's groundwork: VST2 glue, touchscreen logic and stepping, preset library and browser, favorites, user presets, randomize, state text, CPU meter - ✅ Six touchscreen pages and Q-Link sets, an amber skin -- ✅ 27 factory presets in 5 categories, level-matched at −18 LUFS -- ✅ Test suite (360 checks, ASan/UBSan, and under `qemu-arm`), bench, profile-guided device build, +- ✅ 27 factory presets in 5 categories, level-matched at −18 LUFS (57 in 7 since Phase 1) +- ✅ Test suite (360 checks then, 469 now; ASan/UBSan, and under `qemu-arm`), bench, profile-guided device build, release package, demo renders - ✅ Review (DSP, plugin, build / tests / docs, in parallel): about 40 confirmed findings fixed, each with a check — among them pulse edges a moving width swept past, a sounding key struck again, @@ -70,7 +74,7 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred ## Planned - ⬜ **Audio-rate bus sources**: oscillator 2 and noise as mod sources (FM to pitch and cutoff). -- ⬜ **Quality switch**: 4x oversampling, if the device bench leaves room. +- ⬜ **Quality switch**: 4x oversampling, if listening shows a need (the device bench leaves room). - ⬜ **Arpeggiator / sequencer**: low priority — the Force sequences better than any plugin page can. - ⬜ **More presets** after the listening pass. @@ -87,6 +91,7 @@ Status: ✅ done · 🔜 next · ⬜ planned · 💤 deferred - 2026-10-05 — **Own repository**, the plugin groundwork taken from PolyForce, the engine new. - 2026-10-05 — **2x oversampling** for the whole voice (oscillators to VCA), polyBLEP oscillators. -- 2026-10-05 — **The ladder's bass loss kept** (no compensation), as on the hardware. +- 2026-10-05 — **The ladder's bass loss kept** (no compensation), as on the original. - 2026-10-05 — **Factory presets at −18 LUFS** on their demo phrase, peaks under −3 dBFS. -- Moog, Subsequent and other product names are not used in the plugin, its presets or its pages. +- Moog, Subsequent and other product names are not used in the plugin, its presets, its pages or + its docs (beyond the trademark notice): the docs say *the original*. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fa5e2e5..8800bfa 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -13,17 +13,18 @@ ## The idea -One voice, played like an analog monosynth: two oscillators and a sub into a mixer, a 4-pole -transistor ladder, two envelopes and two mod busses, with the panel's habits kept — push the mixer -and the filter overdrives, turn the resonance up and the bass thins out, take it past 70% and the -filter sings on its own. Where the Sub 37's manual says how its panel behaves, SubForce does the -same: the feedback loop, the resonance knob, Multidrive's character, the envelopes' attack and -loop, the pink noise. +One voice, played like a classic 37-key American paraphonic analog monosynth (*the original* in +these docs): two oscillators and a sub into a mixer, a 4-pole transistor ladder, two envelopes and +two mod busses, with the panel's habits kept — push the mixer and the filter overdrives, turn the +resonance up and the bass thins out, take it past 70% and the filter sings on its own. Where the +original's manual says how its panel behaves, SubForce does the same: the feedback loop, the +resonance knob, Multidrive's character, the envelopes' attack and loop, the pink noise. ## The screen -Six tabs. Every tab has the status line at the top: `VOICES 1 CPU 4% PEAK 6%` (the CPU the -plugin used in the last half second, of what one track may use). +Six tabs. Every tab has the status line at the top: `VOICES 1 CPU 3% PEAK 4%`. CPU is the +plugin's share of one core over the last half second (100% would be rendering taking as long as the +audio lasts), PEAK its slowest block in that time; VOICES is 0, 1 or 2 (Duo on two keys). | Tab | What's on it | |---|---| @@ -31,9 +32,18 @@ plugin used in the last half second, of what one track may use). | **FILTER** | Cutoff, resonance, Multidrive, filter EG amount, key track, the filter EG's velocity and key tracking, slope; the filter EG | | **AMP** | The amp EG; velocity, key tracking and volume | | **MOD** | Mod 1 and mod 2: source, sync, sync rate, control, trigger; rate, pitch amount and where it goes, filter amount, the third destination and its amount | -| **BROWSE** | Preset categories and presets; favorite, random, save, init | +| **BROWSE** | Preset categories and presets; favorite, a random preset, save, init | | **KEYS** | Mono / Duo, note priority, trigger; bend ranges; glide; the preset stepper, save, init, randomize | +The pages, rendered offline from the skin (on the device MPC fills in the values and lights the +chosen options): + +| | | +|---|---| +| ![OSC](img/osc.png) | ![FILTER](img/filter.png) | +| ![AMP](img/amp.png) | ![MOD](img/mod.png) | +| ![KEYS](img/keys.png) | | + Every tab has a Q-Link set named after what it controls (`OSC + MIX`, `FILTER`, `AMP`, `MOD 1+2`, `BROWSE`, `KEYS`). Stepped controls (octaves, slopes, modes, the preset stepper) move exactly one step per Q-Link detent or data-wheel click. @@ -50,8 +60,8 @@ step per Q-Link detent or data-wheel click. - **Hard Sync**: oscillator 2 restarts its cycle with oscillator 1's; move osc 2's frequency (by hand, or with a bus) for the tearing sync sound. Osc 1 can be silent in the mixer and still lead. - **Sub Octave**: the sub oscillator is a square one or two octaves under oscillator 1. -- **KB Reset**: On, every new note starts the oscillators at the beginning of their cycle (the same - punch every time); Off, they run free, as analog oscillators do. +- **KB Reset**: On, every note that starts the envelopes starts the oscillators at the beginning of + their cycle too (the same punch every time); Off, they run free, as analog oscillators do. - **Drift**: slow random pitch movement of each oscillator (up to ±6 cents) and of the cutoff, plus a small offset per note. 0 is perfectly stable. @@ -61,10 +71,10 @@ All oscillators are band-limited (polyBLEP) and run at twice the sample rate. Osc 1, Sub, Osc 2, Noise and **Feedback** levels. The knobs have an audio taper. Several sources up high drive the filter harder — on purpose. **Feedback** takes the mixer's own output back into -the mixer, as the Sub 37's FEEDBACK knob does with nothing in EXT IN: up to about 85% it thickens +the mixer, as the original's FEEDBACK knob does with nothing in EXT IN: up to about 85% it thickens and pushes the filter harder, past that the loop runs over unity and gets gritty, and the last -tenth is the chaos of an overdriven loop (+5 dB louder at full: turn the volume down). With -resonance it howls. **Noise Colour** goes from white (0) through pink (the middle, the Sub 37's +tenth is the chaos of an overdriven loop (about +6 dB louder at full: turn the volume down). With +resonance it howls. **Noise Colour** goes from white (0) through pink (the middle, the original's noise, the default) to dark (1), at the same loudness. ### Filter @@ -72,10 +82,10 @@ noise, the default) to dark (1), at the same loudness. A 4-pole transistor ladder, modelled with its nonlinearities. - **Cutoff** 20 Hz–20 kHz. **Resonance**: past 70% the filter oscillates by itself, a sine at the - cutoff, as the Sub 37's "settings above 7" (play it with Key Track at 100%; it tracks the - keyboard, a little flat at the top of the resonance, like the circuit). Resonance also thins the - bass, as on the hardware: about 13 dB just under the edge. -- **Multidrive**: drives the ladder and a second stage after it, as the Sub 37's OTA and FET stages + cutoff, as on the original, where "settings above 7" self-oscillate. Play it with Key Track at + 100%: it tracks the keyboard, a little flat at the top of the resonance, like the circuit. + Resonance also thins the bass, as on the original: about 13 dB just under the edge. +- **Multidrive**: drives the ladder and a second stage after it, as the original's OTA and FET stages between the filter and the VCA: asymmetric, tube-like warmth (even harmonics) in the middle of its range, toward hard clipping at full. Louder too, but by a few dB, not a jump. - **Slope**: 6, 12, 18 or 24 dB per octave (the outputs of the ladder's four stages). @@ -86,12 +96,12 @@ A 4-pole transistor ladder, modelled with its nonlinearities. ### Envelopes -Both are **DAHDSR**: Delay, Attack, Hold, Decay, Sustain, Release (attack to 10 s, the others to -10 s, delay and hold from 0). As the Sub 37's: the attack is linear (its default curve), decay and -release fall exponentially (the times are to −60 dB). +Both are **DAHDSR**: Delay, Attack, Hold, Decay, Sustain, Release (every stage up to 10 s; attack, +decay and release from 1 ms, delay and hold from 0). As on the original, the attack is linear (its +default curve); decay and release fall exponentially (the times are to −60 dB). - **Loop**: while the key is held the envelope cycles delay → attack → hold → decay → release and - round again, the release stage included, as on the hardware. With Sustain at 0 that is a plain + round again, the release stage included, as on the original. With Sustain at 0 that is a plain D-A-H-D cycle; with Sustain up, decay falls to it and release takes it the rest of the way. - **Reset**: a new note's attack starts from 0. Off, it starts from wherever the envelope is (smooth legato, the analog way). @@ -106,14 +116,14 @@ release fall exponentially (the times are to −60 dB). oscillator 2 the second. - **Trigger**: *Multi* restarts the envelopes on every key struck, a sounding key struck again too (held by the pedal, or repeated); releasing a key hands the oscillators back to one still held - without a new attack. *Single* only when the gate was closed (legato phrases glide on one + without a new attack. *Single* only when the gate was closed (legato phrases play on one envelope). - **Glide**: *Off*, *Always*, or *Legato* (only between overlapping keys). **Type**: *Rate* (the time per octave: big leaps take longer), *Time* (every glide takes the same time), *Exp* (exponential, fast then slow, like an RC). **Osc**: which oscillators glide. - **Bend Up / Down**: the pitch bend range, 0–24 semitones each way. - The sustain pedal keeps the last note sounding until it lifts. While it holds the gate open, the - next key plays legato, as on the hardware: Single doesn't restart the envelopes and Legato glide + next key plays legato, as on the original: Single doesn't restart the envelopes and Legato glide glides. ## The mod busses @@ -143,30 +153,36 @@ triangle to Wave 1 with the wave near square. level-matched (−18 LUFS on their demo phrase). Pick them on the BROWSE tab or step through them on KEYS. -- **SAVE** writes `User NNN.sfp` to `/Presets/User/` (there is no text entry on the - device, so presets are numbered; a number is never used twice). Rename them on a computer; the +- **SAVE** writes `User NNN.sfp` to `Presets/User/` in the plugin folder (`/sdcard/Synths/Devko - + VST - SubForce/`). There is no text entry on the device, so presets are numbered; a number is + never used twice. Rename them on a computer; the name shows in the browser. Files added, renamed or deleted while MPC runs show up when you browse (or in a new instance). - **INIT** loads Init: one saw through a half-open filter. - **RANDOM** moves the sound toward a random one by **Rand Amount**: oscillators, mixer, filter and the envelopes' main stages. Volume, the keyboard, glide and the busses stay. Rand Amount itself is a setting of the page, not part of a sound: presets don't save or change it. -- Presets on the SSD: `/media/AkaiForce/SubForce Presets/` (any folders inside become categories). +- **BROWSE**: the first two categories are FAVORITES and RECENT. The heart toggle marks the loaded + preset as a favorite. **RND** loads a random preset of the category shown (never the one loaded); + it isn't RANDOM, which changes the sound. +- Presets on the Force's internal drive: `/media/AkaiForce/SubForce Presets/` (any folders inside + become categories, loose files go to "Unsorted"; a folder named like a factory category shows as + " (files)"). - A preset file is plain text (`subforce 1` and `key=value` lines of real values), the same as an MPC project stores. ## Melodic techno -The Sub 37 is all over melodic techno, Stephan Bodzin's above all: two of them in his studio, a -Subsequent 37 on stage, "the backbone of his music and his live set" — basslines sequenced from -the computer, leads played by hand, and constant rides of cutoff, resonance, drive and glide. -These presets are built on what is documented about that way of playing (no artist's patch is -copied; the names are SubForce's own): +The original is all over melodic techno, Stephan Bodzin's above all: two of them in his studio, +its successor on stage, "the backbone of his music and his live set" (DJ Mag) — basslines +sequenced from the computer, leads played by hand, and constant rides of cutoff, resonance, drive +and glide. These presets are built on what is documented about that way of playing (no artist's +patch is copied, the names are SubForce's own, and no artist endorses SubForce): | Preset | What it is | Play it | |---|---|---| | **Bass / Rolling Sixteen** | Saw and sub, a 160 ms filter snap, KB Reset: the driving 16th bassline | 16ths from the sequencer, A1–D2; open FEG Decay through the build | -| **Bass / Horizon Drift** | Two detuned saws, the sub, feedback and resonance holding a growl under a low cutoff | Long legato roots, Bb0–F2; ride Cutoff and Resonance (FILTER Q-Links) | +| **Bass / Horizon Drift** | Two detuned saws, the sub, feedback and resonance holding a growl under a low cutoff | Long legato roots, Bb0–F2; ride Cutoff and Resonance (FILTER Q-Links 1 and 2) | | **Bass / Fifth Engine** | Saw plus a saw a fifth up, sub, feedback: the one-finger power chord | Legato, Bb0–C2; ride Osc 2 Freq between +7 and 0 | | **Bass / Glide Smear** | Constant-rate glide (big leaps slide longer) on a singing bass | Overlap keys to slide; ride Glide Time in the phrase | | **Bass / Grit Roller** | Multidrive and feedback; velocity drives it harder | Play the velocity: soft is round, hard is torn | @@ -179,13 +195,14 @@ copied; the names are SubForce's own): | **Pad / Tidal Loop** | A looping filter EG breathing every ~4 s | Hold one note | | **FX / Riser Engine** | A 4-bar synced rise of pitch, cutoff and feedback | Start it 4 bars before the drop | -The rest of the new ones are the Sub 37's classics: Foundation Bass, Sub Pressure, Offbeat Knock, -Knuckle Pluck, Glass Arpeggio, Sync Arpeggio, Duo Intervals, Solo Brass, Breath Flute, Tearing -Sync, Fuzz Square, Hollow Haze, Downlifter, Impact Boom, Feedback Howl, Data Burble, Loop Ticker. +The other presets of this set are classic patches of the original: Foundation Bass, Sub Pressure, +Offbeat Knock, Knuckle Pluck, Glass Arpeggio, Sync Arpeggio, Duo Intervals, Solo Brass, Breath +Flute, Tearing Sync, Fuzz Square, Hollow Haze, Downlifter, Impact Boom, Feedback Howl, Data Burble, +Loop Ticker. -Tips: 121–125 BPM; basslines live between about A0 and F2; put Cutoff, Resonance, Multidrive and -EG Amount on the FILTER Q-Links and ride them; use MPC's delay and reverb on the track (SubForce has -no effects of its own, on purpose). +Tips: 121–125 BPM; basslines live between about A0 and F2; ride Cutoff, Resonance, Multidrive and +EG Amount on the FILTER Q-Link set (Q-Links 1–4); use MPC's delay and reverb on the track (SubForce +has no effects of its own, on purpose). ## MIDI @@ -197,5 +214,7 @@ no effects of its own, on purpose). | Channel pressure | a bus's depth, if its control is Aftertouch | | Poly aftertouch | the same, from the sounding key's own pressure (what MPC's pads send) | | CC 64 | sustain pedal (re-striking a held key retriggers in Multi) | -| | A key is down or up: two note-ons for the same key and then one note-off end it, as on a keyboard | +| Note on for a key already down | restrikes it (Multi retriggers); one note-off then releases it: a key is either down or up, as on a keyboard | | CC 120 / 121 / 123 | all sound off / reset all controllers (bend, wheel, pressure, pedal) / all notes off | + +SubForce listens on every MIDI channel. diff --git a/docs/img/amp.png b/docs/img/amp.png new file mode 100644 index 0000000000000000000000000000000000000000..e04eb4e65ac09cc34f58b32a961c530aed058d51 GIT binary patch literal 26634 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�P16IJiE6rhFdr7yE9$x%LnVW`z{$z! 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 literal 0 HcmV?d00001 diff --git a/docs/img/keys.png b/docs/img/keys.png new file mode 100644 index 0000000000000000000000000000000000000000..0161a0019872b0261fc054c7a43b6ee8d6803968 GIT binary patch literal 36814 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 z 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
#oMb1(Len&*Ynr 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 literal 0 HcmV?d00001 diff --git a/dsp/env.h b/dsp/env.h index b7601d7..41c16c0 100644 --- a/dsp/env.h +++ b/dsp/env.h @@ -1,8 +1,8 @@ #pragma once -// The DAHDSR envelope: Delay, Attack, Hold, Decay, Sustain, Release, as the Sub 37's: a linear +// The DAHDSR envelope: Delay, Attack, Hold, Decay, Sustain, Release, as the original's: a linear // attack (its default; EXP ATTACK is an option there), decay settling exponentially on the // sustain level, release falling to -80 dB. Loop, while the key is held: delay -> attack -> hold -// -> decay -> release, and round again, the release stage included as on the hardware ("delay, +// -> decay -> release, and round again, the release stage included as on the original ("delay, // attack, hold, decay, and release stages will loop continuously"): with sustain at 0 it is // D-A-H-D; with sustain up, decay falls to it and release takes it the rest of the way. #include "fastmath.h" diff --git a/dsp/synth.cpp b/dsp/synth.cpp index 2ba32cc..3083216 100644 --- a/dsp/synth.cpp +++ b/dsp/synth.cpp @@ -54,7 +54,8 @@ constexpr float kNoiseComp[17] = {2.000000f, 1.728349f, 1.428936f, 1.182656f, 0. Synth::Synth(float sampleRate) : sr_(sampleRate), osr_(sampleRate * kOversample), invOsr_(1.0f / (sampleRate * kOversample)), - invSr_(1.0f / sampleRate), driftK_(1.0f / (0.6f * sampleRate)) { + invSr_(1.0f / sampleRate), driftK_(1.0f / (0.6f * sampleRate)), + fbK_(1.0f - std::exp(-2.0f * kPi * kFeedbackLp / (sampleRate * kOversample))) { setTransport(120.0, 0.0, false, false); setPatch(Patch{}); } @@ -560,7 +561,6 @@ void Synth::renderRun(float* out, int n) { const bool sync = patch_.sync; const float nk = noiseK_, np = noisePink_, nc = noiseComp_; const float fbR = 1.0f - 2.0f * kPi * kFeedbackHp * invOsr_; // the feedback loop's AC coupling - const float fbK = 1.0f - std::exp(-2.0f * kPi * kFeedbackLp * invOsr_); // the loop's bandwidth // Multidrive's second stage: softclip(g x + b) - softclip(b), tube-like (even harmonics) at // moderate drive, toward symmetric hard clipping at full. const float bias = driveBias_, biasOut = softclip(bias); @@ -616,13 +616,13 @@ void Synth::renderRun(float* out, int n) { } // The mixer, its feedback channel taking the mixer's own output back in (one sample // late): through that channel's overload (a cubic: no division in the loop), AC - // coupled and band-limited, as the Sub 37's FEEDBACK knob does with nothing in EXT IN. + // coupled and band-limited, as the original's FEEDBACK knob does with nothing in EXT IN. // Over unity loop gain it saturates: grit, then the howl of an overdriven loop. if (fbOn) { mix += l4 * fbIn_; // c - c^3 / 3: slope 1 at 0, flat at +-1, where it reads 2/3 of the scale: kFeedbackClip. const float c = clampf(mix * (1.0f / (1.5f * kFeedbackClip)), -1.0f, 1.0f); - fbLp_ += (1.5f * kFeedbackClip * c * (1.0f - (1.0f / 3.0f) * c * c) - fbLp_) * fbK; + fbLp_ += (1.5f * kFeedbackClip * c * (1.0f - (1.0f / 3.0f) * c * c) - fbLp_) * fbK_; fbY1_ = fbLp_ - fbX1_ + fbR * fbY1_; fbX1_ = fbLp_; fbIn_ = fbY1_; diff --git a/dsp/synth.h b/dsp/synth.h index a5538c2..32819d5 100644 --- a/dsp/synth.h +++ b/dsp/synth.h @@ -4,7 +4,7 @@ // Osc 1 (+ square sub) ─┐ // Osc 2 (hard sync) ────┤ mixer ─┬─► Multidrive ─► 4-pole ladder (6/12/18/24 dB) ─► drive ─► VCA ─► out // Noise (white..pink..dark) ────┤ │ -// └── feedback ◄┘ (the mixer's own output back into it, as on the Sub 37) +// └── feedback ◄┘ (the mixer's own output back into it, as on the original) // // Mono, or Duo (paraphonic: each oscillator its own key, one filter and VCA). Two DAHDSR // envelopes (filter, amp), two mod busses (an LFO or the filter envelope to pitch, cutoff and @@ -44,7 +44,7 @@ constexpr int kOctaveMin = -2; // 32' .. 2' (8' = 0) constexpr int kOctaveMax = 2; constexpr float kResMax = 4.6f; // ladder feedback at full resonance (self-oscillation from ~4) constexpr float kResEdge = 0.7f; // the knob where it reaches 4: "settings above 7 cause the filter - // to self-oscillate" (the Sub 37's manual) + // to self-oscillate" (the original's manual) // Resonance knob 0..1 -> ladder feedback: 0..4 up to kResEdge, on to kResMax at full. inline float resFeedback(float k) { @@ -82,7 +82,7 @@ struct Patch { float osc2Semis = 0.0f; // Osc 2 frequency against osc 1, -7..+7 semitones bool sync = false; // Osc 2 hard-synced to osc 1 int subOctave = SO_ONE; - float noiseColor = 0.5f; // 0 white .. 0.5 pink (the Sub 37's) .. 1 dark + float noiseColor = 0.5f; // 0 white .. 0.5 pink (the original's) .. 1 dark bool kbReset = false; // oscillators restart their cycle at each new note float drift = 0.25f; // 0..1 analog pitch and cutoff drift // Mixer, 0..1 (audio taper). Several sources up high drive the filter, as on the hardware. @@ -206,6 +206,7 @@ class Synth { float sr_, osr_, invOsr_, invSr_; float driftK_; // a drift's one-pole step per sample (0.6 s) + float fbK_; // the feedback loop's one-pole step (its bandwidth) at the 2x rate float cutNote_ = 0.0f; // the cutoff knob, as a note Patch patch_; bool havePatch_ = false; diff --git a/plugin/patch_map.cpp b/plugin/patch_map.cpp index a21177d..3453541 100644 --- a/plugin/patch_map.cpp +++ b/plugin/patch_map.cpp @@ -141,7 +141,7 @@ std::string paramDisplay(int id, float n) { std::snprintf(b, sizeof b, "%+.2f oct", oct); break; } - case Fmt::Noise: // white .. pink (the Sub 37's) at the middle .. dark + case Fmt::Noise: // white .. pink (the original's) at the middle .. dark if (v < 0.005f) return "White"; if (std::fabs(v - 0.5f) < 0.005f) return "Pink"; if (v < 0.5f) std::snprintf(b, sizeof b, "Pink %.0f%%", 200.0f * v); diff --git a/surface/surface.py b/surface/surface.py index 0d01aca..a0af65b 100644 --- a/surface/surface.py +++ b/surface/surface.py @@ -131,7 +131,7 @@ def popup_flag(of): num("mix_o2", "Osc 2 Level", "lin", 0, 1, 0, "pct") num("mix_noise", "Noise Level", "lin", 0, 1, 0, "pct") num("mix_fb", "Feedback", "lin", 0, 1, 0, "pct") -num("noise_color", "Noise Colour", "lin", 0, 1, 0.5, "noise") # 0 white, 0.5 pink (the Sub 37's), 1 dark +num("noise_color", "Noise Colour", "lin", 0, 1, 0.5, "noise") # 0 white, 0.5 pink (the original's), 1 dark # --- filter (dsp/ladder.h) --- SLOPES = ["6 dB", "12 dB", "18 dB", "24 dB"] # dsp/synth.h Slope diff --git a/test/engine_test.cpp b/test/engine_test.cpp index 5ec8f1d..1341063 100644 --- a/test/engine_test.cpp +++ b/test/engine_test.cpp @@ -255,7 +255,7 @@ void testLadder() { std::printf(" resonance 100%%, cutoff %5.0f Hz: oscillates at %.1f Hz (%+.0f ct), rms %.3f\n", hz, f, cents, rms(x)); CHECK(std::fabs(cents) < 60.0 && rms(x) > 0.03 && rms(x) < 0.5); } - // The edge at 70% of the knob, as the Sub 37's "settings above 7 cause the filter to + // The edge at 70% of the knob, as the original's "settings above 7 cause the filter to // self-oscillate": under it, no oscillation of its own; over it, it sings. p.cutoffHz = 1000.0f; p.res = 0.65f; @@ -300,7 +300,7 @@ void testLadder() { const double d1 = rms(play(d, 36, 16384)); std::printf(" Multidrive 0 -> 100%%: %+.1f dB\n", 20 * std::log10(d1 / d0)); CHECK(d1 > d0 && 20 * std::log10(d1 / d0) < 9.0); - // Multidrive's asymmetry, the Sub 37's "tube-like warmth": a triangle (odd harmonics only) + // Multidrive's asymmetry, the original's "tube-like warmth": a triangle (odd harmonics only) // picks up even ones at moderate drive, none clean. auto evenDb = [](float drive) { Patch t = plain(); @@ -314,19 +314,21 @@ void testLadder() { CHECK(e0 < -60.0 && e5 > -45.0); // Feedback, the mixer's output back into it: louder and grittier (more upper harmonics) as it // comes up, bounded. - auto fb = [](float level, double* hf) { + auto fb = [](float level, double* hf, double* sub) { Patch t = plain(); t.cutoffHz = 2000.0f; t.mixFeedback = level; const auto x = play(t, 36, 44100, 22050); - *hf = toneAmp(x, 65.41 * 9) / toneAmp(x, 65.41); // the 9th harmonic against the fundamental + *hf = toneAmp(x, 65.41 * 9) / toneAmp(x, 65.41); // the 9th harmonic against the fundamental + *sub = toneAmp(x, 65.41 / 2) / toneAmp(x, 65.41); // an octave under: a loop motorboating return rms(x); }; - double h0 = 0, h1 = 0; - const double f0 = fb(0.0f, &h0), f1 = fb(1.0f, &h1); - std::printf(" feedback 0 -> 100%%: %+.1f dB, 9th harmonic %+.1f dB against the fundamental\n", - 20 * std::log10(f1 / f0), 20 * std::log10(h1 / h0)); + double h0 = 0, h1 = 0, s0 = 0, s1 = 0; + const double f0 = fb(0.0f, &h0, &s0), f1 = fb(1.0f, &h1, &s1); + std::printf(" feedback 0 -> 100%%: %+.1f dB, 9th harmonic %+.1f dB against the fundamental, subharmonic %.0f dB\n", + 20 * std::log10(f1 / f0), 20 * std::log10(h1 / h0), 20 * std::log10(s1)); CHECK(f1 > f0 && 20 * std::log10(f1 / f0) < 15.0 && 20 * std::log10(h1 / h0) > 3.0 && std::isfinite(f1)); + CHECK(20 * std::log10(s1) < -60.0); } void testEnvelopes() { diff --git a/test/keys_test.cpp b/test/keys_test.cpp index ae15860..705a569 100644 --- a/test/keys_test.cpp +++ b/test/keys_test.cpp @@ -85,7 +85,7 @@ void testPriority() { r.run(1323); CHECK(r.s.info().ampEnv > 0.5f); // 30 ms into the linear 50 ms attack // Back to a key still held when the newer one lifts: the oscillators move, the envelopes - // don't start again (Multi retriggers on key presses, as on the hardware). + // don't start again (Multi retriggers on key presses, as on the original). r.run(22050); r.s.noteOn(55, 100); r.run(22050); diff --git a/third_party/mpc-vst-plugins/README.md b/third_party/mpc-vst-plugins/README.md index e855761..5b95ced 100644 --- a/third_party/mpc-vst-plugins/README.md +++ b/third_party/mpc-vst-plugins/README.md @@ -13,7 +13,9 @@ What we use it for (we do NOT link their `vst2_wrap.c` / `engine.h` wrapper — | `tools/gen_vst.py`, `shadow_skin.py`, `skin_assets.py`, `params.py`, `shadow_art.c`, `vendor/force-shadow/` | `params.json` + `layout.conf` -> `params.h`, the touchscreen skin (`TUI.json`, `Q-Links.json`, PNGs) and the `pluginList-arm` entry | | `tools/studio.py`, `studio_web.py` | preview skin pages as PNG; browser layout editor | | `tools/release.py`, `tools/release/*` | release zip + on-device `install.sh` / `uninstall.sh` (stop MPC, back up and edit `MPC.settings`) | -| `wrapper/popup.h`, `wrapper/plugin_dir.h` | popup-picker param helpers; find the plugin's own folder at runtime | +| `tools/catalog_check.py` | the plugin catalog's own check of the release zip (CI runs it with `--catalog`) | + +`wrapper/` is vendored too but not used: the plugin finds its folder itself (`plugin/paths.cpp`). Do not edit files here beyond the marked patches; patch around them so an upgrade stays a plain re-copy. @@ -34,3 +36,9 @@ are (SubForce uses patches 1-4; it has no meters). slider's (`sh_meter_x.png`) for a post-step to redraw (PolyForce's wave view: `surface/skin_polish.py`). With the browser renderer a meter still needs `strip=`. Re-apply them after re-copying upstream. + +## Local patch (SubForce) + +6. `tools/release.py` (marked `SubForce local patch 6`): the generated `INSTALL.md` gave every + `--user-data` entry a trailing slash, files too (`preset_favorites.txt/`); now only folders get one. + Worth sending upstream. diff --git a/third_party/mpc-vst-plugins/tools/release.py b/third_party/mpc-vst-plugins/tools/release.py index a2b93bb..9db9581 100644 --- a/third_party/mpc-vst-plugins/tools/release.py +++ b/third_party/mpc-vst-plugins/tools/release.py @@ -112,7 +112,8 @@ extra_md = "".join("- `%s` (data next to the plugin)\n" % e for e in extras) user_md = ("\nYour own files go in %s inside the plugin folder (`/sdcard/Synths/%s/`); the installer keeps them when you upgrade " "and uninstall, and moves them there from the old `/sdcard/vst` location if you had installed the plugin that way.\n" - % (", ".join("`%s/`" % d for d in a.user_data), skin_name)) if a.user_data else "" + % (", ".join("`%s`" % (d if "." in os.path.basename(d) else d + "/") for d in a.user_data), # SubForce local patch 6: files get no slash + skin_name)) if a.user_data else "" install_md = """# {name} {ver} {about}A native MPC OS plugin ({kind}) with its own MPC screen skin, loaded by MPC's built-in plugin host.