From 5d40afa0725947a410712061fb9a0f85564a6ff6 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 07:47:56 +0200 Subject: [PATCH] feat(dsp): v0.2.0 deep-dive 2-band -> 3-band rebuild + M2 preset system Research-driven rebuild (docs/design-brief.md, docs/research-notes.md), restructuring the signal path from a 2-band (low/high) split to a genuine 3-band (low/mid/high) split matching the reference class's own defining architecture, plus the suite-wide M2 preset system. Topology: - Two cascaded LR4 crossovers replace the single split: Split Low (60-400 Hz, default 120 Hz, was Crossover Frequency) peels off the Low band; Split High (300-2000 Hz, default 600 Hz, NEW) splits the remainder into Mid and High. Clamped >=1/3 octave apart (src/dsp/SplitGap.h). - New Mid band (src/dsp/MidBand.h/.cpp): staged/cascaded drive-only saturation (0-100%, default 30%) + Level, no filter/tone/blend, its own 4x-oversampled shaping stage. - High band's pre-drive highpass ("Tight", 20-500 Hz, default 100 Hz) is promoted from a Razor-only fixed 200 Hz constant to a first-class, voicing-independent control applied ahead of all three voicings. - Low-band compressor ballistics re-sourced to the reference class's fixed "glue" values: ratio 4:1->2:1, attack 10->3ms, release 120->6ms (range floor 10->5ms). Retires the "New York style" framing. - IR loader relocated to process only the Mid+High post-sum signal, never the Low band - structurally enforced and tested (tests/LowBandIsolationTests.cpp). - EQ default corner frequencies re-anchored: 100->80Hz, 2500->2800Hz, 8000->5000Hz (dormant unless EQ is enabled). - Added src/dsp/PhaseAlignFilter.h: a naive cascade of two independent LR4 crossovers does not flat-sum on its own (up to -10dB deviation measured at close split ratios) - fixed with an allpass compensation filter on the Low band, proven algebraically in docs/architecture.md, not just empirically. - Lossy v0.1.x state migration: the old crossoverFreq value maps to the new Split High parameter, clamped into its 300-2000Hz range (v0.1.x's 250Hz default lands exactly at the 300Hz floor - dedicated regression test). M2 preset system (ported from basilica-audio/nave's pilot): - src/presets/PresetManager.{h,cpp}, src/presets/PresetBar.{h,cpp}, src/presets/Localisation.{h,cpp} (copied verbatim per the replication recipe). - Nine factory presets (presets/factory/*.json, docs/presets.md). - German localisation of the preset UI frame (resources/i18n/de.txt). - CMakeLists.txt: juce_add_binary_data(CryptaBinaryData ...), links juce_gui_basics + CryptaBinaryData into SharedCode. - PluginEditor now docks a PresetBar above the existing generic editor. Tests: 53 -> 96 Catch2 cases (all v0.1.1 regression coverage kept green; new: SplitGapTests, MidBandTests, ThreeBandFlatSumTests, StateMigrationTests, LowBandIsolationTests, TopologyRobustnessTests, PresetManagerTests, plus Voicing/GainStaging/Latency/Parameter/State test updates for the new topology). Docs rewritten: docs/manual.md, docs/architecture.md, README.md, CHANGELOG.md (v0.2.0 entry). docs/design-brief.md and docs/research-notes.md added as the binding brief for this rebuild. Version bumped to 0.2.0. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 25 ++ CMakeLists.txt | 28 +- README.md | 77 ++-- docs/architecture.md | 81 +++- docs/design-brief.md | 315 ++++++++++++++ docs/manual.md | 125 ++++-- docs/presets.md | 20 + docs/research-notes.md | 249 +++++++++++ presets/factory/cabColoredGrind.json | 17 + presets/factory/cleanLowLoudTop.json | 16 + presets/factory/cutThrough.json | 16 + presets/factory/default.json | 20 + presets/factory/definitionOnly.json | 18 + presets/factory/fuzzWall.json | 17 + presets/factory/glueAndGrind.json | 20 + presets/factory/subLock.json | 19 + presets/factory/throat.json | 17 + resources/i18n/de.txt | 21 + src/PluginEditor.cpp | 37 +- src/PluginEditor.h | 16 +- src/PluginProcessor.cpp | 323 +++++++++++--- src/PluginProcessor.h | 138 ++++-- src/dsp/MidBand.cpp | 79 ++++ src/dsp/MidBand.h | 66 +++ src/dsp/PhaseAlignFilter.h | 89 ++++ src/dsp/SplitGap.h | 35 ++ src/dsp/Voicing.cpp | 52 +-- src/dsp/Voicing.h | 44 +- src/params/ParameterIds.h | 24 +- src/params/ParameterLayout.cpp | 84 +++- src/presets/Localisation.cpp | 25 ++ src/presets/Localisation.h | 38 ++ src/presets/PresetBar.cpp | 243 +++++++++++ src/presets/PresetBar.h | 53 +++ src/presets/PresetManager.cpp | 564 +++++++++++++++++++++++++ src/presets/PresetManager.h | 246 +++++++++++ tests/GainProcessingTests.cpp | 17 +- tests/GainStagingTests.cpp | 39 +- tests/LatencyTests.cpp | 36 +- tests/LowBandIsolationTests.cpp | 159 +++++++ tests/MidBandTests.cpp | 176 ++++++++ tests/ParameterTests.cpp | 73 ++-- tests/PresetManagerTests.cpp | 609 +++++++++++++++++++++++++++ tests/SplitGapTests.cpp | 67 +++ tests/StateMigrationTests.cpp | 172 ++++++++ tests/StateTests.cpp | 18 +- tests/ThreeBandFlatSumTests.cpp | 192 +++++++++ tests/TopologyRobustnessTests.cpp | 113 +++++ tests/VoicingTests.cpp | 56 +++ 49 files changed, 4655 insertions(+), 329 deletions(-) create mode 100644 docs/design-brief.md create mode 100644 docs/presets.md create mode 100644 docs/research-notes.md create mode 100644 presets/factory/cabColoredGrind.json create mode 100644 presets/factory/cleanLowLoudTop.json create mode 100644 presets/factory/cutThrough.json create mode 100644 presets/factory/default.json create mode 100644 presets/factory/definitionOnly.json create mode 100644 presets/factory/fuzzWall.json create mode 100644 presets/factory/glueAndGrind.json create mode 100644 presets/factory/subLock.json create mode 100644 presets/factory/throat.json create mode 100644 resources/i18n/de.txt create mode 100644 src/dsp/MidBand.cpp create mode 100644 src/dsp/MidBand.h create mode 100644 src/dsp/PhaseAlignFilter.h create mode 100644 src/dsp/SplitGap.h create mode 100644 src/presets/Localisation.cpp create mode 100644 src/presets/Localisation.h create mode 100644 src/presets/PresetBar.cpp create mode 100644 src/presets/PresetBar.h create mode 100644 src/presets/PresetManager.cpp create mode 100644 src/presets/PresetManager.h create mode 100644 tests/LowBandIsolationTests.cpp create mode 100644 tests/MidBandTests.cpp create mode 100644 tests/PresetManagerTests.cpp create mode 100644 tests/SplitGapTests.cpp create mode 100644 tests/StateMigrationTests.cpp create mode 100644 tests/ThreeBandFlatSumTests.cpp create mode 100644 tests/TopologyRobustnessTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec6c26..f6e727b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-07-16 + +### Changed (headline: 2-band → 3-band topology rebuild) + +- **Restructured the signal path from a 2-band (low/high) split to a genuine 3-band (low/mid/high) split**, matching the reference class's own defining architecture ("bass, mids, and high frequencies are processed separately" - see `docs/design-brief.md`/`docs/research-notes.md` for the full sourcing). Two cascaded 4th-order Linkwitz-Riley crossovers replace v0.1.x's single split: `Split Low` (60–400 Hz, default 120 Hz, was `Crossover Frequency`) peels off the Low band; `Split High` (300–2000 Hz, default 600 Hz, NEW) further splits the remainder into Mid and High. `Split High` is always clamped at least 1/3 octave above `Split Low` (`src/dsp/SplitGap.h`) to prevent a degenerate near-zero-width Mid band. +- **Added a new Mid band** (`src/dsp/MidBand.h/.cpp`): staged/cascaded drive-only saturation (0-100%, default 30%) plus an independent output Level, no filter/tone/blend - matching the reference class's own Mid band exactly. Runs its own 4x-oversampled shaping stage. +- **Promoted the High band's pre-drive highpass ("Tight") from a Razor-only fixed 200 Hz internal constant to a first-class, voicing-independent control** (20-500 Hz, default 100 Hz), applied ahead of all three voicings (Gnaw/Wool/Razor) - closing the gap identified in the design brief's "Why v1 falls short" analysis. +- **Re-sourced the low-band parallel compressor's ballistics defaults** to the reference class's own fixed, documented "glue" bus-compressor values: ratio 4:1 → **2:1**, attack 10 ms → **3 ms**, release 120 ms → **6 ms** (release range floor lowered from 10 ms to 5 ms, a breaking pre-1.0 change, so the sourced default is reachable). Retires the v0.1.x manual's "New York style" framing, which characterized the wrong sub-genre of parallel compression for what the reference class actually implements - see `docs/research-notes.md` §3-4. +- **Relocated the IR loader (cab-sim convolution)** to process only the Mid+High post-sum signal, never the Low band - matching the reference class's "low band bypasses the cabsim" architecture. Structurally enforced (not just conventional) and directly tested (`tests/LowBandIsolationTests.cpp`): the Low band's own isolated output is bit-exact identical whether the IR loader is on or off, or which IR is loaded. +- **Re-anchored the post-sum 4-band EQ's default corner frequencies** to a sourced bass-tone-stack frequency set from the same design lineage as the reference class: Low Shelf 100 → **80 Hz**, Peak 2 2500 → **2800 Hz**, High Shelf 8000 → **5000 Hz** (Peak 1's existing 500 Hz default already matched the sourced anchor). Dormant-until-engaged (EQ ships off by default), so not an audible v0.1.x → v0.2.0 change unless a user or preset turns the EQ on. +- **Added a phase-alignment allpass filter for the Low band** (`src/dsp/PhaseAlignFilter.h`), required to make the new cascaded (not parallel) crossover topology actually flat-sum - discovered and fixed during this rebuild: a naive cascade of two independent LR4 crossovers does *not* flat-sum on its own (deviations up to −10 dB were measured at close `Split Low`/`Split High` ratios before this fix). The fix is proven algebraically, not just empirically - see `docs/architecture.md`'s "Cascaded 3-band flat-sum and phase alignment" section for the full derivation. This is a genuine engineering necessity this rebuild surfaced, not something anticipated by the original design brief. +- **Lossy state migration for v0.1.x sessions**: the old single `Crossover Frequency` (`crossoverFreq`) parameter is migrated to the new `Split High` parameter on load, clamped into its 300–2000 Hz range. v0.1.x's shipped default (250 Hz) sits below that floor, so the single most common migration path - an untouched v0.1.x session - lands exactly at the 300 Hz floor (dedicated regression test in `tests/StateMigrationTests.cpp`). `Split Low` and every new Mid-band/Tight parameter fall back to their v0.2.0 defaults; any low-band compressor values a user had explicitly changed away from v0.1.x's old defaults are preserved as-is. +- Docs rewritten to match: `docs/manual.md`, `docs/architecture.md`, `README.md` (signal-flow diagrams, parameter tables, feature list). `docs/design-brief.md` and `docs/research-notes.md` added (the binding brief and its sourcing for this rebuild). + +### Added (M2 preset system) + +- Suite-wide M2 preset system (`.scaffold/specs/preset-system-m2.md`), ported from `basilica-audio/nave`'s pilot implementation: `src/presets/PresetManager.{h,cpp}` (factory + user presets, save/save-as/delete/rename, default resolution, import/export of single presets and zip banks, dirty-state tracking) and `src/presets/PresetBar.{h,cpp}` (a horizontal preset strip docked at the top of the editor). +- Nine factory presets (`presets/factory/*.json`, category `Init`/`Bass`) - see `docs/presets.md` for what each demonstrates: **Default**, **Glue & Grind**, **Sub Lock**, **Throat**, **Fuzz Wall**, **Cut Through**, **Definition Only**, **Clean Low, Loud Top**, **Cab-Colored Grind**. +- German localisation of the preset UI frame (`resources/i18n/de.txt`), auto-selected via `SystemStats::getUserLanguage()`. Core/DSP terminology (parameter names, units) is never translated. +- User presets stored at `~/Library/Audio/Presets/Yves Vogl/Crypta/` (macOS) / `%APPDATA%\Yves Vogl\Crypta\Presets\` (Windows). + +### Fixed + +- Discovered and fixed during the topology rebuild: a naive cascade of two independent LR4 crossovers does not flat-sum (see the phase-alignment entry above) - this was never an issue in v0.1.x's single-crossover topology, so it is not a regression from any prior release, but a new correctness requirement introduced by the 3-band rebuild itself. + ## [0.1.1] - 2026-07-16 ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index df76b88..e3d5907 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum macOS deployment ta # here at the top level avoids it being enabled implicitly deeper inside # JUCE's CMake helpers, which has been observed to break Ninja's generate # step (missing CMAKE_C_COMPILE_OBJECT rule variable). -project(Crypta VERSION 0.1.1 LANGUAGES C CXX) +project(Crypta VERSION 0.2.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -56,6 +56,30 @@ juce_add_plugin(Crypta ICON_BIG "docs/assets/icon.png" ) +# ============================================================================== +# Embedded assets: M2 preset system factory presets (presets/factory/*.json) +# and the M2 i18n frame's German translation (resources/i18n/de.txt). See +# src/presets/PresetManager.h and src/presets/Localisation.h - both consume +# these purely via BinaryData:: symbols passed in from PluginProcessor.cpp/ +# PluginEditor.cpp, never by including BinaryData.h themselves, so they stay +# portable to sibling plugins (see docs/preset-system-notes.md in +# basilica-audio/nave, the M2 pilot this suite-wide pattern was replicated +# from). +# ============================================================================== + +juce_add_binary_data(CryptaBinaryData SOURCES + presets/factory/default.json + presets/factory/glueAndGrind.json + presets/factory/subLock.json + presets/factory/throat.json + presets/factory/fuzzWall.json + presets/factory/cutThrough.json + presets/factory/definitionOnly.json + presets/factory/cleanLowLoudTop.json + presets/factory/cabColoredGrind.json + resources/i18n/de.txt +) + # ============================================================================== # SharedCode: an INTERFACE target carrying the plugin's sources, definitions, # and library links so that both the plugin targets (AU/VST3/Standalone) and @@ -86,9 +110,11 @@ target_compile_definitions(SharedCode INTERFACE target_link_libraries(SharedCode INTERFACE juce::juce_audio_utils juce::juce_dsp + juce::juce_gui_basics juce::juce_recommended_config_flags juce::juce_recommended_lto_flags juce::juce_recommended_warning_flags + CryptaBinaryData ) target_link_libraries(Crypta PRIVATE SharedCode) diff --git a/README.md b/README.md index 5c75d8b..7bfa0cc 100644 --- a/README.md +++ b/README.md @@ -6,56 +6,60 @@ > Formerly **Twist Your Guts**, renamed to Crypta as part of the suite's move to Basilica Audio naming (the crypt: the basilica's low-end foundation). If you have a v0.1.0-era session referencing the old plugin identity (`com.yvesvogl.twistyourguts`, plugin code `Tygt`), see the [Unreleased] entry in [`CHANGELOG.md`](CHANGELOG.md) — the new bundle ID and plugin code (`com.yvesvogl.crypta`, `Cryp`) mean DAWs treat this as a new plugin, so existing sessions will need to be re-pointed at the new plugin. -[![CI](https://github.com/metal-up-your-ass/Crypta/actions/workflows/ci.yml/badge.svg)](https://github.com/metal-up-your-ass/Crypta/actions/workflows/ci.yml) +[![CI](https://github.com/basilica-audio/Crypta/actions/workflows/ci.yml/badge.svg)](https://github.com/basilica-audio/Crypta/actions/workflows/ci.yml) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -> **Work in progress.** Crypta is pre-1.0 and under active development (v0.1.0). Binaries for macOS and Windows are available from the [Releases](../../releases) page (macOS builds are signed & notarized); building from source works too. Expect breaking changes until v1.0.0 ships (see [Roadmap](#roadmap)). +> **Work in progress.** Crypta is pre-1.0 and under active development (v0.2.0). Binaries for macOS and Windows are available from the [Releases](../../releases) page (macOS builds are signed & notarized); building from source works too. Expect breaking changes until v1.0.0 ships (see [Roadmap](#roadmap)). ## What it is -Crypta is a Parallax-style bass plugin built on JUCE 8. It splits your bass signal into low and high bands with a linear-phase-adjacent Linkwitz-Riley crossover, compresses the low band in parallel, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and an impulse-response (cab sim) loader. See [`docs/manual.md`](docs/manual.md) for the full parameter reference and usage tips. +Crypta is a Parallax-style bass plugin built on JUCE 8. As of v0.2.0 it splits your bass signal into **three** bands — low, mid, and high — with two cascaded 4th-order Linkwitz-Riley crossovers, compresses the low band in parallel, drives the mid band with staged saturation, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and an impulse-response (cab sim) loader. v0.2.0 also adds a full preset system (factory + user presets, import/export, German localisation). See [`docs/manual.md`](docs/manual.md) for the full parameter reference and usage tips, and [`docs/design-brief.md`](docs/design-brief.md)/[`docs/research-notes.md`](docs/research-notes.md) for the research behind the v0.2.0 topology rebuild. ## Features -- **Noise gate** — full-band, ahead of the crossover split -- **LR4 crossover band-split** — 4th-order Linkwitz-Riley split, adjustable 60 Hz – 1000 Hz (default 250 Hz) -- **Low band**: parallel ("New York style") compressor with makeup gain, wet/dry mix, and output level -- **High band**: three distortion voicings, each 4x oversampled to keep aliasing under control, with independent drive/tone +- **Noise gate** — full-band, ahead of both crossover splits +- **Two cascaded LR4 crossover splits** — Split Low (60–400 Hz, default 120 Hz) and Split High (300–2000 Hz, default 600 Hz), building a genuine 3-band (low/mid/high) topology, replacing v0.1.x's 2-band split +- **Low band**: parallel "glue" compressor (re-sourced fast/gentle ballistics, ratio 2:1 / attack 3 ms / release 6 ms) with makeup gain, wet/dry mix, and output level +- **Mid band** (NEW): staged/cascaded drive-only saturation, no filter/tone/blend — a distinct "throatier" character separate from the high band +- **High band**: three distortion voicings, each 4x oversampled to keep aliasing under control, now with a shared, voicing-independent **Tight** pre-drive highpass (was Razor-only in v0.1.x) - **Gnaw** — op-amp-style hard clip - **Wool** — cascaded soft-clip fuzz with a mid scoop - - **Razor** — tight overdrive: pre-clip highpass, soft clip, mid hump - - Clean/distorted blend control per voicing, plus output level -- **4-band EQ** post-sum (LowShelf / Peak / Peak / HighShelf) -- **IR loader** (cabinet simulation) on the output stage — convolution engine is live; bundled factory IRs and a GUI file browser land in a later milestone -- **Delay-compensated signal path** — the high band's oversampling latency is reported to the host and the low band is time-aligned to match -- **Presets** with full state save/recall *(planned — a dedicated preset manager/versioning scheme is a later milestone; APVTS state save/load already round-trips today)* + - **Razor** — tight overdrive: soft clip, mid hump + - Clean/distorted blend control, plus drive/tone/output level +- **4-band EQ** post-sum (LowShelf / Peak / Peak / HighShelf), re-anchored default frequencies (80/500/2800/5000 Hz, sourced from the same design lineage's hardware tone stack) +- **IR loader** (cabinet simulation), relocated in v0.2.0 to process only the Mid+High post-sum signal — the low band never passes through it, matching the reference class's own architecture. Convolution engine is live; bundled factory IRs and a GUI file browser land in a later milestone +- **Delay-compensated, phase-aligned signal path** — the Mid+High branch's shared oversampling latency is reported to the host, the low band is time-aligned to match, and a phase-alignment allpass filter keeps the cascaded three-way sum flat-magnitude +- **Presets** — factory + user presets, save/save-as/delete, import/export (single files and zip banks), German localisation of the preset UI frame +- **State migration** — a v0.1.x session's single crossover frequency is migrated to the new Split High parameter on load - **Metering** throughout the signal chain *(planned, alongside the custom GUI)* ## Signal flow ``` -Input Trim → Gate → LR4 Split (60–1000 Hz, default 250 Hz) +Input Trim → Gate → LR4 Split Low (60–400 Hz, default 120 Hz) │ - ┌─────────────┴─────────────┐ - │ │ - Low band High band - Parallel Comp Voicing → Drive → Tone → Blend - → Makeup → Mix │ - │→ Level → Level - └─────────────┬─────────────┘ - │ - Sum (delay-compensated) - │ - 4-band EQ - │ - IR loader - │ - Safety Clip (optional) - │ - Output Trim + ┌─────────────┴───────────────────────────────┐ + │ │ + Low band Remainder → LR4 Split High (300–2000 Hz, default 600 Hz) + Parallel Comp → Level │ + │ ┌───────────────────┴───────────────────┐ + │ Mid band High band + │ Drive → Level Tight → Voicing → Drive → Tone → Blend → Level + │ └───────────────────┬───────────────────┘ + │ Mid+High sum → IR loader (cab sim) + │ │ + └──────────── Phase-align + delay ───────────────┘ + │ + Sum (delay-compensated) + │ + 4-band EQ + │ + Safety Clip (optional) + │ + Output Trim ``` -The high band runs 4x oversampled for the distortion stage; the low band is delay-compensated to stay time-aligned with it before the sum. See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the latency-compensation strategy, and [`docs/manual.md`](docs/manual.md) for the full parameter reference. +The Mid and High bands each run 4x oversampled (identically configured, so their latencies match exactly); the low band is delay-compensated and phase-aligned to stay both time- and magnitude-flat with them before the sum. See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the latency-compensation strategy and the phase-alignment proof, and [`docs/manual.md`](docs/manual.md) for the full parameter reference. ## Parameters @@ -65,9 +69,10 @@ See [`docs/manual.md`](docs/manual.md) for the complete, musically-annotated par |---|---| | IO / Global | Input Gain, Output Gain, Bypass, Safety Clip | | Noise Gate | Enable, Threshold, Ratio, Attack, Release | -| Crossover | Frequency (60–1000 Hz) | +| Crossover | Split Low (60–400 Hz), Split High (300–2000 Hz) | | Low band | Comp Threshold/Ratio/Attack/Release/Makeup/Mix, Level | -| High band | Voicing (Gnaw/Wool/Razor), Drive, Tone, Blend, Level | +| Mid band | Drive, Level | +| High band | Tight, Voicing (Gnaw/Wool/Razor), Drive, Tone, Blend, Level | | EQ | Enable, Low Shelf Freq/Gain, Peak 1 Freq/Gain/Q, Peak 2 Freq/Gain/Q, High Shelf Freq/Gain | | IR loader | Enable, Mix | @@ -111,7 +116,7 @@ ctest --test-dir build --output-on-failure |---|---|---| | M0 | Bootstrap — project skeleton, CI, docs | Done | | M1 | DSP completion & test coverage — gate, crossover, parallel compressor, 3 voicings (oversampled), 4-band EQ, IR loader, latency compensation, broadened test suite | Done (v0.1.0) | -| M2 | Presets & state recall — preset manager, factory presets, versioned state | Planned | +| M2 | Deep-dive topology rebuild (2-band → 3-band) + presets & state recall — preset manager, 9 factory presets, state migration, German localisation | Done (v0.2.0) | | M3 | GUI & accessibility — custom LookAndFeel, metering UI, accessibility pass | Planned | | M4 | Release: signing, notarization, v1.0.0 — installers, tagged release | Planned | @@ -132,4 +137,4 @@ Tagged releases (`v*`) are built and published automatically by [`.github/workfl - **macOS** — AU (`.component`), VST3 (`.vst3`), and Standalone, Universal Binary (arm64 + x86_64), signed with a Developer ID Application certificate (org-level secrets, shared across the Basilica Audio suite), notarized, and stapled. Installs and opens without a Gatekeeper warning. - **Windows** — VST3 and Standalone, **unsigned**. On first run, Windows SmartScreen may show a "Windows protected your PC" warning; choose **More info → Run anyway** to proceed. A signed Windows build is a documented future improvement, not yet available. -See [`v0.1.0`](https://github.com/metal-up-your-ass/Crypta/releases/tag/v0.1.0) for the most recent published release (under the plugin's prior identity as Twist Your Guts); the next tagged release under the Crypta identity is planned as `v0.2.0`. +See [`v0.1.1`](https://github.com/basilica-audio/Crypta/releases/tag/v0.1.1) for the most recent published release; `v0.2.0` is the next tagged release, shipping the 2-band → 3-band topology rebuild and the M2 preset system. diff --git a/docs/architecture.md b/docs/architecture.md index c0d476b..15f76d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,56 +6,97 @@ flowchart LR IN[Input] --> TRIM[Input Trim] TRIM --> GATE[Noise Gate] - GATE --> SPLIT[LR4 Crossover Split
60–1000 Hz, default 250 Hz] + GATE --> SPLIT1[LR4 Split Low
60-400 Hz, default 120 Hz] - SPLIT -->|Low band| LCOMP[Parallel Compressor
+ Makeup + Mix] + SPLIT1 -->|Low band| LCOMP[Parallel Compressor
+ Makeup + Mix] LCOMP --> LLEVEL[Level] + LLEVEL --> LALIGN[Phase-Align Allpass
tied to Split High] - SPLIT -->|High band| HVOICE[Voicing:
Gnaw / Wool / Razor
4x oversampled] + SPLIT1 -->|Remainder| SPLIT2[LR4 Split High
300-2000 Hz, default 600 Hz] + + SPLIT2 -->|Mid band| MDRIVE[Drive
oversampled] + MDRIVE --> MLEVEL[Level] + + SPLIT2 -->|High band| HTIGHT[Tight
pre-drive HPF] + HTIGHT --> HVOICE[Voicing:
Gnaw / Wool / Razor
4x oversampled] HVOICE --> HDRIVE[Drive] HDRIVE --> HTONE[Tone] HTONE --> HBLEND[Clean/Dist Blend] HBLEND --> HLEVEL[Level] - LLEVEL --> SUM[Sum
delay-compensated] - HLEVEL --> SUM + MLEVEL --> MHSUM[Mid+High Sum] + HLEVEL --> MHSUM + MHSUM --> IR[IR Loader
cab sim - Mid+High only] + + LALIGN --> SUM[Sum
delay-compensated] + IR --> SUM SUM --> EQ[4-band EQ] - EQ --> IR[IR Loader
cab sim] - IR --> CLIP[Safety Clip
optional] + EQ --> CLIP[Safety Clip
optional] CLIP --> OUT[Output Trim] ``` -Both bands re-converge at the `Sum` stage. The low band carries a compensation delay so it stays time-aligned with the oversampled high band (see [Latency compensation](#latency-compensation) below). +All three bands re-converge at the `Sum` stage. The Low band carries a compensation delay (matching the Mid/High branch's shared oversampling latency) and a phase-alignment allpass filter (see [Cascaded 3-band flat-sum](#cascaded-3-band-flat-sum-and-phase-alignment) below) so the three-way sum is both time-aligned and magnitude-flat. ## Module map | Directory | Responsibility | |---|---| -| `src/dsp` | All audio-thread DSP, each in its own class with a matching Catch2 test file: `Crossover` (LR4 split/sum), `NoiseGateStage` (full-band input gate), `ParallelCompressor` (low-band dynamics), `Voicing` (high-band Gnaw/Wool/Razor distortion + 4x oversampling + tone + blend), `BandEQ` (post-sum 4-band EQ), `IRLoader` (cab-sim convolution). `RealtimeCoefficients.h` is a shared helper for updating `juce::dsp::IIR` filter coefficients from the audio thread without heap allocation (see below). No allocation, locks, or I/O once `prepareToPlay` has run. | +| `src/dsp` | All audio-thread DSP, each in its own class with a matching Catch2 test file: `Crossover` (LR4 split/sum, two cascaded instances - `lowSplit`, `midHighSplit`), `PhaseAlignFilter` (Low-band phase-alignment allpass - see below), `NoiseGateStage` (full-band input gate), `ParallelCompressor` (low-band dynamics), `MidBand` (mid-band staged drive, NEW in v0.2.0), `Voicing` (high-band Gnaw/Wool/Razor distortion + Tight pre-drive HPF + 4x oversampling + tone + blend), `BandEQ` (post-sum 4-band EQ), `IRLoader` (cab-sim convolution, relocated in v0.2.0 to the Mid+High branch), `SplitGap` (pure `clampSplitHighHz()` helper enforcing the minimum musical gap between the two crossover splits). `RealtimeCoefficients.h` is a shared helper for updating `juce::dsp::IIR` filter coefficients from the audio thread without heap allocation (see below). No allocation, locks, or I/O once `prepareToPlay` has run. | | `src/params` | Parameter layout and `AudioProcessorValueTreeState` definitions — parameter IDs, ranges, defaults, and value-to-DSP mapping. Single source of truth for what a preset captures. | -| `src/state` | Plugin state serialization: preset save/load, versioned state migration, `getStateInformation`/`setStateInformation` glue. Depends on `src/params` for what to persist, not on `src/dsp` directly. Not yet built out beyond the base APVTS state save/load already wired in `PluginProcessor` - the dedicated preset manager/versioning scheme is a later milestone. | -| `src/ui` | Editor/GUI code. Talks to the processor only through `src/params` (attachments) and read-only metering data — never reaches into `src/dsp` internals directly. Placeholder generic JUCE UI until the custom vector GUI lands in a later milestone. | +| `src/presets` | M2 preset system (`PresetManager`, `PresetBar`, `Localisation`) - see [Preset system](#preset-system-m2) below. | +| `src/ui` | Editor/GUI code. Talks to the processor only through `src/params` (attachments) and read-only metering data — never reaches into `src/dsp` internals directly. Placeholder generic JUCE UI (plus the M2 preset bar) until the custom vector GUI lands in a later milestone (M3). | + +Dependency direction is one-way: `src/ui` → `src/params` ← `src/presets`, and `src/dsp` is driven by `src/params` values but has no upward dependency on UI, presets, or state code. This keeps the DSP core testable in isolation (see `tests/`) without instantiating any UI or persistence machinery. + +## Cascaded 3-band flat-sum and phase alignment + +v0.2.0 restructures the signal path from a single 2-band LR4 split to two **cascaded** LR4 splits (Low, then Mid/High of the remainder) - see `docs/design-brief.md`'s Topology section for the design rationale. A single `cryp::Crossover`'s own dual-output Low+High sum is exactly a flat-magnitude allpass-filtered version of *that stage's own input* (`juce::dsp::LinkwitzRileyFilter`'s documented property). But naively cascading two such stages does **not** automatically make the three-way sum flat relative to the *original* input - confirmed empirically while building `tests/ThreeBandFlatSumTests.cpp`: deviations up to −10 dB appeared at close `splitLowHz`/`splitHighHz` ratios (worst inside the first crossover's own transition band), because the second crossover's own phase shift is applied only to the Mid+High branch, leaving the untouched Low band "out of phase" with it at the final sum. + +The fix (`src/dsp/PhaseAlignFilter.h`) is proven algebraically, not just empirically: reading JUCE 8.0.14's own `juce_LinkwitzRileyFilter.cpp` source confirms the dual-output `processSample(channel, input, outputLow, outputHigh)`'s `outputLow + outputHigh` sum uses the *exact same formula* (built only from the first internal biquad stage's state) as that same class's single-output `processSample(channel, input)` in `Type::allpass` mode. Since that allpass transform is linear: + +``` +Low_compensated + Mid + High + = Allpass2(Low) + Allpass2(Remainder) [Mid+High = Allpass2(Remainder), by midHighSplit's own reconstruction property] + = Allpass2(Low + Remainder) [Allpass2 is linear] + = Allpass2(Input) [Low + Remainder = Input exactly, lowSplit's own reconstruction property] +``` + +and `Allpass2(Input)` has flat magnitude relative to `Input` by definition (an allpass filter's magnitude response is unity at every frequency). `PhaseAlignFilter` wraps a *second, physically separate* `juce::dsp::LinkwitzRileyFilter` instance configured `Type::allpass`, always tied to the *same* effective cutoff as `midHighSplit` (via `cryp::clampSplitHighHz()` - see below), applied to the Low band right after its own compressor+level processing, before the final sum. This is a standard technique in professional N-way active-crossover design (phase-alignment allpass networks between cascaded crossover stages), not a Crypta-specific invention. + +## Minimum-gap clamp between Split Low and Split High -Dependency direction is one-way: `src/ui` → `src/params` ← `src/state`, and `src/dsp` is driven by `src/params` values but has no upward dependency on UI or state code. This keeps the DSP core testable in isolation (see `tests/`) without instantiating any UI or persistence machinery. +`splitLowHz`'s own range (60-400 Hz) overlaps `splitHighHz`'s own range (300-2000 Hz), so the two parameters could in principle collapse the Mid band to a degenerate near-zero (or inverted) width. `src/dsp/SplitGap.h`'s `cryp::clampSplitHighHz(splitLowHz, requestedSplitHighHz)` is a pure, real-time-safe function enforcing a minimum 1/3-octave gap: `midHighSplit` (and `lowBandPhaseAlign`, which must always match it exactly) are always set to `clampSplitHighHz()`'s *effective* value, never the raw `splitHighHz` parameter directly. `PluginProcessor`, the test suite, and the two DSP-level tests exercise the same function, so the boundary behaviour is directly testable (`tests/SplitGapTests.cpp`, `tests/ThreeBandFlatSumTests.cpp`). ## Real-time-safe filter coefficient updates -`juce::dsp::IIR::Coefficients::makeLowShelf`/`makePeakFilter`/`makeHighShelf`/... (the usual way to build filter coefficients) heap-allocate a new `Coefficients` object on every call - fine in `prepareToPlay()`, not fine on the audio thread when a parameter (an EQ band's frequency, the high-band voicing's mid-filter, its tone control, ...) is being automated continuously. `BandEQ` and `Voicing` both use `juce::dsp::IIR::ArrayCoefficients::makeXxx()` instead, which returns the same coefficients as a stack-only `std::array` (zero allocation), and `src/dsp/RealtimeCoefficients.h` writes that array's values directly into an already-allocated `Coefficients` object's raw storage (normalising by `a0` the same way `Coefficients`' own constructor does). The `Coefficients` object itself is allocated exactly once, during `prepare()`; every subsequent update on the audio thread only ever overwrites existing memory. +`juce::dsp::IIR::Coefficients::makeLowShelf`/`makePeakFilter`/`makeHighShelf`/... (the usual way to build filter coefficients) heap-allocate a new `Coefficients` object on every call - fine in `prepareToPlay()`, not fine on the audio thread when a parameter (an EQ band's frequency, the high-band voicing's mid-filter, its tone or Tight control, ...) is being automated continuously. `BandEQ` and `Voicing` both use `juce::dsp::IIR::ArrayCoefficients::makeXxx()` instead, which returns the same coefficients as a stack-only `std::array` (zero allocation), and `src/dsp/RealtimeCoefficients.h` writes that array's values directly into an already-allocated `Coefficients` object's raw storage (normalising by `a0` the same way `Coefficients`' own constructor does). The `Coefficients` object itself is allocated exactly once, during `prepare()`; every subsequent update on the audio thread only ever overwrites existing memory. -## IR loader safe-by-default behaviour +## IR loader safe-by-default behaviour and relocation -`juce::dsp::Convolution` falls back to an internal single-sample identity impulse response when `loadImpulseResponse()` has never been called - but that fallback's assumed source sample rate is hardcoded to JUCE's `ProcessSpec` default (44100 Hz), so at any *other* session sample rate it would otherwise get silently resampled (smeared/attenuated) against a mismatched rate. `IRLoader::prepare()` closes that gap by explicitly loading a correctly-rate-tagged identity impulse response itself, so "no IR loaded" is a guaranteed bit-exact passthrough at every session sample rate, not only at 44100 Hz - see the class-level comment in `src/dsp/IRLoader.h`. +`juce::dsp::Convolution` falls back to an internal single-sample identity impulse response when `loadImpulseResponse()` has never been called - but that fallback's assumed source sample rate is hardcoded to JUCE's `ProcessSpec` default (44100 Hz), so at any *other* session sample rate it would otherwise get silently resampled (smeared/attenuated) against a mismatched rate. `IRLoader::prepare()` closes that gap by explicitly loading a correctly-rate-tagged identity impulse response itself, so "no IR loaded" is a guaranteed bit-exact passthrough at every session sample rate, not only at 44100 Hz - see the class-level comment in `src/dsp/IRLoader.h`. This behaviour is unchanged in v0.2.0; what changed is *where* `IRLoader` sits in the signal path - it now processes only the Mid+High post-sum signal, structurally never the Low band (see `PluginProcessor.cpp`'s `processChunk()` and `tests/LowBandIsolationTests.cpp`, which asserts the Low band's own isolated output is bit-exact identical whether the IR loader is enabled or not). ## Latency compensation -The high band's voicing stage (`cryp::Voicing`) runs its nonlinear waveshaping oversampled 4x (`juce::dsp::Oversampling`, FIR half-band equiripple, max quality, integer latency), which introduces processing latency that the low band does not incur. This is the *only* source of latency in the current signal path - the gate, low-band parallel compressor, EQ, and IR loader (configured for zero-latency convolution) are all zero-latency by construction. To keep the two bands phase-coherent at the `Sum` stage: +The Mid band's staged drive (`cryp::MidBand`) and the High band's voicing (`cryp::Voicing`) each run their own nonlinear shaping stage oversampled 4x (`juce::dsp::Oversampling`, FIR half-band equiripple, max quality, integer latency) - two *physically separate* `juce::dsp::Oversampling` instances, identically configured (same factor exponent, same filter type), so their reported latencies are guaranteed numerically equal by construction even though they are not literally one shared object (see `src/dsp/MidBand.h`'s class docs for why literal instance-sharing was not implemented). This oversampling is the *only* source of latency in the current signal path - the gate, both crossovers, the phase-alignment allpass, the low-band parallel compressor, the EQ, and the IR loader (configured for zero-latency convolution) are all zero-latency by construction. To keep all three bands phase-coherent at the `Sum` stage: -- The low band path carries a matching `juce::dsp::DelayLine` (integer/no-interpolation, since the delay is always a whole number of samples) sized to the high band's oversampling latency. -- The high band's own clean/distorted blend (`highBlend`) is handled by a `juce::dsp::DryWetMixer` whose dry path is *also* delay-compensated (`setWetLatency`) by that same amount, so the clean and distorted high-band signals stay phase-coherent with each other too, not just with the low band. +- The Low band path carries a matching `juce::dsp::DelayLine` (integer/no-interpolation, since the delay is always a whole number of samples) sized to `juce::jmax(midBand.getLatencySamples(), highVoicing.getLatencySamples())`. +- The High band's own clean/distorted blend (`highBlend`) is handled by a `juce::dsp::DryWetMixer` whose dry path is *also* delay-compensated (`setWetLatency`) by that same amount, so the clean and distorted high-band signals stay phase-coherent with each other too, not just with the Low band. The Mid band has no blend control (see `src/dsp/MidBand.h`), so it needs no such compensation. -`CryptaAudioProcessor::computeTotalLatencySamples()` reports `Voicing::getLatencySamples()` to the host via `setLatencySamples()`, so host-side plugin delay compensation (PDC) accounts for the whole chain. If the DSP later adds another latency source (e.g. a different oversampling factor becomes user-selectable), this seam is where it gets folded in. +`CryptaAudioProcessor::computeTotalLatencySamples()` reports that shared value to the host via `setLatencySamples()`, so host-side plugin delay compensation (PDC) accounts for the whole chain. ### The `DryWetMixer` priming gotcha (JUCE 8.0.14) -`juce::dsp::DryWetMixer::prepare()` calls `reset()` internally, which snaps its smoothed dry/wet volumes to whatever `mix` was set to *at that moment* - so if `prepare()` runs before the real mix value is set, the mixer briefly snaps to a stale default before the next `setWetMixProportion()` call retargets it, causing an audible fade-in glitch on the very first block. `ParallelCompressor::prepare()`, `Voicing::prepare()`, and `IRLoader::prepare()` all take the current mix proportion as an explicit parameter and call `setWetMixProportion()` *before* `prepare()` internally, closing this gap at the API level rather than relying on call-order discipline at every call site. +`juce::dsp::DryWetMixer::prepare()` calls `reset()` internally, which snaps its smoothed dry/wet volumes to whatever `mix` was set to *at that moment* - so if `prepare()` runs before the real mix value is set, the mixer briefly snaps to a stale default before the next `setWetMixProportion()` call retargets it, causing an audible fade-in glitch on the very first block. `ParallelCompressor::prepare()`, `Voicing::prepare()`, and `IRLoader::prepare()` all take the current mix proportion as an explicit parameter and call `setWetMixProportion()` *before* `prepare()` internally, closing this gap at the API level rather than relying on call-order discipline at every call site. `MidBand` has no `DryWetMixer` (no blend control), so this gotcha does not apply to it. + +## Preset system (M2) + +v0.2.0 adds the suite-wide M2 preset system (`.scaffold/specs/preset-system-m2.md`), copied from `basilica-audio/nave`'s pilot implementation (`docs/preset-system-notes.md` in that repo is the replication recipe) - `src/presets/PresetManager.{h,cpp}` and `src/presets/PresetBar.{h,cpp}` are portable, Crypta-agnostic classes; the only Crypta-specific glue is `PluginProcessor.cpp`'s `makePresetManagerConfig()`/`makeFactoryPresetAssets()` helpers and the nine `presets/factory/*.json` files (embedded via `juce_add_binary_data` as `CryptaBinaryData`, see `CMakeLists.txt`). `AudioProcessorValueTreeState` is the single source of truth for parameter values; `PresetManager` reads/writes it only through its public API and owns no parallel copy of state. + +`PresetManager`'s only audio-thread-adjacent code is its `AudioProcessorValueTreeState::Listener::parameterChanged()` override (dirty-flag tracking), implemented as a single lock-free `std::atomic` store. Every other method (file I/O, JSON parsing, `juce::String`/`juce::var` allocation) is message-thread-only, called from the processor's constructor or from `PresetBar`'s UI callbacks - never from `processBlock()`. + +The editor (`PluginEditor.cpp`) installs a German localisation frame (`resources/i18n/de.txt`, selected via `SystemStats::getUserLanguage()`) before constructing `PresetBar`, using the same `initLocalisationThenGetPresetManager()` helper-function pattern nave established (member initialisers run in declaration order regardless of the order they're written in the initialiser list, so the helper must be invoked from `presetBar`'s own initialiser expression, not the constructor body). + +## State migration (v0.1.x → v0.2.0) + +`CryptaAudioProcessor::setStateInformation()` runs a one-way, best-effort migration (`migrateLegacySingleCrossover()`, `PluginProcessor.cpp`) before handing state to `apvts.replaceState()`: a v0.1.x session's single `crossoverFreq` `PARAM` XML element (that parameter ID no longer exists in v0.2.0's `ParameterLayout`) is read directly out of the raw XML, clamped into `splitHighHz`'s new 300-2000 Hz range, and injected as a new `splitHighHz` `PARAM` element - unless one is already present (defensive, not expected from a genuine v1 or v2 session). Every other new v0.2.0 parameter (`splitLowHz`, `midDrive`, `midLevel`, `highTightHz`) simply falls back to its own `ParameterLayout` default via `AudioProcessorValueTreeState::replaceState()`'s existing "unmentioned parameter ID keeps its current/default value" behaviour - no special-case code needed for those. See `tests/StateMigrationTests.cpp` for the full test coverage, including the dedicated regression test asserting an untouched v0.1.x session (shipped default `crossoverFreq` = 250 Hz, below the new 300 Hz floor) lands exactly at 300 Hz. diff --git a/docs/design-brief.md b/docs/design-brief.md new file mode 100644 index 0000000..ea59838 --- /dev/null +++ b/docs/design-brief.md @@ -0,0 +1,315 @@ +# Crypta — Design Brief v2 (binding; supersedes v1's implicit spec) + +Parallel bass processor: split the signal by frequency, keep the low end tight via fast +parallel-bus compression, run the upper bands through saturation, sum back through EQ and a +cabinet IR. Research-driven rewrite: every default below is sourced (see +`crypta-research-notes.md`) or explicitly reasoned where no source exists. **No brand or +person names in parameters, UI or marketing copy** — generic descriptors only ("Tight", +"Drive", "Split"); the manual/research notes may cite public sources (the named commercial +bass-multiband plugin this genre is built on, its designer's other published hardware) freely, +since those are the honestly-disclosed reference class, not implied endorsement — consistent +with the existing README's non-affiliation language, which this brief does not change. + +## Why v1 falls short (the three core corrections) + +1. **The band count is wrong for the category.** The reference class's own defining sentence + is "bass, mids, and high frequencies are processed separately with distortion and + compression to be mixed back together" — a **three**-way split (low/mid/high), with the mid + and high bands doing functionally distinct jobs (mid: "throatier color... note + articulation"; high: "presence... fuzz or tightness... tames harshness"). v1's two-band + (low/high) split conflates those two distinct distortion characters into a single high band + with three switchable voicings — a reasonable engineering simplification, but it is not what + the category it names itself after actually does (research notes §3, §5). +2. **The low-band compressor's ballistics model the wrong sub-genre of parallel compression.** + v1's manual explicitly calls the low band "New York style" and defaults to ratio 4:1, attack + 10 ms, release 120 ms — classic heavy drum-bus parallel-compression language. The reference + class's own low-band compressor is fixed at **ratio 2.0, attack 3 ms, release 6 ms** + (research notes §3) — a fast, gentle, "glue" bus compressor, categorically different from + New York-style squashing (which typically runs 8–16:1). v1 isn't just using different + numbers, it's modeling a different technique under the right-sounding wrong name. +3. **The pre-distortion high-pass is a single voicing's quirk instead of the high band's + primary character control.** Only Razor gets a pre-clip HPF (fixed 200 Hz) in v1; the + reference class treats a *variable* pre-distortion HPF ("pullable down to 100Hz") as the + main "how much fuzz vs. tightness" knob for the *entire* upper-band signal chain, independent + of which saturation voicing is selected (research notes §3, §5). + +## Topology (restructured: 2-band → 3-band, cascaded LR4) + +``` +Input Trim → Gate (full-band, unchanged) → + LR4 split #1 (Split Low, 60-400 Hz, default 120 Hz) → + Low band → [Parallel Comp: fast/gentle defaults] → Level + Remainder → LR4 split #2 (Split High, 300-2000 Hz, default 600 Hz) → + Mid band → Drive (staged saturation) → Level + High band → Tight (pre-drive HPF, band-wide) → Voicing → Drive → Tone → Blend → Level + │ + [Mid + High summed] → IR loader (cab sim) ── Low band rejoins AFTER the IR loader, + never passed through convolution + │ + Sum (Low + [Mid+High post-IR], delay-compensated) + │ + 4-band EQ (post-sum, unchanged parametric structure, re-anchored defaults) + │ + Safety Clip (optional) → Output Trim +``` + +- **Cascaded (not parallel) LR4** is the correct way to build an N-band flat-summing crossover + — this is the exact pattern the suite's `triptych` multiband plugin already establishes and + that v1's own `Crossover.h` comments flag as the reusable canonical crossover. v2 instantiates + two `cryp::Crossover` objects in series: the first peels off Low, the second splits the + remainder into Mid/High. The three-way sum still reconstructs the original signal within + ±0.1 dB (same guarantee class as v1's existing 2-band test, extended to 3 bands). +- **Deliberate engineering choice not copied from the reference**: the reference class's own + three bands are *not* flat-summing — its Low band has an independently adjustable low-pass + and its High/Treble band has an independently adjustable high-pass with no coupling between + them, so overlaps/gaps between bands are possible by design (it optimizes for tone-shaping + flexibility, not phase-coherent reconstruction). Crypta keeps its existing, more rigorous + flat-summing LR4 discipline instead of copying this looser behavior — the goal of this brief + is to authentically match the reference class's *voicing and ballistics character*, not to + reproduce an engineering shortcut that a rigorously-tested plugin doesn't need. This is called + out explicitly so it isn't mistaken for an oversight. +- **Low band routed around the IR loader, mono-summed last** — matches the reference class's + explicit "low band bypasses the cabsim... remains mono" architecture (research notes §3, §7 + gap 5). This is a structural relocation of the existing `IRLoader` stage: it now sits between + the Mid+High sum and the final three-way sum, not after the full mix as in v1. +- Gate, Safety Clip, Output Trim, oversampling/latency-compensation architecture for the + distortion bands: unchanged from v1. The high band's 4x oversampling and delay-compensation + contract carries forward as-is (research found no reference-class-specific reason to change + it — oversampling factor is an implementation detail invisible to the reference's own + documentation). + +## Module specifications (authentic behaviors, generically named) + +### Split Low / Split High — crossover (was single Crossover Frequency) +- **`splitLowHz`** (was `Crossover Frequency`): 60–400 Hz, log taper, **default 120 Hz** + (reasoned: centers inside the sourced 60–1000 Hz range's lower half, low enough to keep the + fundamental of typical bass tunings — including drop tunings — inside the compressed-only Low + band, consistent with the reference class's "low band never gets distorted" architecture; + not a number taken directly from a source, flagged as reasoned). +- **`splitHighHz`** (NEW): 300–2000 Hz, log taper, **default 600 Hz** (reasoned: the reference + class's Mid band is described as "fixed around 400Hz" as its distortion *character center*, + not a band edge — since Crypta's Mid band needs an actual upper corner rather than a center + frequency, 600 Hz is chosen as a plausible edge that keeps 400 Hz well inside the Mid band's + passband rather than at its boundary; explicitly reasoned, not sourced to an exact crossover + number, because the reference class does not publish one). +- Both remain real-time-safe coefficient recomputes, matching v1's existing `Crossover` + contract; `splitHighHz` is clamped to always stay above `splitLowHz` by a minimum musical + gap (reasoned safety margin, e.g. ≥ 1/3 octave) to avoid a degenerate near-zero-width Mid + band. + +### Low band: parallel compressor + level (ballistics re-sourced, structure unchanged) +- Keeps v1's fully parametric structure (threshold/ratio/attack/release/makeup/mix) — this is + **more capable than the reference class**, which exposes only a single combined + Compression knob with fixed ballistics. Crypta's adjustability is kept deliberately (a + genuine improvement, not a gap), but the **defaults** are re-anchored to the sourced fixed + values so that turning every knob to its default reproduces the reference character: + - `lowCompRatio`: range unchanged 1…20, **default changes 4 → 2** (sourced exactly: "Ratio + 2.0"). + - `lowCompAttackMs`: range unchanged 0.1…100, **default changes 10 → 3 ms** (sourced exactly: + "Attack 3ms"). + - `lowCompReleaseMs`: **range floor lowered from 10 ms to 5 ms** (breaking change, pre-1.0) + so the sourced default is reachable; **default changes 120 → 6 ms** (sourced exactly: + "Release 6ms"). This is a fast, gentle glue compressor, not a squash — document the + manual's "New York style" framing as **retired**, replaced with "fast bus-style glue + compression" language that matches what the reference class actually implements (research + notes §4). + - `lowCompThreshold`, `lowCompMakeup`, `lowCompMix`, `lowLevel`: ranges and defaults + unchanged — no source contradicts v1's existing choices here, and the reference's single + combined knob (0 to +10 dB "gain reduction and make up gain") doesn't cleanly decompose + into separate threshold/makeup numbers to source against. + +### Mid band (NEW): drive + level +- **`midDriveAmount`** (NEW, 0–100%, default 30%): staged saturation, structurally similar to + v1's Wool voicing (cascaded tanh stages) since the reference class describes "multiple tube + gain stages... designed for the Mid and Treble bands separately" (research notes §3, §7 gap + 7) — cascaded/staged saturation, not a single hard-clamp, is the directionally-sourced + character for this band. Default 30% (reasoned: a mid-distorted band is new to Crypta and + should be audibly present but not dominant out of the box, since it sits in the frequency + range most likely to clash with the guitar wall it's meant to cut through, per the existing + manual's own crossover-tuning tip about avoiding guitar-range competition). +- **`midLevel`** (NEW, −24…+12 dB, default 0 dB): matches the Low/High band's existing Level + convention exactly. +- No filter/tone control on this band (matches the reference class exactly: "Mid Drive... Mid + Level" only, no filter exposed) — deliberately minimal, not an oversight. +- Runs inside the same oversampled block as the High band's voicing stage (shares the + oversampling instance for CPU efficiency; both bands' nonlinearities need the same + anti-aliasing headroom). + +### High band: Tight (pre-drive HPF, promoted) + Voicing + Drive + Tone + Blend + Level +- **`highTightHz`** (was Razor-only fixed 200 Hz internal constant, now a first-class, + voicing-independent control): **20–500 Hz, log taper, default 100 Hz.** Sourced: the + reference's high-band HPF is "pullable down to 100Hz" and is framed as the primary "fuzz or + tightness" control for the whole band, plus a harshness-control role ("tames harshness... + when it comes to high saturation drive sounds" — research notes §3, §5). 100 Hz is the + sourced floor of the reference's own documented pull-down range, chosen as the default because + it's the most fuzz-forward, most-quoted end of that range rather than an untested midpoint. + Applies **before** the voicing stage for all three voicings (Gnaw/Wool/Razor), not just + Razor — this retires Razor's old fixed-200 Hz internal pre-HPF entirely in favor of the new + shared, adjustable control. +- **Voicing enum** (`Gnaw`/`Wool`/`Razor`): unchanged selectable set and persisted indices + (hard backward-compatibility constraint, same convention as the rest of the suite). Character + math (drive-gain ceilings, mid-hump/scoop filters) unchanged from v1 — no source in this pass + gives exact per-voicing gain-stage numbers for the reference's own treble band, so v1's + existing (already-flagged-as-engineering-default) voicing math is kept as-is rather than + altered without a source. +- `highDrive`, `highTone`, `highBlend`, `highLevel`: ranges and defaults unchanged — no sourced + reason to change any of these from v1. + +### IR loader (cabinet simulation) — relocated, not respecified +- Structural move only (see Topology): now processes the **Mid+High post-sum signal only**, + never the Low band. Enable/Mix parameters, convolution engine, and the "no IR loaded = bit- + exact passthrough" guarantee all carry forward unchanged from v1. This matches the reference + class's explicit "low band bypasses the cabsim, remains mono" architecture (research notes + §3, §7 gap 5) — Crypta's low band already has no stereo-mono distinction to protect the same + way (Crypta doesn't currently offer a stereo input mode toggle the way the reference does), + so only the "no cab coloration on the low band" half of that reference behavior is adopted; + the mono-forcing half is out of scope (no source pressure to add a feature Crypta doesn't + otherwise have). + +### Post-sum 4-band EQ — re-anchored default frequencies, structure unchanged +- Stays fully parametric (LowShelf/Peak/Peak/HighShelf, ±18 dB) — already a strictly more + capable tool than the reference's fixed 6-band graphic EQ (100/250/500/1k/1.5k/5k Hz, + ±12 dB), so no structural change. **Default corner frequencies re-anchored** to the sourced + bass-tone-stack frequency set from the same design lineage as the named reference plugin + (research notes §6), since v1's existing 100/500/2500/8000 Hz defaults were unsourced + placeholders: + - `eqLowShelfHz`: default changes 100 → **80 Hz** (sourced). + - `eqPeak1Hz`: default changes 500 → **500 Hz** (already matched the sourced anchor — + unchanged). + - `eqPeak2Hz`: default changes 2500 → **2800 Hz** (sourced: the "presence/definition" high- + mid anchor). + - `eqHighShelfHz`: default changes 8000 → **5000 Hz** (sourced). + - Gain/Q ranges and defaults (0 dB, unchanged Q defaults) stay as-is — EQ ships off/flat by + default either way, so these are dormant-until-engaged anchors, not an audible v1→v2 + change unless a user (or a factory preset) turns the EQ on. + +### Gate, Safety Clip, Bypass, Input/Output Trim — unchanged, explicitly validated +- No sourced reason to change any of these. Worth stating explicitly: the reference class's own + gate is a single coarse knob with no separate ratio/attack/release — Crypta's four-parameter + gate already exceeds it in capability, so this is a **confirmed-correct existing decision**, + not an area silently left alone by omission. + +## Factory Presets (for the M2 preset system — proposed, not yet implemented) + +Generic descriptors only, no names/brands. Settings are starting points, not exact renders; +all reference the v2 3-band topology and re-sourced defaults above. + +| Preset | Intent | Rough settings | +|---|---|---| +| **Glue & Grind** | The shipped default: fast/gentle low-band glue, moderate mid saturation, tight high-band fuzz. | Split Low 120 Hz · Split High 600 Hz · Low Comp Ratio 2:1 / Atk 3 ms / Rel 6 ms / Mix 100% · Mid Drive 30% · High Tight 100 Hz · Voicing Gnaw · High Drive 50% · Blend 100% | +| **Sub Lock** | Maximum low-end control for a dense mix; low band does almost all the work. | Split Low 90 Hz · Split High 500 Hz · Low Comp Ratio 3:1 / Atk 3 ms / Rel 6 ms / Makeup +2 dB · Mid Drive 15% · High Tight 150 Hz · Voicing Razor · High Drive 35% | +| **Throat** | Emphasizes the mid band's "throatier" documented character; the mid band carries most of the grind. | Split Low 100 Hz · Split High 800 Hz · Low Comp Ratio 2:1 · Mid Drive 65% · Mid Level +2 dB · High Tight 120 Hz · Voicing Wool · High Drive 30% | +| **Fuzz Wall** | Maximum documented "fuzz" pull: Tight at the sourced floor, aggressive high-band voicing. | Split Low 130 Hz · Split High 550 Hz · Low Comp Ratio 2:1 · Mid Drive 25% · High Tight 100 Hz · Voicing Wool · High Drive 85% · Blend 100% | +| **Cut Through** | Drop-tuned rhythm use case: crossover pushed up so more note body reaches the distorted bands (existing v1 manual tip, now spanning 2 splits). | Split Low 180 Hz · Split High 900 Hz · Low Comp Ratio 2:1 · Mid Drive 40% · High Tight 130 Hz · Voicing Razor · High Drive 55% | +| **Definition Only** | High band's harshness-control role showcased: Tight pulled up, Drive kept moderate, EQ presence bump engaged. | Split Low 110 Hz · Split High 650 Hz · Mid Drive 20% · High Tight 250 Hz · Voicing Razor · High Drive 30% · EQ Enable on, Peak2 2800 Hz +3 dB | +| **Clean Low, Loud Top** | Low band audibly present but untouched in character (Mix low), everything else pushed. | Split Low 120 Hz · Split High 600 Hz · Low Comp Mix 40% · Mid Drive 45% · High Tight 100 Hz · Voicing Gnaw · High Drive 75% | +| **Cab-Colored Grind** | Demonstrates the relocated IR loader coloring only the Mid+High path while the low end stays uncolored. | Split Low 120 Hz · Split High 600 Hz · Mid Drive 35% · High Tight 100 Hz · Voicing Wool · High Drive 60% · IR Enable on, Mix 70% | + +## Guarantees & tests (Catch2; keep all still-valid v1 cases, extend for the 3-band rebuild) + +1. **Three-way flat-sum:** Low + Mid + High reconstructs the input within ±0.1 dB across the + band, for a swept combination of `splitLowHz`/`splitHighHz` settings (extends v1's existing + 2-band flat-sum test to the cascaded 3-band topology; the minimum-gap clamp between the two + splits is exercised at its boundary). +2. **Low-band ballistics regression at the new defaults:** at `lowCompRatio=2`, + `lowCompAttackMs=3`, `lowCompReleaseMs=6`, verify measured gain-reduction time-constants + (10–90% attack/release settling) land within the compressor's own smoothing tolerance of the + sourced 3 ms/6 ms figures — a category-specific measurable proof that the "glue, not squash" + character is actually implemented, not just documented. +3. **Low band never reaches the IR loader:** with IR enabled and a non-trivial IR loaded, + assert the Low band's output (isolated pre-sum) is bit-exact identical whether IR is on or + off — the structural "low band bypasses cab-sim" guarantee, directly testable now that the + IR loader has moved in the signal path. +4. **Mid band null/passthrough and monotonic-drive proofs:** `midDriveAmount = 0` is a + passthrough within tolerance (mirrors existing per-stage null tests); harmonic energy + increases monotonically with `midDriveAmount`, mirroring the existing Voicing monotonic- + harmonic test pattern extended to the new Mid stage. +5. **Tight (highTightHz) is voicing-independent:** for a fixed Drive and a fixed low-frequency + test tone, sweep `highTightHz` across its full 20–500 Hz range for *each* of the three + voicings and assert low-frequency attenuation increases monotonically with `highTightHz` in + all three cases (proof that Tight is no longer Razor-only, closing the gap identified in + Why-v1-falls-short §3). +6. **EQ default-frequency anchors land correctly:** loading the default parameter state and + reading back `eqLowShelfHz`/`eqPeak1Hz`/`eqPeak2Hz`/`eqHighShelfHz` matches the re-anchored + 80/500/2800/5000 Hz values exactly (regression guard against silently drifting back to the + old unsourced defaults). +7. **State migration tolerance (v1 → v2, structural):** old (v1) saved state — with a single + `crossoverFrequency` (no `splitLowHz`/`splitHighHz`), no mid-band parameters, and + `lowCompRatio`/`Attack`/`Release` at v1's old defaults — loads without crashing; the old + single crossover value maps to `splitHighHz` (best-effort: the v1 split point is closer in + role to the reference's high-band edge than to the new low-band edge, since v1 never had a + dedicated mid band), **clamped into the new 300–2000 Hz range on import — NOTE that v1's + SHIPPED DEFAULT crossover is 250 Hz, i.e. below the new floor, so the single most common + migration path (untouched v1 session) hits this clamp and must land exactly at 300 Hz; a + dedicated test asserts this default-session path** — while `splitLowHz` falls back to its + new v2 default; all new mid-band + parameters fall back to their v2 defaults (never zero/garbage); old `lowCompRatio`/`Attack`/ + `Release` values, if explicitly set away from v1's old defaults by the user, are preserved + as-is (only the *shipped default* changes, not a forced migration of a user's deliberate old + setting) — documented as a lossy, best-effort migration per Versioning below. +8. **NaN/Inf robustness across the rebuilt topology:** sweep `splitLowHz`, `splitHighHz` (incl. + their minimum-gap boundary), `midDriveAmount`, `highTightHz` to their extremes combined with + extreme Drive/Level/EQ settings; confirm no NaN/Inf propagates (extends v1's existing + robustness-sweep pattern to every new/changed control). +9. **Real-time-safety carry-forward:** no new allocation on the audio thread from the second + crossover stage, the Mid band's saturation, or the relocated IR loader; existing + `docs/architecture.md` latency-compensation contract and its tests remain green, updated + only where the IR loader's new position changes which stage owns which compensation delay + (the Low band's already-zero-latency compressor path needs no new compensation; only the + Mid+High branch's existing oversampling latency is now also the point after which the IR + loader's own zero-added-latency convolution runs). +10. **Preset round-trip:** every factory preset in the table above loads, all parameter values + land within tolerance of their specified settings, and produces no NaN/Inf/silence on a + standard test signal. + +## Honesty & framing + +- `crypta-research-notes.md` ships the sourced findings (quotes + URLs) — the topology and + default changes in this brief are **research-derived from the named reference plugin's own + official user manual, a third-party professional review, the same design lineage's hardware + product manual, and general parallel-bus-compression community/vendor consensus — not + measured against the reference plugin's actual audio output, its DSP source code, or any + hardware unit by this project.** Say so in the manual, in the same place v1 already discloses + that voicing character is "engineering defaults... not yet finalized by ear against reference + material" — this brief narrows, but does not eliminate, that disclosure. +- The **single most load-bearing sourced number** in this brief — the low-band compressor's + fixed ratio 2.0 / attack 3 ms / release 6 ms — comes from the reference plugin's own official + PDF manual, verbatim and unambiguous ("Fixed settings: Attack 3ms - Release 6ms - Ratio 2.0"), + the strongest-grade source used in this pass. +- Several new defaults (`splitLowHz` 120 Hz, `splitHighHz` 600 Hz, `midDriveAmount` 30%) are + **reasoned engineering choices anchored to the sourced qualitative behavior** (a 3-band split + exists; the mid band's character centers "around 400Hz"), **not numbers taken directly from a + published crossover-frequency spec** — the reference class does not publish exact crossover + corner frequencies, only a description of each band's own filter behavior. Each is called out + individually above; do not represent them as measured/published reference values. +- The mid band's saturation math (cascaded/staged shaping, reusing the existing Wool voicing's + two-stage tanh structure) is a **directional** finding — the reference's manual confirms + "multiple tube gain stages" exist for Mid and Treble but gives no stage count, gain-per-stage, + or transfer-function detail. v2 reuses v1's existing, already-implemented cascaded-stage math + as the closest available building block, not because it's been confirmed to match the + reference's actual circuit. +- Manual notes that the named commercial plugin and its designer's other published hardware are + cited as documented public sources for the *technique and category conventions*, without + implying endorsement, sponsorship, or affiliation — consistent with the existing README's + non-affiliation language, unchanged by this brief. +- Out of scope for v2 (explicitly): a continuously-blendable pair of distortion characters + (mirroring the same design lineage's hardware "Mod" blend control) replacing the discrete + Gnaw/Wool/Razor selector — noted as a possible future direction in the research notes, not + adopted here because it would break the frozen voicing-enum backward-compatibility + constraint; a stereo input-mode toggle that would make the Low band's "stays mono" reference + behavior meaningful for Crypta (Crypta has no stereo-mode distinction today); factory cabinet + IRs and a GUI file browser (already tracked as a later-milestone item in v1's own roadmap, + unaffected by this brief). Custom GUI remains M3 as in v1's roadmap; this brief is + DSP/parameter-layer only. + +## Versioning + +Ships as **v0.2.0** (breaking parameter changes are acceptable pre-1.0, per suite convention; +the Voicing enum's persisted indices remain a hard-frozen exception carried forward from v1). +State migration = tolerant import: old single-crossover, 2-band v1 state loads without +crashing, maps its one crossover value to the new `splitHighHz` (best-effort, lossy — see +Guarantee 7), all new mid-band/Tight parameters fall back to v2 defaults, and unknown IDs in +either direction are ignored rather than fatal (same forward/backward tolerance pattern as the +rest of the suite). CHANGELOG documents the 2-band → 3-band restructuring, the low-band +compressor's ballistics re-source (and retirement of the "New York style" description), and the +IR loader's relocation as the headline v0.2.0 changes. diff --git a/docs/manual.md b/docs/manual.md index be4cc00..6075dbc 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -6,41 +6,59 @@ ## What it is -Crypta is a Parallax-style **parallel bass processor** built for metal production. It splits your bass signal into a low band and a high band with a 4th-order Linkwitz-Riley ("LR4") crossover, keeps the low band tight with a parallel compressor, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and a cabinet-simulation IR loader. +Crypta is a Parallax-style **parallel bass processor** built for metal production. As of v0.2.0 it splits your bass signal into **three** bands — low, mid, and high — with two cascaded 4th-order Linkwitz-Riley ("LR4") crossovers, keeps the low band tight with a parallel compressor, drives the mid band with staged saturation, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and a cabinet-simulation IR loader. + +### Research-derived rebuild (v0.2.0) + +v0.2.0 is a research-driven rewrite of v0.1.x's simpler two-band (low/high) topology, sourced against the reference plugin class's own official user manual, a third-party professional review, the same design lineage's hardware product manual, and general parallel-bus-compression community/vendor consensus — **not measured against any reference plugin's actual audio output, DSP source code, or hardware unit by this project.** See `docs/design-brief.md` and `docs/research-notes.md` for the full sourcing, and the same disclosure v0.1.x already carried: voicing character (drive-gain ranges, mid-filter hump/scoop settings) is engineering-tuned, not yet finalized by ear against reference material. ### Where it sits in a heavy-music chain -Crypta is designed to be the **bass-specific voicing stage** in the "Metal up your ass" suite: +Crypta is designed to be the **bass-specific voicing stage** in the Basilica Audio suite: - Track order: **DI/amp sim → Crypta → bus compression/glue → mix bus**. It expects a reasonably clean, already-amp-sim'd or DI'd bass signal; it is not itself a full amp sim (no built-in preamp gain staging beyond the input trim and drive controls). -- The low band's parallel compressor is meant to keep the fundamental/sub content of the bass locked in place under a wall of distorted guitars, while the high band's voicing adds the upper-mid "grind" that lets the bass cut through a dense mix without competing for the same frequency range as the guitars. -- The crossover point (default 250 Hz) is deliberately tunable across the whole low-mid register (60 Hz–1000 Hz) so you can match the split to the song's tuning (drop-tunings push useful low-end content further up). -- The output stage's IR loader is meant for quick cabinet-style tone shaping without needing a separate cab-sim plugin later in the chain, though it can also be left off entirely if you're already running a dedicated cab sim elsewhere. +- The low band's parallel compressor is meant to keep the fundamental/sub content of the bass locked in place under a wall of distorted guitars. The mid band adds a distinct "throatier" saturation character that sits in the frequency range most likely to clash with a guitar wall — dial it in deliberately, not just as an afterthought. The high band's voicing adds the upper-mid/high "grind" that lets the bass cut through a dense mix. +- Two independent split points (**Split Low**, **Split High**) let you tune both crossover corners across the low-mid register to match the song's tuning (drop-tunings push useful low-end content further up) and to control how wide the mid band's "throat" is. +- The output stage's IR loader — now applied only to the Mid+High path, never the Low band — is meant for quick cabinet-style tone shaping without needing a separate cab-sim plugin later in the chain, though it can also be left off entirely if you're already running a dedicated cab sim elsewhere. ## Signal flow ``` -Input Trim → Gate → LR4 Split (60–1000 Hz, default 250 Hz) +Input Trim → Gate → LR4 Split Low (60–400 Hz, default 120 Hz) │ - ┌─────────────┴─────────────┐ - │ │ - Low band High band - Parallel Comp → Level Voicing → Drive → Tone → Blend → Level - │ │ - └─────────────┬─────────────┘ - │ - Sum (delay-compensated) - │ - 4-band EQ - │ - IR loader - │ - Safety Clip (optional) - │ - Output + ┌─────────────┴───────────────────────────────┐ + │ │ + Low band Remainder → LR4 Split High (300–2000 Hz, default 600 Hz) + Parallel Comp → Level │ + │ ┌───────────────────┴───────────────────┐ + │ Mid band High band + │ Drive → Level Tight → Voicing → Drive → Tone → Blend → Level + │ └───────────────────┬───────────────────┘ + │ Mid+High sum + │ │ + │ IR loader (cab sim) + │ │ + └───────────────────────┬────────────────────────┘ + │ + Sum (delay-compensated) + │ + 4-band EQ + │ + Safety Clip (optional) + │ + Output ``` -The high band runs its distortion voicing oversampled (4x) to keep aliasing under control; the low band carries a matching compensation delay so both bands stay time-aligned at the sum. See [`docs/architecture.md`](architecture.md) for the full technical breakdown, including exactly how that latency compensation works. +The Mid and High bands share the same oversampling anti-aliasing headroom (each 4x oversampled independently, but identically configured, so they report identical latency); the low band carries a matching compensation delay, plus a phase-alignment allpass filter tied to Split High's own cutoff, so all three bands sum flat and stay time-aligned at the final sum. The IR loader (cabinet simulation) sits **after** the Mid+High sum and **before** the final three-way sum — the Low band never passes through it, matching the reference class's "low band bypasses the cabsim" architecture. See [`docs/architecture.md`](architecture.md) for the full technical breakdown, including exactly how the latency and phase-alignment compensation work. + +## Presets + +Crypta ships with a preset system: a horizontal bar at the top of the plugin window lets you step through factory and user presets (`<` / preset name / `>`), save/save-as/delete your own, and import/export single presets or preset banks (zip files of multiple presets). Nine factory presets ship in v0.2.0 — see `docs/presets.md` for what each one demonstrates. User presets are stored per-plugin under: + +- **macOS**: `~/Library/Audio/Presets/Yves Vogl/Crypta/` +- **Windows**: `%APPDATA%\Yves Vogl\Crypta\Presets\` + +A fresh instance loads a user "Default" preset if you've saved one ("Set current as default" in the preset menu), otherwise the factory "Default" preset (matching the plain parameter defaults documented below). ## Parameter reference @@ -50,14 +68,14 @@ Unless noted otherwise, all continuous parameters are smoothed to avoid zipper n | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| -| Input Gain | −24 … +24 | 0 | dB | Trims the signal before anything else in the chain. Use this to get a hot but not clipping signal into the gate/compressor/voicing stages - all of their thresholds are calibrated assuming a reasonably "line level" input. | +| Input Gain | −24 … +24 | 0 | dB | Trims the signal before anything else in the chain. Use this to get a hot but not clipping signal into the gate/compressor/drive/voicing stages - all of their thresholds are calibrated assuming a reasonably "line level" input. | | Output Gain | −24 … +24 | 0 | dB | Final output trim, applied after everything else (including the safety clip). | | Bypass | off/on | off | — | Forces a bit-exact passthrough of the input signal. Also exposed as the plugin's host-facing bypass parameter, so your DAW's own bypass button/automation lane works too. | | Safety Clip | off/on | off | — | A soft (tanh) limiter on the very last stage before the output trim. Off by default; turn it on as a safety net against accidental hard-clipped overs, not as a tone-shaping tool - at typical playing levels it's inaudible, and it only starts rounding peaks once they approach 0 dBFS. | -### Noise Gate (full-band, before the crossover split) +### Noise Gate (full-band, before the crossover splits) -Sits ahead of the crossover, so it gates the input signal as a whole rather than per band. +Sits ahead of both crossovers, so it gates the input signal as a whole rather than per band. | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| @@ -67,32 +85,45 @@ Sits ahead of the crossover, so it gates the input signal as a whole rather than | Gate Attack | 0.1 … 50 | 1 | ms | How fast the gate opens once the signal crosses back above threshold. | | Gate Release | 5 … 500 | 100 | ms | How fast the gate closes once the signal drops below threshold. | -### Crossover +### Split Low / Split High (two cascaded crossovers, NEW topology in v0.2.0) | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| -| Crossover Frequency | 60 … 1000 | 250 | Hz | The LR4 split point between the low and high bands. Log-scaled control (equal knob travel per octave). Lower it to push more of the fundamental into the (typically cleaner, compressed) low band; raise it to give the distortion voicing more of the low-mid content to work with. | +| Split Low | 60 … 400 | 120 | Hz | The LR4 split point between the Low band and everything above it. Log-scaled control. Lower it to push more of the fundamental into the (compressed-only) Low band; raise it to give the Mid band more low-mid content to work with. | +| Split High | 300 … 2000 | 600 | Hz | The LR4 split point between the Mid band and the High band. Log-scaled control. | + +Split High is always kept at least a fraction of an octave above Split Low internally (a reasoned safety margin against a degenerate near-zero-width Mid band) — if you push the two close together, Split High's *effective* value will float slightly above whatever you've set Split Low to, rather than collapsing the Mid band to nothing. ### Low band: parallel compressor + level -The low band is compressed **in parallel** ("New York style"): the compressed signal is blended back with its own uncompressed self via Mix, rather than replacing it outright, which is what keeps the low end feeling tight and controlled without ever sounding squashed or lifeless. +The low band is compressed **in parallel**: the compressed signal is blended back with its own uncompressed self via Mix, rather than replacing it outright, which is what keeps the low end feeling tight and controlled without ever sounding squashed or lifeless. **v0.2.0 re-sources the ballistics defaults** to the reference class's own fixed, sourced values — a fast, gentle "glue" bus compressor, not the heavier "New York style" squash v0.1.x's defaults implied (see `docs/research-notes.md` §3–4 for the full sourcing). | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| | Low Comp Threshold | −60 … 0 | −18 | dB | Level above which the low-band compressor engages. | -| Low Comp Ratio | 1 … 20 | 4 | :1 | Compression ratio above threshold. | -| Low Comp Attack | 0.1 … 100 | 10 | ms | How fast the compressor clamps down once above threshold. | -| Low Comp Release | 10 … 1000 | 120 | ms | How fast the compressor lets go once back under threshold. | +| Low Comp Ratio | 1 … 20 | **2** | :1 | Compression ratio above threshold. | +| Low Comp Attack | 0.1 … 100 | **3** | ms | How fast the compressor clamps down once above threshold. | +| Low Comp Release | **5** … 1000 | **6** | ms | How fast the compressor lets go once back under threshold. Range floor lowered from 10 ms in v0.1.x so the sourced 6 ms default is reachable. | | Low Comp Makeup | −12 … +24 | 0 | dB | Gain applied to the compressed (wet) signal before it's blended back with the dry low band - use this to bring the compressed signal back up to match the dry level, so Mix behaves as a true "how much compression character" control rather than also changing overall loudness. | | Low Comp Mix | 0 … 100 | 100 | % | Blend between the dry (uncompressed) and wet (compressed + makeup) low band. 0% = compressor has no audible effect; 100% = fully compressed. | | Low Level | −24 … +12 | 0 | dB | Level trim on the low band, applied after compression and before the bands are summed back together. | -### High band: voicing, drive, tone, blend, level +### Mid band: drive + level (NEW in v0.2.0) -Three selectable distortion voicings, each oversampled (4x) to keep the nonlinear shaping stage's aliasing out of the audible band. +A dedicated mid band with staged/cascaded saturation, structurally similar to the High band's Wool voicing (two cascaded soft-clip stages) but with no filter, tone, or blend control of its own — matching the reference class's own Mid band exactly ("Mid Drive... Mid Level" only). This band's job is a distinct "throatier" grind character, separate from the High band's own presence/fuzz/harshness-control role. | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| +| Mid Drive | 0 … 100 | 30 | % | Staged saturation amount. 0% is an exact passthrough; increasing it blends progressively toward a fully cascaded-tanh-driven signal. | +| Mid Level | −24 … +12 | 0 | dB | Level trim on the mid band, applied after drive and before the bands are summed back together. | + +### High band: Tight, voicing, drive, tone, blend, level + +Three selectable distortion voicings, each 4x oversampled to keep the nonlinear shaping stage's aliasing out of the audible band. + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| High Tight | 20 … 500 | 100 | Hz | **NEW in v0.2.0**: a pre-drive high-pass filter, now applied ahead of *every* voicing (was a Razor-only fixed 200 Hz internal constant in v0.1.x). This is the primary "how much fuzz vs. tightness" control for the whole High band, and also tames harshness on high-drive settings - pull it down toward its floor for maximum fuzz, push it up for a tighter, more controlled top end. | | High Voicing | Gnaw / Wool / Razor | Gnaw | — | Selects the distortion character. See below. | | High Drive | 0 … 100 | 50 | % | How hard the signal is pushed into the selected voicing's nonlinearity. | | High Tone | 0 … 100 | 50 | % | Post-shaper tone control: a low-pass sweeping from dark (0%) to bright (100%), tucking away or opening up fizz/harshness from the distortion stage. | @@ -103,43 +134,47 @@ Three selectable distortion voicings, each oversampled (4x) to keep the nonlinea - **Gnaw** — an op-amp-style hard clip. Symmetric, unforgiving, the most aggressive of the three; pushes hard into a square-ish waveform at high drive. Good for a raw, buzzy attack. - **Wool** — cascaded soft-clip fuzz with a mid scoop and a touch of asymmetry for a grittier, more fuzz-pedal-like harmonic character. Good for a woolier, less "digital" grind that still cuts. -- **Razor** — a tighter overdrive: the signal is high-passed before the (comparatively mild) clipper, and a mid-hump filter afterwards keeps the low end from ever getting mushy. Good for definition and pick/finger attack without piling on low-end mud. +- **Razor** — a tighter overdrive: a comparatively mild clipper, with a mid-hump filter afterwards that keeps the low end from ever getting mushy (the pre-clip highpass duty is now handled band-wide by Tight, above, rather than being Razor's own quirk as in v0.1.x). *Starting points, not final voicing:* the drive-gain ranges and mid-filter hump/scoop settings for all three voicings are engineering defaults, tuned for musical usefulness and mathematically bounded (no runaway output at any drive setting), not yet finalized by ear against reference material. Expect these to be refined in a future release. ### Post-sum 4-band EQ -Applied after the low/high bands are summed back together. Off by default; when off, the EQ stage is skipped entirely (guaranteed transparent, not just set to unity gain). +Applied after all three bands are summed back together (and after the IR loader). Off by default; when off, the EQ stage is skipped entirely (guaranteed transparent, not just set to unity gain). **v0.2.0 re-anchors the default corner frequencies** to a sourced bass-tone-stack frequency set from the same design lineage as the reference class (v0.1.x's defaults were unsourced placeholders). | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| | EQ Enable | off/on | off | — | Enables the EQ stage. | -| EQ Low Shelf Frequency | 40 … 400 | 100 | Hz | Low shelf corner frequency. | +| EQ Low Shelf Frequency | 40 … 400 | **80** | Hz | Low shelf corner frequency. | | EQ Low Shelf Gain | −18 … +18 | 0 | dB | Low shelf boost/cut. | | EQ Peak 1 Frequency | 100 … 2000 | 500 | Hz | First parametric peak band's centre frequency. | | EQ Peak 1 Gain | −18 … +18 | 0 | dB | First peak band's boost/cut. | | EQ Peak 1 Q | 0.2 … 5.0 | 0.7 | — | First peak band's bandwidth (higher = narrower). | -| EQ Peak 2 Frequency | 500 … 8000 | 2500 | Hz | Second parametric peak band's centre frequency. | +| EQ Peak 2 Frequency | 500 … 8000 | **2800** | Hz | Second parametric peak band's centre frequency - a "presence/definition" high-mid anchor. | | EQ Peak 2 Gain | −18 … +18 | 0 | dB | Second peak band's boost/cut. | | EQ Peak 2 Q | 0.2 … 5.0 | 0.7 | — | Second peak band's bandwidth. | -| EQ High Shelf Frequency | 2000 … 16000 | 8000 | Hz | High shelf corner frequency. | +| EQ High Shelf Frequency | 2000 … 16000 | **5000** | Hz | High shelf corner frequency. | | EQ High Shelf Gain | −18 … +18 | 0 | dB | High shelf boost/cut. | ### IR loader (cabinet simulation) -A convolution-based cab-sim stage on the very end of the chain, before the safety clip. Off by default. With no impulse response loaded, this stage is a guaranteed bit-exact passthrough at every session sample rate, so turning it on before loading an IR never changes your sound. +A convolution-based cab-sim stage that now processes **only the Mid+High post-sum signal** (relocated in v0.2.0 - it sat post-everything in v0.1.x). Off by default. With no impulse response loaded, this stage is a guaranteed bit-exact passthrough at every session sample rate, so turning it on before loading an IR never changes your sound. The Low band is structurally never passed through this stage, matching the reference class's "low band bypasses the cabsim" architecture - your fundamental/sub content stays uncolored regardless of what cab IR you load. | Parameter | Range | Default | Unit | What it does | |---|---|---|---|---| | IR Enable | off/on | off | — | Enables the IR loader stage. | -| IR Mix | 0 … 100 | 100 | % | Blend between the dry (pre-convolution) and fully convolved signal. | +| IR Mix | 0 … 100 | 100 | % | Blend between the dry (pre-convolution) and fully convolved Mid+High signal. | + +*Loading impulse responses:* v0.2.0 still does not ship an in-plugin file browser or factory cabinet IRs (both remain on the roadmap for a later milestone alongside the custom GUI). The IR-loading DSP engine itself is fully implemented and real-time safe. + +## State migration (v0.1.x → v0.2.0) -*Loading impulse responses:* v0.1.0 does not yet ship an in-plugin file browser or factory cabinet IRs (both are on the roadmap for a later milestone alongside the custom GUI). The IR-loading DSP engine itself is fully implemented and real-time safe; a GUI file picker will be wired up to it once the custom interface lands. +If you open a Crypta v0.1.x session, the old single `Crossover Frequency` value is migrated to the new **Split High** parameter, clamped into its new 300–2000 Hz range (v0.1.x's own shipped default, 250 Hz, is below that floor, so an untouched v0.1.x session lands exactly at 300 Hz on reopen). Split Low and every new Mid-band/Tight parameter fall back to their v0.2.0 defaults. Any low-band compressor settings you had explicitly changed away from v0.1.x's old defaults are preserved as-is — only the *shipped default* changed, not your own deliberate settings. This is a best-effort, lossy, one-directional migration; re-check your low/mid/high balance after reopening an old session. ## Tips -- **Start with the low band tight, then dial in the high band's grind.** Set Low Comp Mix and Makeup first so the fundamental feels locked in, *then* pick a voicing and drive amount - it's much easier to judge how much distortion character you actually need once the low end already feels solid. -- **The crossover point is a tone decision, not just a technical one.** Pushing it up (toward 400–600 Hz) moves more of the note's body into the distorted high band, which can help a bass cut through a dense guitar wall at the cost of low-end weight; pulling it down (toward 100–150 Hz) keeps more of the note clean/compressed and reserves the distortion for genuinely high harmonic content. +- **Start with the low band tight, then dial in the mid band's throat, then the high band's grind.** Set Low Comp Mix and Makeup first so the fundamental feels locked in, then dial in Mid Drive for the "throatier" cutting character, and only then pick a High voicing and drive amount. +- **Split Low and Split High are tone decisions, not just technical ones.** Pushing Split Low up moves more note body out of the (compressed-only) Low band; pushing Split High up widens the Mid band's own passband, giving the "throatier" character more room before the High band's own fuzz/presence character takes over. +- **High Tight is your main "fuzz vs. tightness" control**, independent of which voicing you've picked - pull it toward its 20 Hz floor for maximum fuzz, push it up toward 500 Hz for a tighter, more controlled top end. It also tames harshness on hot Drive settings. - **High Blend is your "how much" knob, High Drive is your "how hard" knob.** If a voicing feels too extreme, try lowering Blend before lowering Drive - you'll often keep more of the character that way, just at a lower overall intensity, rather than flattening the nonlinearity itself. -- **Razor plus a mild EQ high-shelf cut** is a good starting point if a mix feels harsh/fizzy - Razor's pre-clip highpass already keeps the low end tight, so a touch of top-end EQ taming after the fact is usually enough rather than reaching for less drive. - **Leave the safety clip off during tracking/mixing**, and only reach for it as insurance against unexpected automation or a hot input on a specific pass - it's a safety net, not part of the intended tone-shaping signal path. diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000..85e00b4 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,20 @@ +# Factory presets + +Nine factory presets ship with Crypta's v0.2.0 M2 preset system. All settings are starting +points designed against the research-derived v0.2.0 defaults (`docs/design-brief.md`'s Factory +Presets section), not exact renders against any reference material. `IR Enable` presets below +turn the IR loader stage on but do not load an actual impulse response - v0.2.0 does not yet +bundle factory cabinet IRs (same as v0.1.x; see `docs/manual.md`), so those presets currently +run the loader in its safe-by-default identity-passthrough state until a user loads their own IR. + +| Preset | Category | Intent | +|---|---|---| +| **Default** | Init | The plain `ParameterLayout` defaults, loaded on a fresh instance - identical settings to "Glue & Grind" below, filed separately under the technical `Init`/"Default" name the preset system's default-resolution order looks for. | +| **Glue & Grind** | Bass | The shipped default character: fast/gentle low-band glue compression, moderate mid saturation, tight high-band fuzz. | +| **Sub Lock** | Bass | Maximum low-end control for a dense mix; the low band does almost all the work, mids/highs kept modest. | +| **Throat** | Bass | Emphasizes the mid band's documented "throatier" character; the mid band carries most of the grind. | +| **Fuzz Wall** | Bass | Maximum documented "fuzz" pull: Tight at its sourced floor (100 Hz), aggressive Wool voicing at high Drive. | +| **Cut Through** | Bass | Drop-tuned rhythm use case: both splits pushed up so more note body reaches the distorted bands. | +| **Definition Only** | Bass | Showcases the high band's harshness-control role: Tight pulled up, Drive kept moderate, EQ presence bump engaged. | +| **Clean Low, Loud Top** | Bass | Low band audibly present but mostly uncompressed (Mix pulled down), mid/high pushed harder. | +| **Cab-Colored Grind** | Bass | Demonstrates the v0.2.0-relocated IR loader coloring only the Mid+High path while the low end stays uncolored. | diff --git a/docs/research-notes.md b/docs/research-notes.md new file mode 100644 index 0000000..612aa06 --- /dev/null +++ b/docs/research-notes.md @@ -0,0 +1,249 @@ +# Crypta — Deep-Dive Research Notes (v2 brief prep) + +Scope: Crypta is a "Parallax-style" parallel bass processor — LR4 band-split, parallel-compress +the lows, distort the highs, sum back through EQ + cab IR. This file collects sourced findings +on the reference class before the v2 brief proposes changes. Research-derived, not +hardware-measured; see the Honesty section of the brief for the disclosure this feeds. + +## 1. v1 as-built (ground truth, from local repo read) + +Source: `/Users/yves/Development/Audio/twist-your-guts` @ `main` (README.md, docs/manual.md, +src/dsp/*.{h,cpp}), read 2026-07-16. + +- **Topology**: Input Trim → Gate (full-band, off by default) → **2-band** LR4 split + (60–1000 Hz, default 250 Hz, log taper) → [Low: `juce::dsp::Compressor` parallel comp + (thresh −60..0 dB def −18, ratio 1..20 def 4, attack 0.1..100 ms def 10, release 10..1000 ms + def 120, makeup −12..+24 dB def 0, mix 0..100% def 100) → Level] + [High: voicing select + (Gnaw hard-clip / Wool cascaded asymmetric soft-clip / Razor mild tanh + pre-HPF + mid-hump), + 4x oversampled, Drive 0..100%, Tone 0..100% (post-shaper 1st-order LPF sweep 700 Hz–15 kHz), + Blend 0..100% def 100, Level] → delay-compensated sum → 4-band EQ (LowShelf/Peak/Peak/ + HighShelf, off by default, ±18 dB) → convolution IR loader (off by default, no factory IRs + yet) → optional tanh safety clip → Output Trim. +- Compressor is plain `juce::dsp::Compressor` (feed-forward VCA, no lookahead, 0 + latency) wrapped in a `DryWetMixer` for the parallel blend — architecturally sound, but + ballistics/ratio defaults are generic ("reasonable" numbers, not sourced). +- Voicing engineering constants (`Voicing.cpp`): Gnaw max drive gain 40x hard-clamped ±1; + Wool two-stage tanh (12x/6x max gain, 0.15 asymmetry bias); Razor tanh (8x max gain) + 200 Hz + pre-HPF (Q 0.7071) + 900 Hz mid-hump (+5 dB, Q 1.0); Wool has a 500 Hz mid-scoop (−6 dB, Q + 0.9); Gnaw has a nominal flat "mid filter" at 1 kHz/0 dB/Q 0.7 (present in code but inert at + 0 dB gain). All three share one oversampled shaping call with a single Tone LPF (700 Hz–15 + kHz) after the mid filter. Code comments explicitly flag these as engineering defaults, "not + yet finalized by ear against reference material" (docs/manual.md line 108, src/dsp/ + Voicing.cpp comment). +- Docs already frame Crypta as "Parallax-style" and explicitly note non-affiliation with + "Neural DSP or the makers of any Parallax-branded product" (README.md). +- Test suite (Catch2): per-stage null/passthrough tests, flat-sum crossover tests (±0.1 dB), + monotonic-harmonic-energy-with-drive test for voicings, NaN/Inf robustness sweeps, state + round-trip. No test currently asserts anything about *frequency-dependent* clipping behavior, + ballistics-vs-reference-class accuracy, or EQ-frequency placement rationale — all category- + specific "does this sound like the reference class" guarantees are absent, only generic DSP + hygiene is covered. + +## 2. Reference class definition + +Crypta's own docs point at "Parallax-branded" plugins, i.e. **Neural DSP Parallax** (2020, +designed by Doug Castro — founder of Darkglass Electronics — and Francisco Cresp of Neural +DSP) as the primary named reference. Darkglass's own hardware distortion pedals (the same +designer's other work, e.g. Alpha•Omega / B7K family) are the second reference: they define +the "blend a clean low end with a distorted top end" mechanism in the analog-pedal world that +Parallax explicitly digitizes into a multiband plugin. A third, generic reference is the +"New York style" parallel-bus-compression convention (dbx 160-class hardware, drum/bass bus +compression folklore) that both v1's own manual and Parallax's low-band compressor draw on. + +## 3. Neural DSP Parallax — primary reference, sourced numbers + +Source: official **Parallax User's Guide v2.0.0** PDF, fetched and read in full (17 pages), +`https://downloads.neuraldsp.com/file/parallax-installers/Parallax-v2.0.0.pdf`. + +- **"Parallax is a multi-band distortion for bass. This plugin is meant to bring the user a + ready tool, which is based on a studio technique used by audio engineers and producers to + craft their bass tone. Bass, mids, and high frequencies are processed separately with + distortion and compression to be mixed back together."** (p.8) — this is the direct + definition of the category Crypta claims to be part of. +- **Band topology is 3-way, not 2-way**: Low / Mid / Treble, each independently enabled. This + is a structural difference from Crypta's 2-band (Low/High) split. "Bass, mids, and high + frequencies are processed separately" (p.8) — the whole premise of the category, per the + designer, is a *three*-way split, not two. + - Low band: **Low Pass filter** (variable — "for perfect control over the bottom end + response") + Compression + Level. **Bypasses the cab-sim entirely and stays mono even in + stereo input mode** ("The low band signal passes straight to the graphic equalizer + bypassing the cabsim, and it remains mono while in stereo input mode." p.9). + - Mid band: **Mid Drive** (tube-gain-stage saturation) + Mid Level only — no filter exposed + on this band in the UI; its center is fixed. Per third-party review (Guitar Interactive + Magazine, see §5): "The mid band, as described, is fixed around 400hz." + - High/Treble band: **High Pass filter** (variable, "allows dialing the perfect amount of + fuzz or tightness") + High Drive + High Level. Per the same review, the HPF is "pullable + down to 100Hz." + - Framing: "Dialing a high gain sound with presence, definition, and clarity requires + removing a certain amount of low-end from the spectrum to be distorted." (p.9) — i.e. the + HPF-before-distortion move (which Crypta's Razor voicing already does, but only for one of + three voicings, and only as a fixed 200 Hz corner) is presented as *the* generic technique + for the whole high-distortion band, not a single voicing's quirk. +- **Low-band compressor — exact fixed ballistics, the single most load-bearing sourced number + in this research pass**: "COMPRESSION KNOB: Drag and move it to set the amount of gain + reduction and make up gain from 0dB to +10dB. **Fixed settings: Attack 3ms - Release 6ms - + Ratio 2.0.**" (p.9, verbatim, bold in original manual). This is dramatically different from + Crypta v1's low-comp defaults (ratio 4:1, attack 10 ms, release 120 ms — a much slower, + harder-hitting compressor). Parallax's reference-class low-band compressor is a **fast, + low-ratio "glue" bus compressor** — a single combined Compression knob sets *only* gain- + reduction depth/makeup, not ratio or ballistics, because the ballistics are deliberately + fixed at values that behave more like parallel-bus glue than a dynamics tool. This matches + general "bus compression" folklore (dbx 160-class hardware bass-bus compression commonly runs + 4–6 dB of GR at fast attack/release for "evening out low frequencies... more controlled and + punchy" — TalkBass community sourcing, §4) but Parallax's own numbers are far faster and + gentler-ratio than that generic folklore, confirming a deliberate "glue, not squash" design + choice specific to this category. +- **Distortion is described as tube-modeled saturation ("multiple tube gain stages"), not + simple hard-clip/tanh math**: "Individual multiple tube gain stages for Mid and Treble." + (p.2, feature list) "The Mid Drive has enough dynamic range to go from mild saturation to + blistering high gain, all without losing definition and articulation. Multiple tube gain + stages were designed for the Mid and Treble bands separately." (p.9) This is presented as a + cascaded-gain-stage character (closer to Crypta's Wool voicing, which already cascades two + stages) rather than Gnaw's single hard-clamp, suggesting the reference class leans toward + cascaded/staged saturation as the norm, not the exception. +- **EQ section — fixed 6-band graphic EQ, exact frequencies**: "Low Shelf: 100Hz, 250Hz, + 500Hz, 1.0kHz, 1.5kHz, 5.0kHz [likely OCR of a final band labeled High Shelf], boost/cut + −12dB to +12dB." (p.10) These are graphic-EQ fixed bands (not parametric peak/shelf with + variable frequency/Q, unlike Crypta's fully parametric 4-band EQ) — a deliberately simpler, + "just enough to fix a mix problem" EQ, not a sound-design tool. Crypta's EQ (4-band, fully + parametric, ±18 dB) is already more capable than the reference's graphic EQ; no functional + gap here, but the fixed reference frequencies are useful anchors for factory-preset EQ + moves. +- **Gate**: single "Gate Knob: Attenuates the input signal below the threshold" — no separate + ratio/attack/release exposed at all (simpler than Crypta's 4-parameter gate). Confirms a gate + is a secondary, coarse safety feature in this category, not a sound-design control — Crypta's + richer gate is not a gap, it's already more capable. +- **Cab sim**: 6 movable virtual mic positions/distances, low band explicitly bypasses it. + Confirms the "low band skips the cab/IR stage" pattern as a category convention, which Crypta + v1 does *not* currently implement (Crypta's IR loader sits post-sum, after both bands are + already combined) — flagged as a gap. +- **Input/Output gain framing**: "Input will affect how much signal the plugin will feed in. + This will affect the amount of distortion range of the gain knobs..." (p.12) — i.e. input + trim is explicitly documented as *part of* the distortion-amount control, not just gain + staging. Matches Crypta's existing Input Gain framing in its own manual ("all of their + thresholds are calibrated assuming a reasonably 'line level' input") — no gap here. + +## 4. Parallel ("New York") bus compression — general technique background + +Source: WebSearch aggregation across `dbxpro.com`, `talkbass.com`, `gearspace.com` forum +threads on New York/parallel bus compression (accessed 2026-07-16; no single canonical primary +source URL, treat as corroborated community/vendor consensus, not a single citable manual). + +- "New York compression is also called 'Parallel Compression'... you need a much higher ratio + (often starting at 10:1, which is really more like limiting), with fast attack times set as + fast as possible while still allowing the leading edge of the transient through... Typical + settings include 8–16 ratio with weird attack and release settings, but for safety, keep fast + attack and long release." — general drum/bus New York-compression folklore. +- Bass-specific dbx 160 guidance (TalkBass community consensus): "use a ratio of 5:1 and set + the threshold so you're getting about 4–6 dB of gain reduction, as the DBX 160's fast + response evens out low frequencies and makes the bass feel more controlled and punchy." +- **Reading against Parallax's own numbers (§3)**: Parallax's fixed low-band compressor + (ratio 2.0, attack 3 ms, release 6 ms) is *gentler in ratio* and *dramatically faster in + release* than either the generic New York-drum convention (8–16:1) or the bass-specific dbx + 160 folklore (5:1). This confirms Parallax is not doing "New York style" heavy parallel + compression in the classic drum-bus sense at all — it is doing something closer to a fast + "glue"/optical-style bus compressor blended in parallel, calibrated for a *few dB* of + continuous gentle control rather than big, audible pumping. Crypta v1's manual explicitly + invokes "New York style" framing (`docs/manual.md` line 78) and a heavier 4:1/10 ms/120 ms + default — this is the single largest characterization mismatch found in this research pass: + v1's own naming ("New York style") points at the wrong sub-genre of parallel compression for + what its named reference (Parallax) actually does. + +## 5. Third-party review commentary (workflow lore) + +Source: Guitar Interactive Magazine review of Neural DSP Parallax, +`https://guitarinteractivemagazine.com/review/neural-dsp-parallax/` (accessed 2026-07-16). + +- "The mid band, as described, is fixed around 400hz." — corroborates the mid band's fixed + center per §3. +- On the low band: distortion "can make anything sound stodgy and incoherent" by compressing + transients — i.e. the reviewer frames the low band's job as compression-only (never + distortion) precisely because low-frequency distortion reads as mud, reinforcing why the + reference class keeps the low band's processing to compression/level only, never saturation. +- On the high/treble band: the HPF-before-distortion move "tames harshness... when it comes to + high saturation drive sounds" — i.e. the pre-clip HPF is framed as *harshness control*, not + (only) a tightness/mud-control move. This nuances Crypta's existing Razor-only 200 Hz pre-HPF: + the reference class treats a pre-distortion HPF as a universal high-band hygiene move across + *all* voicings, not a single voicing's identity trait. +- Workflow: "crafting low-end punch, applying band specific compression to tighten it up, + careful midrange treatment, and using the built-in EQ for final shaping" — a mid-first, then + EQ-to-finish order, consistent with (not contradicting) Crypta's existing manual tip to "set + the low band first, then dial in the high band." + +## 6. Darkglass Alpha•Omega — same designer's hardware lineage, tone-stack anchors + +Source: `https://www.darkglass.com/pages/alpha-omega-manual` (accessed 2026-07-16). Doug +Castro (Darkglass founder) co-designed Parallax, so Darkglass's own hardware tone-stack +frequency choices are a reasonable secondary anchor for EQ-band placement, though this is +*not* the same product and should not be over-indexed. + +- "Blend: Mixes the clean and processed signals. The clean signal remains at unity gain while + the volume of the overdriven signal is set by the Level knob, allowing for fine mix tuning." + — the canonical "clean stays at unity, wet gets its own level, blend crossfades" pattern; + matches Crypta's existing High Blend + High Level pairing structurally already. +- "Mod: Selects or mixes between the two distinct distortion circuits: Alpha is punchy, tight + with a lot of definition, whereas Omega is simply brutal and raw." — a *continuously + blendable* pair of distortion characters (not a hard 3-way switch), a UX pattern Crypta's + discrete Gnaw/Wool/Razor selector does not currently offer; noted as a possible future + direction, out of scope for this v2 pass (kept as a discrete selector per the honesty/scope + section below). +- Fixed EQ-band anchors: **Bass ±12 dB @ 80 Hz, Mid ±12 dB @ 500 Hz, Treble ±12 dB @ 5 kHz, + Bite (high-mid presence boost) @ 2.8 kHz, Growl = shelving bass boost + increased low-end + saturation.** These four frequencies (80 Hz / 500 Hz / 2.8 kHz / 5 kHz) are a well- + corroborated, purpose-built "bass tone stack" frequency set from the same design lineage as + Parallax, useful as sourced anchors for Crypta's post-sum EQ factory-preset defaults (Crypta + already has a fully parametric 4-band EQ with generic corners of 100/500/2500/8000 Hz — close + to, but not exactly anchored on, these sourced bass-specific corners). + +## 7. Gaps identified — v1 vs. reference class (feeds directly into the brief's §1) + +1. **Compressor ballistics/ratio mismatch (largest, most sourced gap).** v1: ratio 4:1, attack + 10 ms, release 120 ms, manual explicitly says "New York style." Reference (Parallax, exact + fixed values): ratio 2.0, attack 3 ms, release 6 ms — much faster, gentler-ratio "glue" bus + compression, not classic New York drum-style parallel squashing. v1's own naming points at + the wrong technique for what its stated reference actually implements. +2. **2-band split vs. reference's 3-band split.** The reference class's defining move — + "bass, mids, and high frequencies are processed separately" — is a 3-way split with a fixed- + ish mid band around 400 Hz doing tube-style saturation, distinct from the treble band's + harshness-controlled saturation. v1's 2-band (Low/High) split conflates what the reference + class treats as two functionally distinct distorted bands (mid = "throatier color, note + articulation"; high = "presence, fuzz/tightness, harshness control"). +3. **Pre-distortion HPF is voicing-specific in v1, band-wide in the reference.** Only Razor + gets a pre-clip HPF (fixed 200 Hz) in v1; the reference class treats a variable pre- + distortion HPF as the primary "how much fuzz vs. tightness" control for the *entire* high + band, available regardless of which saturation character is selected. +4. **Low band never gets distortion/saturation in the reference class, confirmed by both the + manual's architecture (low band bypasses cab-sim, stays mono) and third-party commentary + (low-frequency distortion reads as "stodgy and incoherent").** v1 already respects this + (low band is compression-only) — not a gap, a confirmed-correct existing decision worth + stating explicitly as validated rather than silently unchanged. +5. **IR/cab-sim routing gap.** Reference class explicitly routes the low band around the cab + sim entirely (mono, bypasses convolution). v1's IR loader sits post-sum, processing the + combined low+high signal — the low band's punch/mono character is not protected from cab-sim + coloration/stereo-izing the way the reference class protects it. +6. **EQ frequency anchors are generic, not sourced.** v1's 4-band EQ defaults (100/500/2500/ + 8000 Hz) are plausible but arbitrary; the same-designer hardware lineage (Darkglass) offers + sourced bass-tone-stack anchors (80/500/2.8k/5k Hz) that are a better-grounded starting + point for factory presets, even though v1's fully parametric EQ already exceeds the + reference's fixed 6-band graphic EQ in capability (not a functional gap, a defaults gap). +7. **Voicing character is cascaded/staged saturation in the reference, single-stage in two of + v1's three voicings.** Only Wool cascades two shaping stages; the reference's "multiple tube + gain stages... designed for the Mid and Treble bands separately" language suggests staged + saturation is the category norm, not Wool's special case. (Flagged as a directional finding, + not a numerically sourced one — the manual doesn't give stage counts or gain-per-stage + numbers, so this cannot be over-specified in the brief.) + +## Sources (deduplicated) + +- Neural DSP, *Parallax User's Guide v2.0.0* (PDF, 17pp), designer credits + full parameter + reference + fixed compressor ballistics: https://downloads.neuraldsp.com/file/parallax-installers/Parallax-v2.0.0.pdf +- Guitar Interactive Magazine, Neural DSP Parallax review (workflow/mid-band-fixed-400Hz + commentary): https://guitarinteractivemagazine.com/review/neural-dsp-parallax/ +- Darkglass Electronics, Alpha•Omega manual (tone-stack frequency anchors, Blend/Mod pattern): + https://www.darkglass.com/pages/alpha-omega-manual +- WebSearch aggregation, "New York compression" / dbx 160 bass bus-compression convention + (dbxpro.com, talkbass.com, gearspace.com; no single canonical URL — community/vendor + consensus): queried via WebSearch 2026-07-16, no single primary page fetched. +- Local repo ground truth: `/Users/yves/Development/Audio/twist-your-guts` `README.md`, + `docs/manual.md`, `src/dsp/{ParallelCompressor,Voicing,Crossover,NoiseGateStage}.{h,cpp}`, + `tests/*.cpp` — read directly, `main` branch, 2026-07-16. diff --git a/presets/factory/cabColoredGrind.json b/presets/factory/cabColoredGrind.json new file mode 100644 index 0000000..e3e9111 --- /dev/null +++ b/presets/factory/cabColoredGrind.json @@ -0,0 +1,17 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Cab-Colored Grind", + "category": "Bass", + "parameters": { + "splitLowHz": 120.0, + "splitHighHz": 600.0, + "midDrive": 35.0, + "highTightHz": 100.0, + "highVoicing": 1.0, + "highDrive": 60.0, + "irEnabled": 1.0, + "irMix": 70.0 + } +} diff --git a/presets/factory/cleanLowLoudTop.json b/presets/factory/cleanLowLoudTop.json new file mode 100644 index 0000000..64827ab --- /dev/null +++ b/presets/factory/cleanLowLoudTop.json @@ -0,0 +1,16 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Clean Low, Loud Top", + "category": "Bass", + "parameters": { + "splitLowHz": 120.0, + "splitHighHz": 600.0, + "lowCompMix": 40.0, + "midDrive": 45.0, + "highTightHz": 100.0, + "highVoicing": 0.0, + "highDrive": 75.0 + } +} diff --git a/presets/factory/cutThrough.json b/presets/factory/cutThrough.json new file mode 100644 index 0000000..3c2c5ab --- /dev/null +++ b/presets/factory/cutThrough.json @@ -0,0 +1,16 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Cut Through", + "category": "Bass", + "parameters": { + "splitLowHz": 180.0, + "splitHighHz": 900.0, + "lowCompRatio": 2.0, + "midDrive": 40.0, + "highTightHz": 130.0, + "highVoicing": 2.0, + "highDrive": 55.0 + } +} diff --git a/presets/factory/default.json b/presets/factory/default.json new file mode 100644 index 0000000..e048b35 --- /dev/null +++ b/presets/factory/default.json @@ -0,0 +1,20 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Default", + "category": "Init", + "parameters": { + "splitLowHz": 120.0, + "splitHighHz": 600.0, + "lowCompRatio": 2.0, + "lowCompAttack": 3.0, + "lowCompRelease": 6.0, + "lowCompMix": 100.0, + "midDrive": 30.0, + "highTightHz": 100.0, + "highVoicing": 0.0, + "highDrive": 50.0, + "highBlend": 100.0 + } +} diff --git a/presets/factory/definitionOnly.json b/presets/factory/definitionOnly.json new file mode 100644 index 0000000..8aa4e83 --- /dev/null +++ b/presets/factory/definitionOnly.json @@ -0,0 +1,18 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Definition Only", + "category": "Bass", + "parameters": { + "splitLowHz": 110.0, + "splitHighHz": 650.0, + "midDrive": 20.0, + "highTightHz": 250.0, + "highVoicing": 2.0, + "highDrive": 30.0, + "eqEnabled": 1.0, + "eqPeak2Freq": 2800.0, + "eqPeak2Gain": 3.0 + } +} diff --git a/presets/factory/fuzzWall.json b/presets/factory/fuzzWall.json new file mode 100644 index 0000000..839e530 --- /dev/null +++ b/presets/factory/fuzzWall.json @@ -0,0 +1,17 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Fuzz Wall", + "category": "Bass", + "parameters": { + "splitLowHz": 130.0, + "splitHighHz": 550.0, + "lowCompRatio": 2.0, + "midDrive": 25.0, + "highTightHz": 100.0, + "highVoicing": 1.0, + "highDrive": 85.0, + "highBlend": 100.0 + } +} diff --git a/presets/factory/glueAndGrind.json b/presets/factory/glueAndGrind.json new file mode 100644 index 0000000..b93f998 --- /dev/null +++ b/presets/factory/glueAndGrind.json @@ -0,0 +1,20 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Glue & Grind", + "category": "Bass", + "parameters": { + "splitLowHz": 120.0, + "splitHighHz": 600.0, + "lowCompRatio": 2.0, + "lowCompAttack": 3.0, + "lowCompRelease": 6.0, + "lowCompMix": 100.0, + "midDrive": 30.0, + "highTightHz": 100.0, + "highVoicing": 0.0, + "highDrive": 50.0, + "highBlend": 100.0 + } +} diff --git a/presets/factory/subLock.json b/presets/factory/subLock.json new file mode 100644 index 0000000..c9a05a5 --- /dev/null +++ b/presets/factory/subLock.json @@ -0,0 +1,19 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Sub Lock", + "category": "Bass", + "parameters": { + "splitLowHz": 90.0, + "splitHighHz": 500.0, + "lowCompRatio": 3.0, + "lowCompAttack": 3.0, + "lowCompRelease": 6.0, + "lowCompMakeup": 2.0, + "midDrive": 15.0, + "highTightHz": 150.0, + "highVoicing": 2.0, + "highDrive": 35.0 + } +} diff --git a/presets/factory/throat.json b/presets/factory/throat.json new file mode 100644 index 0000000..56e9cdf --- /dev/null +++ b/presets/factory/throat.json @@ -0,0 +1,17 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Throat", + "category": "Bass", + "parameters": { + "splitLowHz": 100.0, + "splitHighHz": 800.0, + "lowCompRatio": 2.0, + "midDrive": 65.0, + "midLevel": 2.0, + "highTightHz": 120.0, + "highVoicing": 1.0, + "highDrive": 30.0 + } +} diff --git a/resources/i18n/de.txt b/resources/i18n/de.txt new file mode 100644 index 0000000..f3e7b18 --- /dev/null +++ b/resources/i18n/de.txt @@ -0,0 +1,21 @@ +language: German +countries: de at ch + +"Init" = "Init" +"Factory" = "Werksvoreinstellungen" +"User" = "Eigene" +"Set current as default" = "Aktuelle als Standard festlegen" +"Save" = "Speichern" +"Save As..." = "Speichern unter..." +"Delete" = "Löschen" +"Import..." = "Importieren..." +"Export..." = "Exportieren..." +"Enter a name for the new preset:" = "Namen für die neue Voreinstellung eingeben:" +"Preset name" = "Name der Voreinstellung" +"Cancel" = "Abbrechen" +"Import a preset or preset bank..." = "Voreinstellung oder Voreinstellungs-Sammlung importieren..." +"Import failed" = "Import fehlgeschlagen" +"Export preset..." = "Voreinstellung exportieren..." +"This file is not a valid preset." = "Diese Datei ist keine gültige Voreinstellung." +"This preset was saved by an incompatible version of the preset format." = "Diese Voreinstellung wurde mit einer inkompatiblen Version des Voreinstellungsformats gespeichert." +"This preset file belongs to a different plugin." = "Diese Voreinstellungsdatei gehört zu einem anderen Plugin." diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index 265db9b..5dfec52 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -1,18 +1,51 @@ #include "PluginEditor.h" #include "PluginProcessor.h" +#include "presets/Localisation.h" + +#include + +namespace +{ + constexpr int presetBarHeight = 28; + constexpr int margin = 4; + + // M2 i18n frame (.scaffold/specs/preset-system-m2.md): selects German + // (resources/i18n/de.txt) or falls through to English, once, at editor + // construction - see Localisation.h's docs. `presetBar` is a member + // initialised via the constructor's initialiser list, and its own + // constructor already calls TRANS() on every button label - member + // initialisers run in declaration order regardless of the order they're + // written in, so this helper (called from presetBar's own initialiser + // expression below) is what actually guarantees installLocalisation() + // runs before presetBar exists, not a installLocalisation() call in the + // constructor *body*, which would run too late. + basilica::presets::PresetManager& initLocalisationThenGetPresetManager (CryptaAudioProcessor& processor) + { + basilica::presets::installLocalisation (BinaryData::de_txt, BinaryData::de_txtSize); + return processor.presetManager; + } +} CryptaAudioProcessorEditor::CryptaAudioProcessorEditor (CryptaAudioProcessor& processorToEdit) : juce::AudioProcessorEditor (&processorToEdit), + presetBar (initLocalisationThenGetPresetManager (processorToEdit)), genericEditor (processorToEdit) { + addAndMakeVisible (presetBar); addAndMakeVisible (genericEditor); + setResizable (true, true); - setSize (genericEditor.getWidth(), genericEditor.getHeight()); + setSize (genericEditor.getWidth(), presetBarHeight + margin + genericEditor.getHeight()); } CryptaAudioProcessorEditor::~CryptaAudioProcessorEditor() = default; void CryptaAudioProcessorEditor::resized() { - genericEditor.setBounds (getLocalBounds()); + auto bounds = getLocalBounds(); + + presetBar.setBounds (bounds.removeFromTop (presetBarHeight)); + bounds.removeFromTop (margin); + + genericEditor.setBounds (bounds); } diff --git a/src/PluginEditor.h b/src/PluginEditor.h index 9f3d039..64216d5 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -2,11 +2,14 @@ #include +#include "presets/PresetBar.h" + class CryptaAudioProcessor; -// Minimal editor: wraps JUCE's GenericAudioProcessorEditor so every -// APVTS parameter gets a working control for free. A custom GUI replaces -// this in a later milestone. +// Minimal editor: a M2 preset bar (src/presets/PresetBar.h) docked at the +// top, wrapping JUCE's GenericAudioProcessorEditor below it so every APVTS +// parameter still gets a working control for free. A custom GUI replaces +// this in a later milestone (M3). class CryptaAudioProcessorEditor final : public juce::AudioProcessorEditor { public: @@ -16,6 +19,13 @@ class CryptaAudioProcessorEditor final : public juce::AudioProcessorEditor void resized() override; private: + // M2 preset system (src/presets/PresetBar.h) - a horizontal strip + // docked at the top of the editor. Constructed after the localisation + // frame is installed (see the constructor) so its TRANS()'d strings (and + // any of its own dialogs opened later) pick up the right language from + // the very first paint. + basilica::presets::PresetBar presetBar; + juce::GenericAudioProcessorEditor genericEditor; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CryptaAudioProcessorEditor) diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index 249f21d..572570a 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -1,8 +1,11 @@ #include "PluginProcessor.h" #include "PluginEditor.h" +#include "dsp/SplitGap.h" #include "params/ParameterIds.h" #include "params/ParameterLayout.h" +#include + #include namespace @@ -10,6 +13,103 @@ namespace // ~20ms smoothing ramp for gain changes: fast enough to feel responsive, // slow enough to avoid zipper noise on parameter automation. constexpr double gainRampDurationSeconds = 0.02; + + //========================================================================== + // v0.1.x -> v0.2.0 structural state migration (docs/design-brief.md's + // "State migration" guarantee #7): v1 sessions serialize a single + // "crossoverFreq" PARAM element (id="crossoverFreq") - that parameter ID + // no longer exists in v0.2.0's ParameterLayout, replaced by the + // splitLowHz/splitHighHz pair. Best-effort, lossy, one-directional: the + // old single split value is closer in role to the new splitHighHz (the + // v2 high-band edge) than to splitLowHz, since v1 never had a dedicated + // mid band - clamped into splitHighHz's new 300-2000 Hz range on import. + // v1's shipped default crossoverFreq is 250 Hz, i.e. below that new + // floor, so the single most common migration path (an untouched v1 + // session) lands exactly at the 300 Hz floor - see + // tests/StateMigrationTests.cpp's dedicated test for that path. + // + // splitLowHz itself is deliberately NOT injected here: it simply falls + // back to its own v0.2.0 ParameterLayout default (120 Hz) via + // AudioProcessorValueTreeState::replaceState()'s normal "unmentioned + // parameter ID keeps its current/default value" behaviour - no v1 value + // exists to migrate it from in the first place. Same for every new + // mid-band/Tight parameter. + constexpr float legacySplitHighFloorHz = 300.0f; + constexpr float legacySplitHighCeilingHz = 2000.0f; + constexpr float legacyCrossoverDefaultHz = 250.0f; + + void migrateLegacySingleCrossover (juce::XmlElement& stateXml) + { + for (auto* paramXml : stateXml.getChildIterator()) + { + if (! paramXml->hasTagName ("PARAM") || paramXml->getStringAttribute ("id") != "crossoverFreq") + continue; + + const auto legacyHz = static_cast (paramXml->getDoubleAttribute ("value", legacyCrossoverDefaultHz)); + const auto migratedSplitHighHz = juce::jlimit (legacySplitHighFloorHz, legacySplitHighCeilingHz, legacyHz); + + // A v0.2.0+ session's own saved state never contains a + // "crossoverFreq" element at all (the parameter no longer + // exists), so this only ever fires for genuine v1 state - but + // guard against a malformed/hand-edited file claiming both + // anyway, rather than overwriting an explicit splitHighHz value + // that's already present. + if (stateXml.getChildByAttribute ("id", "splitHighHz") == nullptr) + { + auto* splitHighXml = new juce::XmlElement ("PARAM"); + splitHighXml->setAttribute ("id", "splitHighHz"); + splitHighXml->setAttribute ("value", static_cast (migratedSplitHighHz)); + stateXml.addChildElement (splitHighXml); + } + + break; + } + } + + //========================================================================== + // M2 preset system (.scaffold/specs/preset-system-m2.md, + // docs/preset-system-notes.md's replication recipe from basilica-audio/ + // nave's pilot). The small, Crypta-specific config surface + // basilica::presets::PresetManager needs - everything else about the + // preset system is fully generic and portable across the suite. + basilica::presets::PresetManagerConfig makePresetManagerConfig() + { + // JucePlugin_CFBundleIdentifier expands to a raw (unquoted) token + // sequence, not a string literal - JUCE_STRINGIFY() is the + // documented way to turn it into one. Always "com.yvesvogl.crypta" + // here (BUNDLE_ID in CMakeLists.txt), matching the "plugin" field + // baked into every presets/factory/*.json file. + basilica::presets::PresetManagerConfig config; + config.pluginId = JUCE_STRINGIFY (JucePlugin_CFBundleIdentifier); + config.pluginName = JucePlugin_Name; + config.manufacturerName = "Yves Vogl"; + config.pluginVersion = JucePlugin_VersionString; + // userPresetsDirectoryOverrideForTests intentionally left + // default-constructed (empty) - production instances always use the + // real platform-standard preset location (see PresetManager.h). + return config; + } + + // BinaryData symbol names are derived from the presets/factory/*.json + // file names passed to juce_add_binary_data() in CMakeLists.txt (dots + // become underscores) - this list must stay in sync with that SOURCES + // list. Order here only affects factory-preset iteration order before + // getAllPresets() re-sorts alphabetically, so it isn't otherwise + // significant. + std::vector makeFactoryPresetAssets() + { + return { + { BinaryData::default_json, BinaryData::default_jsonSize }, + { BinaryData::glueAndGrind_json, BinaryData::glueAndGrind_jsonSize }, + { BinaryData::subLock_json, BinaryData::subLock_jsonSize }, + { BinaryData::throat_json, BinaryData::throat_jsonSize }, + { BinaryData::fuzzWall_json, BinaryData::fuzzWall_jsonSize }, + { BinaryData::cutThrough_json, BinaryData::cutThrough_jsonSize }, + { BinaryData::definitionOnly_json, BinaryData::definitionOnly_jsonSize }, + { BinaryData::cleanLowLoudTop_json, BinaryData::cleanLowLoudTop_jsonSize }, + { BinaryData::cabColoredGrind_json, BinaryData::cabColoredGrind_jsonSize }, + }; + } } //============================================================================== @@ -17,14 +117,17 @@ CryptaAudioProcessor::CryptaAudioProcessor() : AudioProcessor (BusesProperties() .withInput ("Input", juce::AudioChannelSet::stereo(), true) .withOutput ("Output", juce::AudioChannelSet::stereo(), true)), - apvts (*this, nullptr, "PARAMETERS", createParameterLayout()) + apvts (*this, nullptr, "PARAMETERS", createParameterLayout()), + presetManager (apvts, makePresetManagerConfig(), makeFactoryPresetAssets()) { inputGainDb = apvts.getRawParameterValue (ParamIDs::inputGain); outputGainDb = apvts.getRawParameterValue (ParamIDs::outputGain); bypassFlag = apvts.getRawParameterValue (ParamIDs::bypass); outputClipEnabled = apvts.getRawParameterValue (ParamIDs::outputClip); - crossoverFreqHz = apvts.getRawParameterValue (ParamIDs::crossoverFreq); + splitLowHzParam = apvts.getRawParameterValue (ParamIDs::splitLowHz); + splitHighHzParam = apvts.getRawParameterValue (ParamIDs::splitHighHz); lowLevelDb = apvts.getRawParameterValue (ParamIDs::lowLevel); + midLevelDb = apvts.getRawParameterValue (ParamIDs::midLevel); highLevelDb = apvts.getRawParameterValue (ParamIDs::highLevel); bypassParameter = apvts.getParameter (ParamIDs::bypass); @@ -41,6 +144,9 @@ CryptaAudioProcessor::CryptaAudioProcessor() lowCompMakeupDb = apvts.getRawParameterValue (ParamIDs::lowCompMakeup); lowCompMixPercent = apvts.getRawParameterValue (ParamIDs::lowCompMix); + midDrivePercent = apvts.getRawParameterValue (ParamIDs::midDrive); + + highTightHzParam = apvts.getRawParameterValue (ParamIDs::highTightHz); highVoicingChoice = apvts.getRawParameterValue (ParamIDs::highVoicing); highDrivePercent = apvts.getRawParameterValue (ParamIDs::highDrive); highTonePercent = apvts.getRawParameterValue (ParamIDs::highTone); @@ -65,8 +171,10 @@ CryptaAudioProcessor::CryptaAudioProcessor() jassert (outputGainDb != nullptr); jassert (bypassFlag != nullptr); jassert (outputClipEnabled != nullptr); - jassert (crossoverFreqHz != nullptr); + jassert (splitLowHzParam != nullptr); + jassert (splitHighHzParam != nullptr); jassert (lowLevelDb != nullptr); + jassert (midLevelDb != nullptr); jassert (highLevelDb != nullptr); jassert (bypassParameter != nullptr); @@ -83,6 +191,9 @@ CryptaAudioProcessor::CryptaAudioProcessor() jassert (lowCompMakeupDb != nullptr); jassert (lowCompMixPercent != nullptr); + jassert (midDrivePercent != nullptr); + + jassert (highTightHzParam != nullptr); jassert (highVoicingChoice != nullptr); jassert (highDrivePercent != nullptr); jassert (highTonePercent != nullptr); @@ -102,6 +213,11 @@ CryptaAudioProcessor::CryptaAudioProcessor() jassert (irEnabled != nullptr); jassert (irMixPercent != nullptr); + + // M2 default resolution: user "Default" preset > factory "Default" + // preset > the ParameterLayout defaults apvts was just constructed with + // above (see PresetManager::applyStartupDefault()'s docs). + presetManager.applyStartupDefault(); } CryptaAudioProcessor::~CryptaAudioProcessor() = default; @@ -136,11 +252,9 @@ bool CryptaAudioProcessor::isMidiEffect() const double CryptaAudioProcessor::getTailLengthSeconds() const { // Issue #58: report the IR loader's actual loaded-IR duration instead of - // a hardcoded 0 - 0.0 while only the safe-by-default identity IR is - // installed (matching the previous, correct-for-that-state behaviour), - // but the real convolution tail length once loadImpulseResponse() has - // installed a real cab IR, so hosts trusting this value for bounce/ - // freeze/render-tail decisions don't truncate it. + // a hardcoded 0 - unaffected by the IR loader's v0.2.0 relocation to the + // Mid+High branch, since this just reports the currently loaded IR's own + // duration regardless of where in the chain it sits. return irLoader.getTailLengthSeconds(); } @@ -186,10 +300,22 @@ void CryptaAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock // Full-band input noise gate. gate.prepare (spec); - // Issue #8: LR4 crossover, prepared for the same spec as the gain - // processors so its per-channel filter state matches the bus layout. - crossover.prepare (spec); - crossover.setCutoffFrequency (crossoverFreqHz->load (std::memory_order_relaxed)); + // v0.2.0: two cascaded LR4 crossovers. lowSplit peels off the Low band; + // midHighSplit further splits the remainder into Mid and High - see + // docs/design-brief.md's Topology section. + lowSplit.prepare (spec); + lowSplit.setCutoffFrequency (splitLowHzParam->load (std::memory_order_relaxed)); + + midHighSplit.prepare (spec); + midHighSplit.setCutoffFrequency (cryp::clampSplitHighHz (splitLowHzParam->load (std::memory_order_relaxed), + splitHighHzParam->load (std::memory_order_relaxed))); + + // v0.2.0: Low band phase-alignment allpass (see PluginProcessor.h's + // member docs / src/dsp/PhaseAlignFilter.h) - always tied to the same + // effective cutoff as midHighSplit above. + lowBandPhaseAlign.prepare (spec); + lowBandPhaseAlign.setCutoffFrequency (cryp::clampSplitHighHz (splitLowHzParam->load (std::memory_order_relaxed), + splitHighHzParam->load (std::memory_order_relaxed))); // Low-band parallel compressor. The DryWetMixer inside needs its mix // proportion primed *before* prepare() runs its internal reset() (JUCE @@ -197,16 +323,27 @@ void CryptaAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock // value is read and passed in here rather than set afterwards. lowCompressor.prepare (spec, lowCompMixPercent->load (std::memory_order_relaxed) / 100.0f); - // High-band oversampled distortion voicing. Same DryWetMixer-priming - // requirement for highBlend. + // Mid band: staged drive, no DryWetMixer (no blend control - see + // cryp::MidBand's class docs), so no priming gotcha here. + midBand.prepare (spec); + midBand.setDrive (midDrivePercent->load (std::memory_order_relaxed) / 100.0f); + + // High band: Tight pre-drive HPF + oversampled distortion voicing. Same + // DryWetMixer-priming requirement for highBlend as the low-band + // compressor above. highVoicing.prepare (spec, highBlendPercent->load (std::memory_order_relaxed) / 100.0f); + highVoicing.setTightHz (highTightHzParam->load (std::memory_order_relaxed)); - // Issue #10: independent per-band level trims, smoothed the same way as - // the input/output gains to avoid zipper noise on automation. + // Per-band level trims, smoothed the same way as the input/output gains + // to avoid zipper noise on automation. lowGainProcessor.setRampDurationSeconds (gainRampDurationSeconds); lowGainProcessor.prepare (spec); lowGainProcessor.setGainDecibels (lowLevelDb->load (std::memory_order_relaxed)); + midGainProcessor.setRampDurationSeconds (gainRampDurationSeconds); + midGainProcessor.prepare (spec); + midGainProcessor.setGainDecibels (midLevelDb->load (std::memory_order_relaxed)); + highGainProcessor.setRampDurationSeconds (gainRampDurationSeconds); highGainProcessor.prepare (spec); highGainProcessor.setGainDecibels (highLevelDb->load (std::memory_order_relaxed)); @@ -214,7 +351,9 @@ void CryptaAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock // Post-sum 4-band EQ. eq.prepare (spec); - // IR loader. Same DryWetMixer-priming requirement for irMix. + // IR loader. v0.2.0 relocates this stage to process the Mid+High + // post-sum signal only (see processChunk()) - its own prepare()/mix + // priming contract is unchanged from v1. irLoader.prepare (spec, irMixPercent->load (std::memory_order_relaxed) / 100.0f); // Issue #9: (re)allocate the low-band compensation delay line for the @@ -223,13 +362,16 @@ void CryptaAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock lowBandLatencyDelay.setMaximumDelayInSamples (maxLatencyCompensationSamples); lowBandLatencyDelay.prepare (spec); - // Pre-allocate the band buffers to the promised block size so + // Pre-allocate every band/scratch buffer to the promised block size so // processBlock() never resizes a buffer on the audio thread, even if a // host later sends an oversized block (handled defensively by chunking // in processBlock() - see processChunk()). preparedBlockSize = samplesPerBlock; lowBandBuffer.setSize (static_cast (spec.numChannels), preparedBlockSize); + remainderBandBuffer.setSize (static_cast (spec.numChannels), preparedBlockSize); + midBandBuffer.setSize (static_cast (spec.numChannels), preparedBlockSize); highBandBuffer.setSize (static_cast (spec.numChannels), preparedBlockSize); + midHighSumBuffer.setSize (static_cast (spec.numChannels), preparedBlockSize); updateLatencyCompensation(); } @@ -237,13 +379,15 @@ void CryptaAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock //============================================================================== int CryptaAudioProcessor::computeTotalLatencySamples() const noexcept { - // Issue #42: the high band's oversampled voicing stage is the only - // source of latency in the chain (the gate, low-band compressor, EQ and - // IR loader - default zero-latency Convolution - are all zero-latency), - // so the plugin's total reported latency is exactly the oversampling - // latency, and that is exactly what the low-band compensation delay - // needs to match to stay time-aligned with the high band at the sum. - return highVoicing.getLatencySamples(); + // v0.2.0: the Mid+High branch's two independently-owned but identically + // configured oversampling stages (cryp::MidBand, cryp::Voicing) are the + // only sources of latency in the chain (the gate, low-band compressor, + // EQ and IR loader - default zero-latency Convolution - are all + // zero-latency); their reported latencies are guaranteed numerically + // equal by construction (same factor/filter type - see MidBand.h's + // class docs), but jmax() here is a defensive, self-documenting + // guarantee rather than relying on that equality silently holding. + return juce::jmax (midBand.getLatencySamples(), highVoicing.getLatencySamples()); } void CryptaAudioProcessor::updateLatencyCompensation() @@ -253,9 +397,9 @@ void CryptaAudioProcessor::updateLatencyCompensation() setLatencySamples (totalLatencySamples); // The low band bypasses oversampling entirely, so it must be delayed by - // the same amount the high band's oversampling stage delays the high - // band, keeping both bands time-aligned when they are summed back - // together in processChunk(). + // the same amount the Mid+High branch's oversampling stages delay it, + // keeping all bands time-aligned when they are summed back together in + // processChunk(). lowBandLatencyDelay.setDelay (static_cast (totalLatencySamples)); } @@ -269,21 +413,21 @@ void CryptaAudioProcessor::reset() // Issue #56: clears every per-stage DSP class's own state (each already // exposes its own real-time-safe reset() for exactly this purpose - see // src/dsp/*.h) so a host transport stop/loop/rewind doesn't leave a - // decaying tail (crossover filter memory, gate/compressor envelopes, - // the high-band voicing's oversampling FIR/mid/tone filter state, the - // low-band latency-compensation delay line, EQ biquad history, or the - // IR convolution engine) ringing into whatever plays next. No - // allocation: every stage's reset() only clears already-allocated - // storage. + // decaying tail ringing into whatever plays next. No allocation: every + // stage's reset() only clears already-allocated storage. inputGainProcessor.reset(); outputGainProcessor.reset(); gate.reset(); - crossover.reset(); + lowSplit.reset(); + midHighSplit.reset(); + lowBandPhaseAlign.reset(); lowCompressor.reset(); + midBand.reset(); highVoicing.reset(); lowGainProcessor.reset(); + midGainProcessor.reset(); highGainProcessor.reset(); eq.reset(); @@ -331,8 +475,17 @@ void CryptaAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce: inputGainProcessor.setGainDecibels (inputGainDb->load (std::memory_order_relaxed)); outputGainProcessor.setGainDecibels (outputGainDb->load (std::memory_order_relaxed)); lowGainProcessor.setGainDecibels (lowLevelDb->load (std::memory_order_relaxed)); + midGainProcessor.setGainDecibels (midLevelDb->load (std::memory_order_relaxed)); highGainProcessor.setGainDecibels (highLevelDb->load (std::memory_order_relaxed)); - crossover.setCutoffFrequency (crossoverFreqHz->load (std::memory_order_relaxed)); + + const auto rawSplitLowHz = splitLowHzParam->load (std::memory_order_relaxed); + const auto rawSplitHighHz = splitHighHzParam->load (std::memory_order_relaxed); + const auto effectiveSplitHighHz = cryp::clampSplitHighHz (rawSplitLowHz, rawSplitHighHz); + lowSplit.setCutoffFrequency (rawSplitLowHz); + midHighSplit.setCutoffFrequency (effectiveSplitHighHz); + // Must always track midHighSplit's own effective cutoff exactly - see + // src/dsp/PhaseAlignFilter.h's class docs. + lowBandPhaseAlign.setCutoffFrequency (effectiveSplitHighHz); gate.setEnabled (gateEnabled->load (std::memory_order_relaxed) >= 0.5f); gate.setThresholdDb (gateThresholdDb->load (std::memory_order_relaxed)); @@ -347,6 +500,9 @@ void CryptaAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce: lowCompressor.setMakeupGainDb (lowCompMakeupDb->load (std::memory_order_relaxed)); lowCompressor.setWetMixProportion (lowCompMixPercent->load (std::memory_order_relaxed) / 100.0f); + midBand.setDrive (midDrivePercent->load (std::memory_order_relaxed) / 100.0f); + + highVoicing.setTightHz (highTightHzParam->load (std::memory_order_relaxed)); const auto voicingIndex = static_cast (highVoicingChoice->load (std::memory_order_relaxed)); highVoicing.setVoicing (static_cast (juce::jlimit (0, 2, voicingIndex))); highVoicing.setDrive (highDrivePercent->load (std::memory_order_relaxed) / 100.0f); @@ -364,9 +520,9 @@ void CryptaAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce: // Defensive chunking: hosts are expected to never exceed the block size // promised to prepareToPlay(), but if one ever did, indexing straight - // into lowBandBuffer/highBandBuffer (sized to preparedBlockSize) would - // be out of bounds. Processing in chunks of at most preparedBlockSize - // handles that case safely without ever resizing a buffer here. + // into the band buffers (sized to preparedBlockSize) would be out of + // bounds. Processing in chunks of at most preparedBlockSize handles that + // case safely without ever resizing a buffer here. const auto chunkLimit = preparedBlockSize > 0 ? static_cast (preparedBlockSize) : juce::jmax (static_cast (1), fullBlock.getNumSamples()); @@ -383,51 +539,83 @@ void CryptaAudioProcessor::processChunk (juce::dsp::AudioBlock& chunk) no { inputGainProcessor.process (juce::dsp::ProcessContextReplacing (chunk)); - // Full-band noise gate, ahead of the crossover split. + // Full-band noise gate, ahead of the crossover splits. gate.process (chunk); const auto numChannels = chunk.getNumChannels(); const auto numSamples = chunk.getNumSamples(); auto lowBlock = juce::dsp::AudioBlock (lowBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); + auto remainderBlock = juce::dsp::AudioBlock (remainderBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); + auto midBlock = juce::dsp::AudioBlock (midBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); auto highBlock = juce::dsp::AudioBlock (highBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); + auto midHighSumBlock = juce::dsp::AudioBlock (midHighSumBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); - // Issue #8: split the input-trimmed, gated signal into low/high bands. - crossover.process (chunk, lowBlock, highBlock); + // Split #1: peel off the Low band; the remainder carries the Mid+High + // content on to split #2. + lowSplit.process (chunk, lowBlock, remainderBlock); - // Low band: parallel compressor, then level trim. + // Split #2: remainder -> Mid / High. + midHighSplit.process (remainderBlock, midBlock, highBlock); + + // Low band: parallel compressor, then level trim, then the v0.2.0 + // phase-alignment allpass that makes the cascaded three-way sum flat + // (see PluginProcessor.h's lowBandPhaseAlign member docs / class-level + // proof in src/dsp/PhaseAlignFilter.h). lowCompressor.process (lowBlock); + lowGainProcessor.process (juce::dsp::ProcessContextReplacing (lowBlock)); + lowBandPhaseAlign.process (lowBlock); + + // Test-only observability seam (see setLowBandIsolationCaptureForTests()'s + // docs in PluginProcessor.h) - captured here, before the delay + // compensation below (a pure time shift, not a content change) and + // before the IR loader (which never touches the Low band at all - this + // capture is what makes that guarantee directly testable). + if (lowBandIsolationCaptureForTests != nullptr) + for (size_t channel = 0; channel < numChannels; ++channel) + lowBandIsolationCaptureForTests->copyFrom ( + static_cast (channel), 0, lowBlock.getChannelPointer (channel), static_cast (numSamples)); + + // Mid band: staged drive, then level trim. + midBand.process (midBlock); + midGainProcessor.process (juce::dsp::ProcessContextReplacing (midBlock)); - // High band: oversampled distortion voicing (Gnaw/Wool/Razor), then - // level trim. This is the only source of latency in the chain. + // High band: Tight pre-drive HPF (inside Voicing) -> oversampled + // distortion voicing (Gnaw/Wool/Razor) -> drive -> tone -> blend, then + // level trim. This (together with Mid band's own oversampling) is the + // only source of latency in the chain. highVoicing.process (highBlock); + highGainProcessor.process (juce::dsp::ProcessContextReplacing (highBlock)); - // Issue #9: time-align the low band with the latency the high band's - // oversampling stage introduces. + // Issue #9: time-align the low band with the latency the Mid+High + // branch's oversampling stages introduce. lowBandLatencyDelay.process (juce::dsp::ProcessContextReplacing (lowBlock)); - // Issue #10: independent per-band level trims, then sum the bands back - // together in place of the pre-split signal. - lowGainProcessor.process (juce::dsp::ProcessContextReplacing (lowBlock)); - highGainProcessor.process (juce::dsp::ProcessContextReplacing (highBlock)); + // Sum Mid + High into a dedicated buffer (never aliasing either addend) + // ahead of the relocated IR loader. + midHighSumBlock.replaceWithSumOf (midBlock, highBlock); + + // Cab-sim IR loader (v0.2.0: relocated here, between the Mid+High sum + // and the final three-way sum) - the Low band structurally never passes + // through this call, matching the reference class's "low band bypasses + // the cabsim" architecture (docs/design-brief.md). Skipped entirely when + // disabled for a guaranteed bit-exact bypass rather than relying on + // mix==0. + if (irEnabled->load (std::memory_order_relaxed) >= 0.5f) + irLoader.process (midHighSumBlock); - chunk.replaceWithSumOf (lowBlock, highBlock); + // Final sum: Low (delay-compensated) + [Mid+High, post-IR]. + chunk.replaceWithSumOf (lowBlock, midHighSumBlock); // Post-sum 4-band EQ. Skipped entirely when disabled for a guaranteed // bit-exact bypass rather than relying on all-zero band gains. if (eqEnabled->load (std::memory_order_relaxed) >= 0.5f) eq.process (chunk); - // Cab-sim IR loader. Skipped entirely when disabled for the same reason - // (and safe-by-default even when enabled with no IR loaded yet - see - // cryp::IRLoader). - if (irEnabled->load (std::memory_order_relaxed) >= 0.5f) - irLoader.process (chunk); - - // Optional safety clip (issue #10): a soft (tanh) limiter that only - // engages when the user explicitly enables it, protecting against - // accidental hard-clipped overs without colouring the signal at typical - // playing levels (tanh(x) ~= x for |x| well below 1.0). + // Optional safety clip: a soft (tanh) limiter that only engages when the + // user explicitly enables it, protecting against accidental hard-clipped + // overs without colouring the signal at typical playing levels + // (tanh(x) ~= x for |x| well below 1.0). if (outputClipEnabled->load (std::memory_order_relaxed) >= 0.5f) { for (size_t channel = 0; channel < numChannels; ++channel) @@ -471,8 +659,15 @@ void CryptaAudioProcessor::setStateInformation (const void* data, int sizeInByte { const std::unique_ptr xmlState (getXmlFromBinary (data, sizeInBytes)); - if (xmlState != nullptr && xmlState->hasTagName (apvts.state.getType())) - apvts.replaceState (juce::ValueTree::fromXml (*xmlState)); + if (xmlState == nullptr || ! xmlState->hasTagName (apvts.state.getType())) + return; + + // v0.1.x -> v0.2.0 structural migration (docs/design-brief.md guarantee + // #7) - see migrateLegacySingleCrossover()'s docs above. No-op for a + // v0.2.0+ saved state (it never contains a "crossoverFreq" element). + migrateLegacySingleCrossover (*xmlState); + + apvts.replaceState (juce::ValueTree::fromXml (*xmlState)); } //============================================================================== diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index b1e1f20..a8a5be2 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -6,17 +6,23 @@ #include "dsp/BandEQ.h" #include "dsp/Crossover.h" #include "dsp/IRLoader.h" +#include "dsp/MidBand.h" #include "dsp/NoiseGateStage.h" #include "dsp/ParallelCompressor.h" +#include "dsp/PhaseAlignFilter.h" #include "dsp/Voicing.h" - -// As of the M1 "DSP completion" milestone (issue #42), the full v1.0 signal -// path from the README is wired and live: input trim -> noise gate -> LR4 -// crossover split -> [low: parallel compressor -> level] + [high: voicing -// (Gnaw/Wool/Razor, oversampled) -> level] -> delay-compensated sum -> -// 4-band EQ -> IR loader (cab sim) -> optional safety clip -> output trim. -// See docs/architecture.md for the full breakdown and docs/manual.md for the -// user-facing parameter reference. +#include "presets/PresetManager.h" + +// v0.2.0 "deep-dive" rebuild (docs/design-brief.md): the v1.0 2-band +// (low/high) topology is now a cascaded 3-band split (low/mid/high). Full +// signal path: input trim -> noise gate -> LR4 split #1 (Split Low) -> +// [low: parallel compressor -> level] + +// [remainder -> LR4 split #2 (Split High) -> +// [mid: drive -> level] + [high: Tight HPF -> voicing -> drive -> tone +// -> blend -> level] -> sum -> IR loader (cab sim, Mid+High only)] -> +// delay-compensated sum (low + [mid+high post-IR]) -> 4-band EQ -> optional +// safety clip -> output trim. See docs/architecture.md for the full +// breakdown and docs/manual.md for the user-facing parameter reference. class CryptaAudioProcessor final : public juce::AudioProcessor { public: @@ -70,75 +76,130 @@ class CryptaAudioProcessor final : public juce::AudioProcessor juce::AudioProcessorValueTreeState apvts; + // M2 preset system (.scaffold/specs/preset-system-m2.md, + // docs/preset-system-notes.md replication recipe from basilica-audio/ + // nave's pilot implementation). Declared after apvts - construction + // order follows declaration order, and PresetManager's constructor reads + // apvts.processor.getParameters(), so apvts must already be fully built. + basilica::presets::PresetManager presetManager; + // Loads a new cab-sim impulse response into the IR loader stage. Not // real-time safe by contract (see cryp::IRLoader) - call from the message // thread only (e.g. in response to a future GUI file picker or preset // load), never from processBlock() or any audio-thread callback. void loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate); + // Test-only observability seam (docs/design-brief.md guarantee #3: "Low + // band never reaches the IR loader"). When non-null, processChunk() + // copies the Low band's own fully-processed (post-compressor, post- + // lowLevel, post-phase-align, pre-final-sum, pre-delay-compensation) + // output into this buffer every chunk - letting a test assert that content is bit-exact + // identical whether the (relocated, Mid+High-only) IR loader is enabled + // or not, which the full processBlock() output alone can't isolate + // cleanly (finite LR4 stopband leakage of low-frequency content into the + // Mid/High branch means even an unrelated IR-loader change nudges the + // *combined* output by a measurable, if tiny, amount). Intended for + // single-chunk test scenarios only (buffer size <= the size promised to + // prepareToPlay()); no production code path ever sets this. Not real-time + // safe to *set* (call only from the message thread, before processBlock() + // runs) - reading/writing the pointed-to buffer from inside processChunk() + // itself performs no allocation, so it is safe to leave set while + // processBlock() runs in a test. + void setLowBandIsolationCaptureForTests (juce::AudioBuffer* captureBuffer) noexcept + { + lowBandIsolationCaptureForTests = captureBuffer; + } + private: //============================================================================== // Latency-compensation seam (issue #9): computes the plugin's total - // reported latency (now the high-band voicing's oversampling latency, - // issue #42) and re-arms the low-band compensation delay to match it. - // Called once from prepareToPlay(). + // reported latency (the Mid+High branch's shared oversampling latency - + // see computeTotalLatencySamples()) and re-arms the low-band + // compensation delay to match. Called once from prepareToPlay(). int computeTotalLatencySamples() const noexcept; void updateLatencyCompensation(); - // Processes at most `preparedBlockSize` samples: gate, crossover split, - // per-band dynamics/voicing, per-band level, sum, EQ, IR loader, - // optional safety clip. Called once per chunk from processBlock() so - // oversized host blocks (larger than prepareToPlay promised) are handled - // defensively without ever resizing a buffer on the audio thread. + // Processes at most `preparedBlockSize` samples: gate, two cascaded LR4 + // crossover splits, per-band dynamics/drive/voicing, per-band level, + // Mid+High sum, IR loader, final sum, EQ, optional safety clip. Called + // once per chunk from processBlock() so oversized host blocks (larger + // than prepareToPlay promised) are handled defensively without ever + // resizing a buffer on the audio thread. void processChunk (juce::dsp::AudioBlock& chunk) noexcept; //============================================================================== juce::dsp::Gain inputGainProcessor; juce::dsp::Gain outputGainProcessor; - // Full-band input noise gate, ahead of the crossover split. + // Full-band input noise gate, ahead of the crossover splits. cryp::NoiseGateStage gate; - // Issue #8: LR4 crossover splitting the (input-trimmed, gated) signal - // into low and high bands ahead of independent per-band processing. - cryp::Crossover crossover; + // v0.2.0: two cascaded LR4 crossovers replace v1's single split. + // lowSplit peels off the Low band; midHighSplit further splits the + // remainder into Mid and High - the exact "cascaded (not parallel) LR4" + // pattern docs/design-brief.md's Topology section specifies. + cryp::Crossover lowSplit; + cryp::Crossover midHighSplit; + + // v0.2.0: compensates the Low band for midHighSplit's own phase shift so + // the three-way sum is flat-magnitude, not just each individual + // crossover stage - see src/dsp/PhaseAlignFilter.h's class docs for the + // exact algebraic proof of why this is required for a *cascaded* N-way + // LR4 crossover (unlike a single 2-way split, which is flat by + // construction with no compensation needed). + cryp::PhaseAlignFilter lowBandPhaseAlign; // Low band: parallel compressor, then level trim. cryp::ParallelCompressor lowCompressor; - // High band: selectable oversampled distortion voicing (Gnaw/Wool/ - // Razor), then level trim. + // Mid band (NEW in v0.2.0): staged/cascaded drive only, then level trim. + cryp::MidBand midBand; + + // High band: Tight pre-drive HPF (now voicing-independent) + selectable + // oversampled distortion voicing (Gnaw/Wool/Razor), then level trim. cryp::Voicing highVoicing; - // Issue #10: independent per-band level trims applied after each band's - // dynamics/voicing processing and before the bands are summed back - // together. + // Per-band level trims applied after each band's own dynamics/drive/ + // voicing processing and before the bands are summed back together. juce::dsp::Gain lowGainProcessor; + juce::dsp::Gain midGainProcessor; juce::dsp::Gain highGainProcessor; - // Post-sum 4-band EQ and cab-sim IR loader. + // Post-sum 4-band EQ and cab-sim IR loader. v0.2.0 relocates the IR + // loader: it now processes the Mid+High post-sum signal only, never the + // Low band (docs/design-brief.md's "IR loader" section) - structurally + // enforced in processChunk() below, not just by convention. cryp::BandEQ eq; cryp::IRLoader irLoader; // Issue #9: upper bound on the latency this plugin will ever need to - // compensate for, i.e. the largest oversampling latency the high-band - // voicing stage is expected to introduce. Generous headroom well above - // the actual 4x-oversampling FIR latency at any supported sample rate, - // at negligible memory cost. + // compensate for, i.e. the largest oversampling latency the Mid+High + // branch is expected to introduce. Generous headroom well above the + // actual 4x-oversampling FIR latency at any supported sample rate, at + // negligible memory cost. static constexpr int maxLatencyCompensationSamples = 4096; - // Delays the low band so it stays time-aligned with the oversampled high - // band. None-interpolation is correct here: latency compensation is - // always an integer number of samples, never a fractionally modulated - // delay. + // Delays the low band so it stays time-aligned with the oversampled + // Mid+High branch. None-interpolation is correct here: latency + // compensation is always an integer number of samples, never a + // fractionally modulated delay. juce::dsp::DelayLine lowBandLatencyDelay { maxLatencyCompensationSamples }; // Pre-allocated band buffers, sized to `preparedBlockSize` in - // prepareToPlay(). Never resized in processBlock(). + // prepareToPlay(). Never resized in processBlock(). remainderBandBuffer + // holds lowSplit's high output (Mid+High content) ahead of midHighSplit; + // midHighSumBuffer holds the Mid+High sum ahead of the IR loader and the + // final low+midHigh sum. juce::AudioBuffer lowBandBuffer; + juce::AudioBuffer remainderBandBuffer; + juce::AudioBuffer midBandBuffer; juce::AudioBuffer highBandBuffer; + juce::AudioBuffer midHighSumBuffer; int preparedBlockSize = 0; + // See setLowBandIsolationCaptureForTests() above. Null in production. + juce::AudioBuffer* lowBandIsolationCaptureForTests = nullptr; + // Raw atomic pointers into the APVTS-managed parameter values, resolved // once at construction time so processBlock() never has to search for // them (no allocation/locks on the audio thread). @@ -146,8 +207,10 @@ class CryptaAudioProcessor final : public juce::AudioProcessor std::atomic* outputGainDb = nullptr; std::atomic* bypassFlag = nullptr; std::atomic* outputClipEnabled = nullptr; - std::atomic* crossoverFreqHz = nullptr; + std::atomic* splitLowHzParam = nullptr; + std::atomic* splitHighHzParam = nullptr; std::atomic* lowLevelDb = nullptr; + std::atomic* midLevelDb = nullptr; std::atomic* highLevelDb = nullptr; std::atomic* gateEnabled = nullptr; @@ -163,6 +226,9 @@ class CryptaAudioProcessor final : public juce::AudioProcessor std::atomic* lowCompMakeupDb = nullptr; std::atomic* lowCompMixPercent = nullptr; + std::atomic* midDrivePercent = nullptr; + + std::atomic* highTightHzParam = nullptr; std::atomic* highVoicingChoice = nullptr; std::atomic* highDrivePercent = nullptr; std::atomic* highTonePercent = nullptr; diff --git a/src/dsp/MidBand.cpp b/src/dsp/MidBand.cpp new file mode 100644 index 0000000..e648feb --- /dev/null +++ b/src/dsp/MidBand.cpp @@ -0,0 +1,79 @@ +#include "MidBand.h" + +#include + +namespace +{ + // Cascaded two-stage tanh, structurally mirroring Voicing's Wool + // voicing (the reference class's own "multiple tube gain stages... + // designed for the Mid and Treble bands separately" - see + // docs/design-brief.md's Mid band section for the sourcing). Deliberately + // more modest ceilings than Wool's own 12x/6x: the Mid band is a single + // always-on stage with no Blend control, so its 0-100% Drive range alone + // has to cover everything from "barely there" to "dominant", not just + // add character on top of a separately-adjustable blend the way Voicing's + // highBlend does. + constexpr float midMaxDriveGain1 = 8.0f; + constexpr float midMaxDriveGain2 = 4.0f; + + // 4x oversampling (factor exponent 2), matching Voicing's High band + // factor - see MidBand.h's class-level comment for why this is a + // separate, identically-configured instance rather than one physically + // shared with Voicing. + constexpr size_t oversamplingFactorExponent = 2; +} + +namespace cryp +{ + void MidBand::prepare (const juce::dsp::ProcessSpec& spec) + { + oversampling = std::make_unique> ( + static_cast (spec.numChannels), + oversamplingFactorExponent, + juce::dsp::Oversampling::filterHalfBandFIREquiripple, + true, + true); + oversampling->initProcessing (static_cast (spec.maximumBlockSize)); + oversampling->reset(); + latencySamples = static_cast (std::lround (oversampling->getLatencyInSamples())); + } + + void MidBand::reset() + { + if (oversampling != nullptr) + oversampling->reset(); + } + + void MidBand::process (juce::dsp::AudioBlock& block) noexcept + { + jassert (oversampling != nullptr); + + auto upBlock = oversampling->processSamplesUp (juce::dsp::AudioBlock (block)); + + const auto numChannels = upBlock.getNumChannels(); + const auto numSamples = upBlock.getNumSamples(); + + const auto g1 = 1.0f + driveAmount01 * (midMaxDriveGain1 - 1.0f); + const auto g2 = 1.0f + driveAmount01 * (midMaxDriveGain2 - 1.0f); + + for (size_t channel = 0; channel < numChannels; ++channel) + { + auto* data = upBlock.getChannelPointer (channel); + + for (size_t sample = 0; sample < numSamples; ++sample) + { + const auto x = data[sample]; + const auto driven = std::tanh (g2 * std::tanh (g1 * x)); + + // No separate blend control on this band (see class docs) - + // driveAmount01 itself crossfades toward an exact + // passthrough as it approaches 0, so "Mid Drive = 0%" is a + // guaranteed passthrough rather than "0% of a fixed, + // already-non-unity nonlinearity". + data[sample] = x + driveAmount01 * (driven - x); + } + } + + oversampling->processSamplesDown (block); + } +} diff --git a/src/dsp/MidBand.h b/src/dsp/MidBand.h new file mode 100644 index 0000000..d8ef2b7 --- /dev/null +++ b/src/dsp/MidBand.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include + +// Mid band (v0.2.0 3-band rebuild, docs/design-brief.md's "Mid band" section): +// staged/cascaded saturation only - "Mid Drive" - no filter, no tone, and +// (deliberately, matching the reference class exactly) no clean/distorted +// blend control. Runs its own 4x-oversampled shaping stage so its +// nonlinearity gets the same anti-aliasing headroom as the High band's +// Voicing stage. +// +// docs/design-brief.md asks for the Mid band to "share the oversampling +// instance" with the High band's Voicing stage for CPU efficiency. This +// class deliberately does NOT do that literally - see the class-level +// comment in Voicing.h and docs/design-brief.md's implementation note for +// the reasoning: MidBand instead owns its own juce::dsp::Oversampling +// instance, configured identically (same factor exponent, same FIR filter +// type) to Voicing's own. Because juce::dsp::Oversampling's reported latency +// depends only on that configuration (not on any per-instance state or the +// audio data itself), MidBand::getLatencySamples() and +// Voicing::getLatencySamples() are guaranteed numerically equal, which is +// the actual correctness property CryptaAudioProcessor's shared Mid+High +// latency-compensation value depends on - physically sharing one +// Oversampling object would only add CPU savings on top of that, at +// materially higher implementation/testing risk (manual channel +// interleaving through one shared oversampled block) for this pass. +namespace cryp +{ + class MidBand + { + public: + MidBand() = default; + + // Allocates the oversampling stage. Must be called before process() + // and whenever the channel count, sample rate, or max block size + // changes. + void prepare (const juce::dsp::ProcessSpec& spec); + + // Clears the oversampling stage's internal FIR history (e.g. on + // transport stop) without deallocating. + void reset(); + + // Real-time safe: just retargets a scalar, no allocation. + void setDrive (float drive01) noexcept { driveAmount01 = juce::jlimit (0.0f, 1.0f, drive01); } + + // Integer sample latency contributed by the oversampling stage. Zero + // until prepare() has run. + int getLatencySamples() const noexcept { return latencySamples; } + + // In-place: processes the Mid band (already split off by the second + // LR4 crossover) through the staged saturation stage. Real-time + // safe: no allocation once prepare() has run. + void process (juce::dsp::AudioBlock& block) noexcept; + + private: + float driveAmount01 = 0.3f; + + // Constructed in prepare() once the real channel count is known; + // Oversampling's constructor itself allocates, so it must never be + // (re)constructed from processBlock(). + std::unique_ptr> oversampling; + int latencySamples = 0; + }; +} diff --git a/src/dsp/PhaseAlignFilter.h b/src/dsp/PhaseAlignFilter.h new file mode 100644 index 0000000..df793c2 --- /dev/null +++ b/src/dsp/PhaseAlignFilter.h @@ -0,0 +1,89 @@ +#pragma once + +#include + +// v0.2.0 3-band rebuild: phase-alignment allpass companion to cryp::Crossover, +// required to make the cascaded (Low, then Mid/High) topology actually +// flat-sum. +// +// A single cryp::Crossover's own dual-output Low+High sum is exactly a +// flat-magnitude allpass-filtered version of *that stage's own input* +// (juce::dsp::LinkwitzRileyFilter's documented property - JUCE 8.0.14, +// juce_LinkwitzRileyFilter.h). But cascading two such stages - splitting Low +// off first, then splitting the remainder into Mid/High - does NOT +// automatically make Low+Mid+High flat relative to the *original* signal: +// Mid+High is a flat-magnitude allpass-filtered version of the *remainder* +// (the first stage's own High output), not of the original input, so +// summing it directly against the first stage's untouched Low output +// generally does NOT reconstruct flat magnitude - confirmed empirically in +// this repo (see the PR this class shipped in: a direct 3-band flat-sum test +// without this compensation showed deviations up to -10 dB at close +// splitLowHz/splitHighHz ratios, worst inside the first crossover's own +// transition band). +// +// The fix, proven algebraically (not just empirically): reading JUCE 8.0.14's +// own juce_LinkwitzRileyFilter.cpp source confirms the dual-output +// processSample(channel, input, outputLow, outputHigh)'s outputLow+outputHigh +// sum uses the *exact same formula* (built only from the first internal +// biquad stage's s1/s2 state) as that same class's single-output +// processSample(channel, input) in Type::allpass mode. Since that allpass +// transform is linear, applying the *second* crossover's own allpass +// transform (same cutoff, a physically separate PhaseAlignFilter +// instance/state) to the *first* crossover's Low output before the final sum +// makes the algebra exact: +// +// Low_compensated + Mid + High +// = Allpass2(Low) + Allpass2(Remainder) [Mid+High = Allpass2(Remainder)] +// = Allpass2(Low + Remainder) [Allpass2 is linear] +// = Allpass2(Input) [Low + Remainder = Input exactly, +// the first crossover's own +// dual-output reconstruction +// property] +// +// and Allpass2(Input) has flat magnitude relative to Input by definition (an +// allpass filter's magnitude response is unity at every frequency) - so the +// three-way sum is flat, exactly, not just approximately. +// +// See PluginProcessor.cpp's processChunk() for where this is wired into the +// signal path (applied to the Low band, cutoff tied to the same effective +// splitHighHz fed to the Mid/High crossover) and +// tests/ThreeBandFlatSumTests.cpp for the direct-DSP-level proof. +namespace cryp +{ + class PhaseAlignFilter + { + public: + PhaseAlignFilter() + { + filter.setType (juce::dsp::LinkwitzRileyFilter::Type::allpass); + } + + void prepare (const juce::dsp::ProcessSpec& spec) { filter.prepare (spec); } + void reset() { filter.reset(); } + + // Must always be set to the *same* cutoff frequency as the + // downstream Mid/High crossover this instance is compensating for + // (i.e. the effective, already-clamped splitHighHz - see + // cryp::clampSplitHighHz()), or the algebraic cancellation above + // does not hold. + void setCutoffFrequency (float newCutoffHz) { filter.setCutoffFrequency (newCutoffHz); } + + // In-place: real-time safe, no allocation once prepare() has run. + void process (juce::dsp::AudioBlock& block) noexcept + { + const auto numChannels = block.getNumChannels(); + const auto numSamples = block.getNumSamples(); + + for (size_t channel = 0; channel < numChannels; ++channel) + { + auto* data = block.getChannelPointer (channel); + + for (size_t sample = 0; sample < numSamples; ++sample) + data[sample] = filter.processSample (static_cast (channel), data[sample]); + } + } + + private: + juce::dsp::LinkwitzRileyFilter filter; + }; +} diff --git a/src/dsp/SplitGap.h b/src/dsp/SplitGap.h new file mode 100644 index 0000000..2e5f07a --- /dev/null +++ b/src/dsp/SplitGap.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +// v0.2.0 3-band rebuild (docs/design-brief.md, "Split Low / Split High" +// section): the two cascaded LR4 crossovers' cutoffs are independently +// automatable parameters with overlapping ranges - splitLowHz extends up to +// 400 Hz, above splitHighHz's own 300 Hz floor - so without an explicit +// clamp, a user (or a preset, or automation) could push splitLowHz above +// splitHighHz and collapse the Mid band to a degenerate, near-zero-width (or +// inverted) passband. This header is the single, pure, real-time-safe +// function that enforces the brief's "clamped to always stay above +// splitLowHz by a minimum musical gap (reasoned safety margin, e.g. >= 1/3 +// octave)" rule - used identically by CryptaAudioProcessor and by the test +// suite (SplitGapTests.cpp, ThreeBandFlatSumTests.cpp) so the exact boundary +// behaviour is directly testable rather than only implicit in the processor. +namespace cryp +{ + // Reasoned (not sourced - see docs/design-brief.md) minimum gap between + // the two crossover points, in octaves. 1/3 octave is comfortably above + // the transition-band width either LR4 crossover needs to sound like a + // normal crossover rather than an abrupt notch/bump, while still leaving + // most of each parameter's own musically-useful range unclamped. + inline constexpr float minSplitGapOctaves = 1.0f / 3.0f; + + // Returns the splitHighHz value actually fed to the second (Mid/High) + // crossover, given the raw (possibly too-close, possibly even inverted) + // splitLowHz/requestedSplitHighHz parameter values. Pure and real-time + // safe: no allocation, no state, just a floating-point floor. + inline float clampSplitHighHz (float splitLowHz, float requestedSplitHighHz) noexcept + { + const auto minimumSplitHighHz = splitLowHz * std::pow (2.0f, minSplitGapOctaves); + return requestedSplitHighHz > minimumSplitHighHz ? requestedSplitHighHz : minimumSplitHighHz; + } +} diff --git a/src/dsp/Voicing.cpp b/src/dsp/Voicing.cpp index 3f3519d..98428c3 100644 --- a/src/dsp/Voicing.cpp +++ b/src/dsp/Voicing.cpp @@ -22,10 +22,12 @@ namespace constexpr float woolMidFreqHz = 500.0f, woolMidGainDb = -6.0f, woolMidQ = 0.9f; // mid scoop constexpr float razorMidFreqHz = 900.0f, razorMidGainDb = 5.0f, razorMidQ = 1.0f; // mid hump - // Razor's pre-clip highpass: keeps the fundamental out of the shaper so - // the low end stays tight instead of flabby. - constexpr float razorPreHighPassHz = 200.0f; - constexpr float razorPreHighPassQ = 0.7071f; + // v0.2.0: "Tight" pre-clip highpass Q, shared by all three voicings (was + // Razor-only in v1) - keeps the fundamental out of the shaper so the low + // end stays tight instead of flabby. The corner frequency itself is now + // the voicing-independent, user-adjustable `tightHz` member (see + // Voicing.h's setTightHz()), not a fixed constant. + constexpr float tightHighPassQ = 0.7071f; // highTone sweep range. constexpr float minToneHz = 700.0f; @@ -98,36 +100,38 @@ namespace cryp updateMidFilterCoefficients(); // Issue #57: avoid carrying over stale filter state from the - // previous voicing into the freshly-swapped coefficients above - the - // same reasoning as the Razor-only preHighPass reset just below, but - // unconditional since midFilter is live for every voicing (not just - // Razor). + // previous voicing into the freshly-swapped mid-filter coefficients + // above - midFilter is live for every voicing, and each voicing's + // coefficients differ (hump vs. scoop vs. neutral), so a coefficient + // jump without a state reset would ring a spurious transient. + // + // preHighPass (the "Tight" control) deliberately does NOT get reset + // here as of v0.2.0: it is now voicing-independent (its coefficients + // depend only on tightHz, not on which voicing is selected - see + // Voicing.h's class docs), so switching voicings never changes its + // coefficients and there is nothing to guard against. midFilter.reset(); - - if (voicing == VoicingType::razor) - { - updatePreHighPassCoefficients(); - // Avoid carrying over stale filter state from a previous Razor - // session (or silence) into the newly (re)activated pre-HPF. - preHighPass.reset(); - } } void Voicing::process (juce::dsp::AudioBlock& block) noexcept { jassert (oversampling != nullptr); - // Control-rate refresh: highTone is read fresh every host block by - // the processor and forwarded via setTone(), so recomputing the - // tone filter's (zero-allocation, see RealtimeCoefficients.h) - // coefficients once per block keeps it tracking automation without - // per-sample coefficient-recompute cost. + // Control-rate refresh: highTone/highTightHz are read fresh every + // host block by the processor and forwarded via setTone()/ + // setTightHz(), so recomputing these filters' (zero-allocation, see + // RealtimeCoefficients.h) coefficients once per block keeps them + // tracking automation without a per-sample coefficient-recompute + // cost. updateToneFilterCoefficients(); + updatePreHighPassCoefficients(); blendMixer.pushDrySamples (juce::dsp::AudioBlock (block)); - if (voicing == VoicingType::razor) - preHighPass.process (juce::dsp::ProcessContextReplacing (block)); + // v0.2.0: Tight now applies ahead of every voicing (was Razor-only + // in v1) - see Voicing.h's class docs and docs/design-brief.md's + // "High band" section. + preHighPass.process (juce::dsp::ProcessContextReplacing (block)); auto upBlock = oversampling->processSamplesUp (juce::dsp::AudioBlock (block)); @@ -218,7 +222,7 @@ namespace cryp void Voicing::updatePreHighPassCoefficients() noexcept { - const auto raw = juce::dsp::IIR::ArrayCoefficients::makeHighPass (sampleRate, razorPreHighPassHz, razorPreHighPassQ); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makeHighPass (sampleRate, tightHz, tightHighPassQ); applyBiquadCoefficients (*preHighPass.state, raw); } } diff --git a/src/dsp/Voicing.h b/src/dsp/Voicing.h index bc10442..1ec073a 100644 --- a/src/dsp/Voicing.h +++ b/src/dsp/Voicing.h @@ -7,19 +7,22 @@ #include #include -// High-band distortion engine (issue #42): selectable voicing (Gnaw/Wool/ -// Razor) with drive, tone, and a clean/distorted blend, running the -// nonlinear shaping stage oversampled to keep aliasing under control - -// exactly the "oversampled nonlinearities to control aliasing" pattern this -// suite uses for every distortion stage. +// High-band distortion engine (issue #42; v0.2.0 promoted the pre-drive +// highpass from a Razor-only quirk to a first-class, voicing-independent +// "Tight" control - docs/design-brief.md's "High band" section, closing the +// gap identified in the brief's "Why v1 falls short" #3): selectable voicing +// (Gnaw/Wool/Razor) with a shared pre-drive Tight highpass, drive, tone, and +// a clean/distorted blend, running the nonlinear shaping stage oversampled to +// keep aliasing under control - exactly the "oversampled nonlinearities to +// control aliasing" pattern this suite uses for every distortion stage. // // Topology per block, all real-time safe (no allocation once prepare() has // run): // dry tap (pre-voicing high band) --------------------------+ // | -// [Razor only] pre-highpass (base rate, tight low end) --+ | -// oversample up (juce::dsp::Oversampling, JUCE | | -// 8.0.14 juce_dsp/processors/juce_Oversampling.h) --------+ | +// Tight pre-highpass (base rate, all three voicings) -----+ | +// oversample up (juce::dsp::Oversampling, JUCE | | +// 8.0.14 juce_dsp/processors/juce_Oversampling.h) --------+ | // per-sample waveshape (voicing-specific, drive-scaled) | // oversample down | // mid filter (peak, voicing-specific hump/scoop) | @@ -57,6 +60,15 @@ namespace cryp void setVoicing (VoicingType newVoicing) noexcept; void setDrive (float drive01) noexcept { driveAmount01 = juce::jlimit (0.0f, 1.0f, drive01); } void setTone (float tone01) noexcept { toneAmount01 = juce::jlimit (0.0f, 1.0f, tone01); } + + // v0.2.0: "Tight" - the pre-drive highpass corner, now applied ahead + // of every voicing (was a Razor-only fixed 200 Hz internal constant + // in v1). 20-500 Hz per docs/design-brief.md; recomputed at control + // rate every process() call (like the tone filter), so it tracks + // automation without a per-sample coefficient-recompute cost and + // without needing an explicit state reset on every change. + void setTightHz (float newTightHz) noexcept { tightHz = juce::jlimit (20.0f, 500.0f, newTightHz); } + void setWetMixProportion (float wetMixProportion01) noexcept { blendMixer.setWetMixProportion (wetMixProportion01); } // Integer sample latency contributed by the oversampling stage. @@ -85,6 +97,15 @@ namespace cryp float driveAmount01 = 0.5f; float toneAmount01 = 0.5f; + // v0.2.0 default, matching docs/design-brief.md's sourced Tight + // default (100 Hz - the reference class's own documented pull-down + // floor). Only load-bearing for a Voicing instance never touched by + // setTightHz() (e.g. a standalone unit test) - PluginProcessor + // always calls setTightHz() from the highTightHz parameter's own + // ParameterLayout default before the first process() call in + // practice. + float tightHz = 100.0f; + // Constructed in prepare() once the real channel count is known; // Oversampling's constructor itself allocates, so it must never be // (re)constructed from processBlock(). @@ -93,9 +114,10 @@ namespace cryp using Duplicator = juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients>; - // Razor-only pre-emphasis highpass, run at the base sample rate - // before oversampling (linear filter, no aliasing concern, so no - // need to pay the oversampling cost for it). + // v0.2.0: "Tight" pre-drive highpass, now applied for every voicing + // (was Razor-only in v1), run at the base sample rate before + // oversampling (linear filter, no aliasing concern, so no need to + // pay the oversampling cost for it). Duplicator preHighPass { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; // Voicing-specific character filter (mid hump for Razor, mid scoop diff --git a/src/params/ParameterIds.h b/src/params/ParameterIds.h index 2b59e7e..31581ba 100644 --- a/src/params/ParameterIds.h +++ b/src/params/ParameterIds.h @@ -35,11 +35,18 @@ namespace ParamIDs inline constexpr auto gateRelease = "gateRelease"; //============================================================================== - // Crossover (Linkwitz-Riley 4th order split point) - inline constexpr auto crossoverFreq = "crossoverFreq"; + // Crossover (two cascaded Linkwitz-Riley 4th order splits, v0.2.0 + // 2-band -> 3-band rebuild - docs/design-brief.md's "Topology" section). + // The v1 single `crossoverFreq` ID is retired (breaking change, + // acceptable pre-1.0 per the brief's Versioning section); old sessions + // are migrated in CryptaAudioProcessor::setStateInformation() - see + // its migrateLegacySingleCrossover() helper. + inline constexpr auto splitLowHz = "splitLowHz"; + inline constexpr auto splitHighHz = "splitHighHz"; //============================================================================== - // Low band: compressor + level + // Low band: compressor + level (ballistics re-sourced in v0.2.0, IDs/ + // structure unchanged from v1) inline constexpr auto lowCompThreshold = "lowCompThreshold"; inline constexpr auto lowCompRatio = "lowCompRatio"; inline constexpr auto lowCompAttack = "lowCompAttack"; @@ -49,7 +56,16 @@ namespace ParamIDs inline constexpr auto lowLevel = "lowLevel"; //============================================================================== - // High band: voicing + drive + tone + blend + level + // Mid band (NEW in v0.2.0): drive + level only - no filter/tone/blend, + // matching the reference class's own Mid band exactly (docs/design-brief.md). + inline constexpr auto midDrive = "midDrive"; + inline constexpr auto midLevel = "midLevel"; + + //============================================================================== + // High band: Tight (NEW in v0.2.0 - promoted from a Razor-only fixed + // internal constant to a first-class, voicing-independent control) + + // voicing + drive + tone + blend + level + inline constexpr auto highTightHz = "highTightHz"; inline constexpr auto highVoicing = "highVoicing"; inline constexpr auto highDrive = "highDrive"; inline constexpr auto highTone = "highTone"; diff --git a/src/params/ParameterLayout.cpp b/src/params/ParameterLayout.cpp index 4009739..bd3d3e1 100644 --- a/src/params/ParameterLayout.cpp +++ b/src/params/ParameterLayout.cpp @@ -100,16 +100,36 @@ namespace cryp juce::AudioParameterFloatAttributes().withLabel ("ms"))); //====================================================================== - // Crossover + // Crossover: two cascaded LR4 splits (v0.2.0 2-band -> 3-band + // rebuild - docs/design-brief.md's "Split Low / Split High" + // section). splitHighHz's floor (300 Hz) is deliberately above + // splitLowHz's own ceiling-minus-gap, and the two are further + // clamped at runtime by cryp::clampSplitHighHz() (src/dsp/SplitGap.h) + // so splitHighHz can never collapse the Mid band to a degenerate + // near-zero width even at the extremes of both ranges. layout.add (std::make_unique ( - juce::ParameterID { ParamIDs::crossoverFreq, 1 }, - "Crossover Frequency", - makeLogFrequencyRange (60.0f, 1000.0f), - 250.0f, + juce::ParameterID { ParamIDs::splitLowHz, 1 }, + "Split Low", + makeLogFrequencyRange (60.0f, 400.0f), + 120.0f, + juce::AudioParameterFloatAttributes().withLabel ("Hz"))); + + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::splitHighHz, 1 }, + "Split High", + makeLogFrequencyRange (300.0f, 2000.0f), + 600.0f, juce::AudioParameterFloatAttributes().withLabel ("Hz"))); //====================================================================== - // Low band: compressor + level + // Low band: compressor + level. v0.2.0 re-sources the ballistics + // defaults to the reference class's own fixed "glue" bus-compressor + // values (ratio 2.0 / attack 3ms / release 6ms - docs/design-brief.md, + // docs/research-notes.md §3); the release range floor is lowered + // from 10ms to 5ms (breaking change, acceptable pre-1.0) so the + // sourced 6ms default is reachable. Threshold/makeup/mix/level + // ranges and defaults are unchanged from v1 - no source contradicts + // them. layout.add (std::make_unique ( juce::ParameterID { ParamIDs::lowCompThreshold, 1 }, "Low Comp Threshold", @@ -121,21 +141,21 @@ namespace cryp juce::ParameterID { ParamIDs::lowCompRatio, 1 }, "Low Comp Ratio", juce::NormalisableRange (1.0f, 20.0f, 0.01f), - 4.0f, + 2.0f, juce::AudioParameterFloatAttributes().withLabel (":1"))); layout.add (std::make_unique ( juce::ParameterID { ParamIDs::lowCompAttack, 1 }, "Low Comp Attack", makeLogTimeRange (0.1f, 100.0f), - 10.0f, + 3.0f, juce::AudioParameterFloatAttributes().withLabel ("ms"))); layout.add (std::make_unique ( juce::ParameterID { ParamIDs::lowCompRelease, 1 }, "Low Comp Release", - makeLogTimeRange (10.0f, 1000.0f), - 120.0f, + makeLogTimeRange (5.0f, 1000.0f), + 6.0f, juce::AudioParameterFloatAttributes().withLabel ("ms"))); layout.add (std::make_unique ( @@ -160,7 +180,33 @@ namespace cryp juce::AudioParameterFloatAttributes().withLabel ("dB"))); //====================================================================== - // High band: voicing + drive + tone + blend + level + // Mid band (NEW in v0.2.0): drive + level only - no filter/tone/ + // blend, matching the reference class's own Mid band exactly + // (docs/design-brief.md's "Mid band" section). + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::midDrive, 1 }, + "Mid Drive", + juce::NormalisableRange (0.0f, 100.0f, 0.1f), + 30.0f, + juce::AudioParameterFloatAttributes().withLabel ("%"))); + + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::midLevel, 1 }, + "Mid Level", + juce::NormalisableRange (-24.0f, 12.0f, 0.01f), + 0.0f, + juce::AudioParameterFloatAttributes().withLabel ("dB"))); + + //====================================================================== + // High band: Tight (NEW in v0.2.0) + voicing + drive + tone + blend + // + level + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::highTightHz, 1 }, + "High Tight", + makeLogFrequencyRange (20.0f, 500.0f), + 100.0f, + juce::AudioParameterFloatAttributes().withLabel ("Hz"))); + layout.add (std::make_unique ( juce::ParameterID { ParamIDs::highVoicing, 1 }, "High Voicing", @@ -196,7 +242,15 @@ namespace cryp juce::AudioParameterFloatAttributes().withLabel ("dB"))); //====================================================================== - // Post-sum 4-band EQ (LowShelf / Peak / Peak / HighShelf) + // Post-sum 4-band EQ (LowShelf / Peak / Peak / HighShelf). v0.2.0 + // re-anchors the default corner frequencies to the sourced + // bass-tone-stack frequency set from the same design lineage as the + // reference class (80 / 500 / 2800 / 5000 Hz - docs/research-notes.md + // §6); v1's 100/500/2500/8000 Hz defaults were unsourced + // placeholders. Gain/Q ranges and defaults are unchanged - the EQ + // ships off by default either way, so this is a dormant-until-engaged + // anchor change, not an audible v1->v2 difference unless a user (or + // factory preset) turns the EQ on. layout.add (std::make_unique ( juce::ParameterID { ParamIDs::eqEnabled, 1 }, "EQ Enable", @@ -206,7 +260,7 @@ namespace cryp juce::ParameterID { ParamIDs::eqLowShelfFreq, 1 }, "EQ Low Shelf Frequency", makeLogFrequencyRange (40.0f, 400.0f), - 100.0f, + 80.0f, juce::AudioParameterFloatAttributes().withLabel ("Hz"))); layout.add (std::make_unique ( @@ -241,7 +295,7 @@ namespace cryp juce::ParameterID { ParamIDs::eqPeak2Freq, 1 }, "EQ Peak 2 Frequency", makeLogFrequencyRange (500.0f, 8000.0f), - 2500.0f, + 2800.0f, juce::AudioParameterFloatAttributes().withLabel ("Hz"))); layout.add (std::make_unique ( @@ -262,7 +316,7 @@ namespace cryp juce::ParameterID { ParamIDs::eqHighShelfFreq, 1 }, "EQ High Shelf Frequency", makeLogFrequencyRange (2000.0f, 16000.0f), - 8000.0f, + 5000.0f, juce::AudioParameterFloatAttributes().withLabel ("Hz"))); layout.add (std::make_unique ( diff --git a/src/presets/Localisation.cpp b/src/presets/Localisation.cpp new file mode 100644 index 0000000..fe5f826 --- /dev/null +++ b/src/presets/Localisation.cpp @@ -0,0 +1,25 @@ +#include "Localisation.h" + +#include + +namespace basilica::presets +{ + void installLocalisation (const char* deTranslationData, int deTranslationDataSize) + { + const auto userLanguage = juce::SystemStats::getUserLanguage(); + + if (userLanguage.startsWithIgnoreCase ("de") && deTranslationData != nullptr && deTranslationDataSize > 0) + { + const auto text = juce::String::fromUTF8 (deTranslationData, deTranslationDataSize); + + // setCurrentMappings() takes ownership of the pointer passed to + // it (see juce_LocalisedStrings.h) - this is the documented + // JUCE idiom, not a leak. + juce::LocalisedStrings::setCurrentMappings (new juce::LocalisedStrings (text, true)); + } + else + { + juce::LocalisedStrings::setCurrentMappings (nullptr); + } + } +} diff --git a/src/presets/Localisation.h b/src/presets/Localisation.h new file mode 100644 index 0000000..3c64dc5 --- /dev/null +++ b/src/presets/Localisation.h @@ -0,0 +1,38 @@ +#pragma once + +// Basilica Audio suite-wide M2 i18n frame (.scaffold/specs/preset-system-m2.md, +// "I18N" section). Copy-paste-portable to sibling plugins: the only +// per-plugin piece is the BinaryData symbol name passed to +// installLocalisation() from PluginEditor.cpp (see +// docs/preset-system-notes.md). +// +// Scope, per the binding spec: ALL user-facing FRAME strings (PresetBar +// labels, menu items, dialogs, error messages) are wrapped in JUCE's +// TRANS()/juce::translate(). Core/DSP terminology - parameter names, units, +// technical terms (LoCut, HiCut, Distance, IR Blend, Mix, Level, Hz, dB, %) +// - is NEVER translated anywhere in this plugin; PluginEditor.cpp's knob +// labels intentionally do not call TRANS() on parameter names. +namespace basilica::presets +{ + // Installs the correct juce::LocalisedStrings mappings for the current + // system language: German (resources/i18n/de.txt, embedded via + // BinaryData) if juce::SystemStats::getUserLanguage() starts with "de", + // otherwise no mappings are installed and TRANS() falls through to its + // built-in behaviour (return the original English string unchanged). + // No user-facing language override yet - selection is automatic, once, + // per the spec. + // + // Message-thread-only (juce::LocalisedStrings::setCurrentMappings() is + // not documented as audio-thread-safe, and there is no reason to ever + // call this from processBlock). Idempotent - safe to call more than + // once (e.g. if more than one editor is opened over the plugin's + // lifetime); each call simply reinstalls the same mappings. + // + // `deTranslationData`/`deTranslationDataSize` are the calling plugin's + // own BinaryData:: symbols for resources/i18n/de.txt (e.g. + // BinaryData::de_txt/BinaryData::de_txtSize) - passed in rather than + // included here, so this header stays free of any BinaryData.h + // dependency (see PresetManager.h's FactoryPresetAsset for the same + // pattern). + void installLocalisation (const char* deTranslationData, int deTranslationDataSize); +} diff --git a/src/presets/PresetBar.cpp b/src/presets/PresetBar.cpp new file mode 100644 index 0000000..0464759 --- /dev/null +++ b/src/presets/PresetBar.cpp @@ -0,0 +1,243 @@ +#include "PresetBar.h" + +namespace basilica::presets +{ + namespace + { + constexpr int arrowButtonWidth = 24; + constexpr int actionButtonWidth = 84; + constexpr int margin = 4; + constexpr int timerIntervalMs = 250; // cheap polling for the dirty '*' indicator - see PresetBar.h + } + + PresetBar::PresetBar (PresetManager& managerToControl) : manager (managerToControl) + { + previousButton.onClick = [this] { manager.previousPreset(); refreshFromManager(); }; + addAndMakeVisible (previousButton); + + nameButton.onClick = [this] { showPresetMenu(); }; + addAndMakeVisible (nameButton); + + nextButton.onClick = [this] { manager.nextPreset(); refreshFromManager(); }; + addAndMakeVisible (nextButton); + + saveButton.setButtonText (TRANS ("Save")); + saveButton.onClick = [this] { manager.saveCurrentUserPreset(); refreshFromManager(); }; + addAndMakeVisible (saveButton); + + saveAsButton.setButtonText (TRANS ("Save As...")); + saveAsButton.onClick = [this] { promptAndSaveAs(); }; + addAndMakeVisible (saveAsButton); + + deleteButton.setButtonText (TRANS ("Delete")); + deleteButton.onClick = [this] + { + manager.deleteUserPreset (manager.getCurrentPresetName()); + refreshFromManager(); + }; + addAndMakeVisible (deleteButton); + + importButton.setButtonText (TRANS ("Import...")); + importButton.onClick = [this] { promptAndImport(); }; + addAndMakeVisible (importButton); + + exportButton.setButtonText (TRANS ("Export...")); + exportButton.onClick = [this] { promptAndExport(); }; + addAndMakeVisible (exportButton); + + refreshFromManager(); + startTimer (timerIntervalMs); + } + + PresetBar::~PresetBar() + { + stopTimer(); + } + + void PresetBar::timerCallback() + { + refreshFromManager(); + } + + void PresetBar::refreshFromManager() + { + auto name = manager.getCurrentPresetName(); + + if (name.isEmpty()) + name = TRANS ("Init"); + + nameButton.setButtonText (manager.isDirty() ? name + "*" : name); + + const auto isUserPreset = ! manager.isCurrentPresetFactory() && manager.getCurrentPresetName().isNotEmpty(); + saveButton.setEnabled (isUserPreset && manager.isDirty()); + deleteButton.setEnabled (isUserPreset); + exportButton.setEnabled (manager.getCurrentPresetName().isNotEmpty()); + } + + void PresetBar::showPresetMenu() + { + const auto allPresets = manager.getAllPresets(); + + juce::PopupMenu factoryMenu; + juce::PopupMenu userMenu; + + // PopupMenu item IDs are 1-based (0 means "nothing selected"); index + // presetNames by (id - 1) in the async callback below. + std::vector presetNames; + + for (auto& entry : allPresets) + { + presetNames.push_back (entry.name); + const auto itemId = static_cast (presetNames.size()); + const auto isTicked = entry.name == manager.getCurrentPresetName(); + + if (entry.isFactory) + factoryMenu.addItem (itemId, entry.name, true, isTicked); + else + userMenu.addItem (itemId, entry.name, true, isTicked); + } + + juce::PopupMenu menu; + menu.addSubMenu (TRANS ("Factory"), factoryMenu, factoryMenu.containsAnyActiveItems()); + menu.addSubMenu (TRANS ("User"), userMenu, userMenu.containsAnyActiveItems()); + menu.addSeparator(); + + const auto setDefaultId = static_cast (presetNames.size()) + 1; + menu.addItem (setDefaultId, TRANS ("Set current as default")); + + menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (nameButton), + [this, presetNames, setDefaultId] (int result) + { + if (result == 0) + return; + + if (result == setDefaultId) + { + manager.setCurrentAsDefault(); + return; + } + + const auto index = static_cast (result - 1); + + if (index < presetNames.size()) + manager.loadPreset (presetNames[index]); + + refreshFromManager(); + }); + } + + void PresetBar::promptAndSaveAs() + { + // A bespoke rename/save dialog is M3 GUI-polish scope - this uses a + // stock, fully-functional juce::AlertWindow text prompt instead. + // + // deleteWhenDismissed=false is deliberate: JUCE deletes the + // component *before* invoking the modal callback when that flag is + // true (see Component::enterModalState's docs), so reading + // getTextEditorContents() from inside the callback would be a + // use-after-free. activeAlertWindow keeps the window alive as a + // member (the same pattern JUCE's own Projucer uses for this exact + // scenario - jucer_NewFileWizard.cpp); the callback reads its + // contents while it's still alive, then resets the member itself. + activeAlertWindow = std::make_unique ( + TRANS ("Save As..."), TRANS ("Enter a name for the new preset:"), juce::MessageBoxIconType::NoIcon); + + activeAlertWindow->addTextEditor ("name", manager.getCurrentPresetName(), TRANS ("Preset name")); + activeAlertWindow->addButton (TRANS ("Save"), 1, juce::KeyPress (juce::KeyPress::returnKey)); + activeAlertWindow->addButton (TRANS ("Cancel"), 0, juce::KeyPress (juce::KeyPress::escapeKey)); + + activeAlertWindow->enterModalState (true, juce::ModalCallbackFunction::create ([this] (int result) + { + if (result == 1 && activeAlertWindow != nullptr) + { + const auto name = activeAlertWindow->getTextEditorContents ("name"); + + if (name.isNotEmpty()) + manager.saveUserPreset (name, "Init"); + } + + activeAlertWindow.reset(); + refreshFromManager(); + }), + false); + } + + void PresetBar::promptAndImport() + { + activeFileChooser = std::make_unique ( + TRANS ("Import a preset or preset bank..."), juce::File(), "*.basilicapreset;*.zip"); + + constexpr auto flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + activeFileChooser->launchAsync (flags, [this] (const juce::FileChooser& chooser) + { + const auto file = chooser.getResult(); + + if (! file.existsAsFile()) + return; + + if (file.hasFileExtension ("zip")) + { + manager.importBank (file); + } + else + { + juce::String errorMessage; + + if (! manager.importPresetFile (file, errorMessage)) + juce::AlertWindow::showMessageBoxAsync (juce::MessageBoxIconType::WarningIcon, TRANS ("Import failed"), errorMessage); + } + + refreshFromManager(); + }); + } + + void PresetBar::promptAndExport() + { + const auto name = manager.getCurrentPresetName(); + + if (name.isEmpty()) + return; + + activeFileChooser = std::make_unique ( + TRANS ("Export preset..."), + juce::File::getSpecialLocation (juce::File::userDocumentsDirectory).getChildFile (name + ".basilicapreset"), + "*.basilicapreset"); + + constexpr auto flags = juce::FileBrowserComponent::saveMode + | juce::FileBrowserComponent::canSelectFiles + | juce::FileBrowserComponent::warnAboutOverwriting; + + activeFileChooser->launchAsync (flags, [this, name] (const juce::FileChooser& chooser) + { + const auto file = chooser.getResult(); + + if (file != juce::File()) + manager.exportPreset (name, file); + }); + } + + void PresetBar::resized() + { + auto bounds = getLocalBounds(); + + previousButton.setBounds (bounds.removeFromLeft (arrowButtonWidth)); + bounds.removeFromLeft (margin); + + exportButton.setBounds (bounds.removeFromRight (actionButtonWidth)); + bounds.removeFromRight (margin); + importButton.setBounds (bounds.removeFromRight (actionButtonWidth)); + bounds.removeFromRight (margin); + deleteButton.setBounds (bounds.removeFromRight (actionButtonWidth)); + bounds.removeFromRight (margin); + saveAsButton.setBounds (bounds.removeFromRight (actionButtonWidth)); + bounds.removeFromRight (margin); + saveButton.setBounds (bounds.removeFromRight (actionButtonWidth)); + bounds.removeFromRight (margin); + + nextButton.setBounds (bounds.removeFromRight (arrowButtonWidth)); + bounds.removeFromRight (margin); + + nameButton.setBounds (bounds); + } +} diff --git a/src/presets/PresetBar.h b/src/presets/PresetBar.h new file mode 100644 index 0000000..cfe866c --- /dev/null +++ b/src/presets/PresetBar.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include "PresetManager.h" + +// Basilica Audio suite-wide M2 preset system: the editor half of +// PresetManager.h (.scaffold/specs/preset-system-m2.md). Copy-paste-portable +// to sibling plugins - see docs/preset-system-notes.md for the exact +// replication recipe. +// +// Deliberately plain: a horizontal strip +// "[<] [PresetName*] [>] [Save] [Save As...] [Delete] [Import...] +// [Export...]" using stock juce::TextButton/PopupMenu/AlertWindow/ +// FileChooser, matching the rest of this plugin's v0.1/v0.2 functional-but- +// unstyled editor. M3 (GUI & a11y milestone) restyles this; M2's job is +// correct, fully-wired behaviour, not visual polish - per the spec's own +// "do not gold-plate" note. +namespace basilica::presets +{ + class PresetBar : public juce::Component, private juce::Timer + { + public: + explicit PresetBar (PresetManager& managerToControl); + ~PresetBar() override; + + void resized() override; + + private: + void refreshFromManager(); + void showPresetMenu(); + void promptAndSaveAs(); + void promptAndImport(); + void promptAndExport(); + void timerCallback() override; + + PresetManager& manager; + + juce::TextButton previousButton { "<" }; + juce::TextButton nameButton; // click opens the preset menu; text shows "PresetName[*]" + juce::TextButton nextButton { ">" }; + juce::TextButton saveButton; + juce::TextButton saveAsButton; + juce::TextButton deleteButton; + juce::TextButton importButton; + juce::TextButton exportButton; + + std::unique_ptr activeFileChooser; + std::unique_ptr activeAlertWindow; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PresetBar) + }; +} diff --git a/src/presets/PresetManager.cpp b/src/presets/PresetManager.cpp new file mode 100644 index 0000000..ba3a8b3 --- /dev/null +++ b/src/presets/PresetManager.cpp @@ -0,0 +1,564 @@ +#include "PresetManager.h" + +#include + +// JSON preset file format ("basilica-preset-1"): +// +// { +// "format": "basilica-preset-1", +// "plugin": "com.yvesvogl.nave", +// "pluginVersion": "0.2.0", +// "name": "Preset Name", +// "category": "Init|Guitar|...", +// "parameters": { "": , ... } +// } +// +// See .scaffold/specs/preset-system-m2.md for the full binding spec this +// implements. + +namespace basilica::presets +{ + namespace + { + constexpr const char* formatKey = "format"; + constexpr const char* pluginKey = "plugin"; + constexpr const char* pluginVersionKey = "pluginVersion"; + constexpr const char* nameKey = "name"; + constexpr const char* categoryKey = "category"; + constexpr const char* parametersKey = "parameters"; + constexpr const char* defaultPresetName = "Default"; + + juce::String legalFileStem (const juce::String& name) + { + return juce::File::createLegalFileName (name); + } + } + + //========================================================================== + PresetManager::PresetManager (juce::AudioProcessorValueTreeState& stateToControl, + PresetManagerConfig configToUse, + std::vector factoryAssets) + : apvts (stateToControl), config (std::move (configToUse)) + { + for (auto& asset : factoryAssets) + { + if (asset.data == nullptr || asset.dataSize <= 0) + continue; + + const auto jsonText = juce::String::fromUTF8 (asset.data, asset.dataSize); + juce::String errorMessage; + const auto parsed = parseAndValidate (jsonText, errorMessage); + + if (parsed.isVoid()) + { + // A factory preset asset failing to parse/validate is a + // build-time authoring bug (a malformed presets/factory/*.json + // file shipped in BinaryData), not a runtime condition a user + // can hit - surfaced loudly in debug builds, skipped safely + // in release builds rather than crashing the plugin over a + // missing preset. + jassertfalse; + continue; + } + + auto* obj = parsed.getDynamicObject(); + factoryPresets.push_back ({ obj->getProperty (nameKey).toString(), + obj->getProperty (categoryKey).toString(), + parsed }); + } + + for (auto* parameter : apvts.processor.getParameters()) + if (auto* ranged = dynamic_cast (parameter)) + apvts.addParameterListener (ranged->paramID, this); + } + + PresetManager::~PresetManager() + { + for (auto* parameter : apvts.processor.getParameters()) + if (auto* ranged = dynamic_cast (parameter)) + apvts.removeParameterListener (ranged->paramID, this); + } + + //========================================================================== + void PresetManager::parameterChanged (const juce::String&, float) + { + // Real-time safe by design - see the class-level docs in + // PresetManager.h. Nothing else happens in this callback. + if (! applyingPreset.load (std::memory_order_relaxed)) + dirty.store (true, std::memory_order_relaxed); + } + + void PresetManager::applyStartupDefault() + { + loadPreset (defaultPresetName); + } + + //========================================================================== + void PresetManager::resetAllParametersToDefault() + { + for (auto* parameter : apvts.processor.getParameters()) + if (auto* ranged = dynamic_cast (parameter)) + ranged->setValueNotifyingHost (ranged->getDefaultValue()); + } + + void PresetManager::applyPlainValues (const juce::var& parametersObject) + { + auto* obj = parametersObject.getDynamicObject(); + + if (obj == nullptr) + return; + + for (auto& property : obj->getProperties()) + { + // Unknown parameter IDs are silently ignored - forward-tolerant + // import (a preset saved by a newer plugin version carrying a + // parameter this build doesn't know about). + auto* ranged = dynamic_cast (apvts.getParameter (property.name.toString())); + + if (ranged != nullptr) + ranged->setValueNotifyingHost (ranged->convertTo0to1 (static_cast (property.value))); + } + } + + void PresetManager::applyParsedPreset (const juce::var& parsed, const juce::String& name, bool isFactory) + { + applyingPreset.store (true, std::memory_order_relaxed); + + resetAllParametersToDefault(); + + auto* obj = parsed.getDynamicObject(); + jassert (obj != nullptr); // parseAndValidate() guarantees this + + applyPlainValues (obj->getProperty (parametersKey)); + + currentPresetName = name; + currentPresetIsFactory = isFactory; + + applyingPreset.store (false, std::memory_order_relaxed); + dirty.store (false, std::memory_order_relaxed); + } + + //========================================================================== + juce::var PresetManager::parseAndValidate (const juce::String& jsonText, juce::String& errorMessage) const + { + juce::var parsed; + const auto parseResult = juce::JSON::parse (jsonText, parsed); + + if (parseResult.failed() || ! parsed.isObject()) + { + errorMessage = TRANS ("This file is not a valid preset."); + return {}; + } + + auto* obj = parsed.getDynamicObject(); + + if (obj->getProperty (formatKey).toString() != juce::String (presetFormatTag)) + { + errorMessage = TRANS ("This preset was saved by an incompatible version of the preset format."); + return {}; + } + + if (obj->getProperty (pluginKey).toString() != config.pluginId) + { + errorMessage = TRANS ("This preset file belongs to a different plugin."); + return {}; + } + + return parsed; + } + + juce::var PresetManager::buildPresetVar (const juce::String& name, const juce::String& category) const + { + auto* parametersObj = new juce::DynamicObject(); + + for (auto* parameter : apvts.processor.getParameters()) + if (auto* ranged = dynamic_cast (parameter)) + parametersObj->setProperty (ranged->paramID, ranged->convertFrom0to1 (ranged->getValue())); + + auto* obj = new juce::DynamicObject(); + obj->setProperty (formatKey, juce::String (presetFormatTag)); + obj->setProperty (pluginKey, config.pluginId); + obj->setProperty (pluginVersionKey, config.pluginVersion); + obj->setProperty (nameKey, name); + obj->setProperty (categoryKey, category); + obj->setProperty (parametersKey, juce::var (parametersObj)); + + return juce::var (obj); + } + + bool PresetManager::writePresetVarToFile (const juce::var& presetVar, const juce::File& destination) const + { + const auto json = juce::JSON::toString (presetVar, false); + return destination.replaceWithText (json); + } + + juce::File PresetManager::userPresetFileFor (const juce::String& name) const + { + return getUserPresetsDirectory (config).getChildFile (legalFileStem (name) + presetFileExtension); + } + + //========================================================================== + juce::File PresetManager::getUserPresetsDirectory (const PresetManagerConfig& presetManagerConfig) + { + if (presetManagerConfig.userPresetsDirectoryOverrideForTests != juce::File()) + return presetManagerConfig.userPresetsDirectoryOverrideForTests; + + #if JUCE_MAC + return juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("Library") + .getChildFile ("Audio") + .getChildFile ("Presets") + .getChildFile (presetManagerConfig.manufacturerName) + .getChildFile (presetManagerConfig.pluginName); + #elif JUCE_WINDOWS + return juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile (presetManagerConfig.manufacturerName) + .getChildFile (presetManagerConfig.pluginName) + .getChildFile ("Presets"); + #else + // Linux/other: not a CI or release target for this suite (see + // CLAUDE.md - CI is macOS + Windows only), but still a sane, + // discoverable per-user location rather than an unsupported path. + return juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile (presetManagerConfig.manufacturerName) + .getChildFile (presetManagerConfig.pluginName) + .getChildFile ("Presets"); + #endif + } + + //========================================================================== + std::vector PresetManager::getAllPresets() const + { + std::vector factoryEntries; + + for (auto& factory : factoryPresets) + factoryEntries.push_back ({ factory.name, factory.category, true }); + + std::sort (factoryEntries.begin(), factoryEntries.end(), + [] (const PresetEntry& a, const PresetEntry& b) { return a.name < b.name; }); + + std::vector userEntries; + const auto userFiles = getUserPresetsDirectory (config).findChildFiles ( + juce::File::findFiles, false, juce::String ("*") + presetFileExtension); + + for (auto& file : userFiles) + { + juce::String errorMessage; + const auto parsed = parseAndValidate (file.loadFileAsString(), errorMessage); + + if (parsed.isVoid()) + continue; + + auto* obj = parsed.getDynamicObject(); + userEntries.push_back ({ obj->getProperty (nameKey).toString(), obj->getProperty (categoryKey).toString(), false }); + } + + std::sort (userEntries.begin(), userEntries.end(), + [] (const PresetEntry& a, const PresetEntry& b) { return a.name < b.name; }); + + factoryEntries.insert (factoryEntries.end(), userEntries.begin(), userEntries.end()); + return factoryEntries; + } + + //========================================================================== + bool PresetManager::loadPreset (const juce::String& name) + { + // User presets win over factory presets of the same name - the same + // resolution order applyStartupDefault() relies on for "Default". + const auto userFile = userPresetFileFor (name); + + if (userFile.existsAsFile()) + { + juce::String errorMessage; + const auto parsed = parseAndValidate (userFile.loadFileAsString(), errorMessage); + + if (! parsed.isVoid()) + { + applyParsedPreset (parsed, name, false); + return true; + } + } + + for (auto& factory : factoryPresets) + { + if (factory.name == name) + { + applyParsedPreset (factory.parsed, name, true); + return true; + } + } + + return false; + } + + void PresetManager::nextPreset() + { + const auto all = getAllPresets(); + + if (all.empty()) + return; + + const auto it = std::find_if (all.begin(), all.end(), + [this] (const PresetEntry& e) { return e.name == currentPresetName; }); + + const auto currentIndex = (it == all.end()) ? static_cast (-1) + : static_cast (std::distance (all.begin(), it)); + const auto nextIndex = (it == all.end()) ? size_t { 0 } : (currentIndex + 1) % all.size(); + + loadPreset (all[nextIndex].name); + } + + void PresetManager::previousPreset() + { + const auto all = getAllPresets(); + + if (all.empty()) + return; + + const auto it = std::find_if (all.begin(), all.end(), + [this] (const PresetEntry& e) { return e.name == currentPresetName; }); + + size_t previousIndex = 0; + + if (it != all.end()) + { + const auto currentIndex = static_cast (std::distance (all.begin(), it)); + previousIndex = (currentIndex == 0) ? all.size() - 1 : currentIndex - 1; + } + else + { + previousIndex = all.size() - 1; + } + + loadPreset (all[previousIndex].name); + } + + //========================================================================== + bool PresetManager::saveUserPreset (const juce::String& name, const juce::String& category) + { + for (auto& factory : factoryPresets) + if (factory.name == name) + return false; + + const auto dir = getUserPresetsDirectory (config); + + if (! dir.exists() && ! dir.createDirectory()) + return false; + + const auto presetVar = buildPresetVar (name, category); + + if (! writePresetVarToFile (presetVar, userPresetFileFor (name))) + return false; + + currentPresetName = name; + currentPresetIsFactory = false; + dirty.store (false, std::memory_order_relaxed); + return true; + } + + bool PresetManager::saveCurrentUserPreset() + { + if (currentPresetIsFactory || currentPresetName.isEmpty()) + return false; + + juce::String category = "Init"; + const auto existingFile = userPresetFileFor (currentPresetName); + + if (existingFile.existsAsFile()) + { + juce::String errorMessage; + const auto parsed = parseAndValidate (existingFile.loadFileAsString(), errorMessage); + + if (! parsed.isVoid()) + category = parsed.getDynamicObject()->getProperty (categoryKey).toString(); + } + + return saveUserPreset (currentPresetName, category); + } + + bool PresetManager::renameUserPreset (const juce::String& oldName, const juce::String& newName) + { + const auto oldFile = userPresetFileFor (oldName); + + if (! oldFile.existsAsFile()) + return false; + + juce::String errorMessage; + const auto parsed = parseAndValidate (oldFile.loadFileAsString(), errorMessage); + + if (parsed.isVoid()) + return false; + + auto* obj = parsed.getDynamicObject(); + const auto renamed = buildPresetVar (newName, obj->getProperty (categoryKey).toString()); + + // buildPresetVar() above stamps the *current live* APVTS values, not + // necessarily the renamed preset's own saved values - overwrite its + // parameters with the original file's, so a rename never silently + // mutates the preset's content. + renamed.getDynamicObject()->setProperty (parametersKey, obj->getProperty (parametersKey)); + + if (! writePresetVarToFile (renamed, userPresetFileFor (newName))) + return false; + + oldFile.deleteFile(); + + if (currentPresetName == oldName) + currentPresetName = newName; + + return true; + } + + bool PresetManager::deleteUserPreset (const juce::String& name) + { + const auto file = userPresetFileFor (name); + + if (! file.existsAsFile()) + return false; + + const auto deleted = file.deleteFile(); + + if (deleted && currentPresetName == name) + currentPresetName.clear(); + + return deleted; + } + + //========================================================================== + bool PresetManager::setCurrentAsDefault() + { + const auto dir = getUserPresetsDirectory (config); + + if (! dir.exists() && ! dir.createDirectory()) + return false; + + // Deliberately bypasses saveUserPreset()'s "can't shadow a factory + // name" guard and doesn't touch currentPresetName/dirty - see the + // header docs. + const auto presetVar = buildPresetVar (defaultPresetName, "Init"); + return writePresetVarToFile (presetVar, userPresetFileFor (defaultPresetName)); + } + + bool PresetManager::resetDefault() + { + const auto file = userPresetFileFor (defaultPresetName); + + if (! file.existsAsFile()) + return true; // nothing to reset is not an error + + return file.deleteFile(); + } + + //========================================================================== + bool PresetManager::importPresetFile (const juce::File& file, juce::String& errorMessage) + { + const auto parsed = parseAndValidate (file.loadFileAsString(), errorMessage); + + if (parsed.isVoid()) + return false; + + applyParsedPreset (parsed, parsed.getDynamicObject()->getProperty (nameKey).toString(), false); + return true; + } + + bool PresetManager::exportPreset (const juce::String& name, const juce::File& destination) const + { + for (auto& factory : factoryPresets) + if (factory.name == name) + return writePresetVarToFile (factory.parsed, destination); + + const auto userFile = userPresetFileFor (name); + + if (! userFile.existsAsFile()) + return false; + + juce::String errorMessage; + const auto parsed = parseAndValidate (userFile.loadFileAsString(), errorMessage); + + if (parsed.isVoid()) + return false; + + return writePresetVarToFile (parsed, destination); + } + + //========================================================================== + bool PresetManager::exportBank (const juce::File& destination, const std::vector& userPresetNamesOnly) const + { + const auto dir = getUserPresetsDirectory (config); + juce::Array filesToInclude; + + if (userPresetNamesOnly.empty()) + { + filesToInclude = dir.findChildFiles (juce::File::findFiles, false, juce::String ("*") + presetFileExtension); + } + else + { + for (auto& name : userPresetNamesOnly) + { + const auto file = userPresetFileFor (name); + + if (file.existsAsFile()) + filesToInclude.add (file); + } + } + + if (filesToInclude.isEmpty()) + return false; + + juce::ZipFile::Builder builder; + + for (auto& file : filesToInclude) + builder.addFile (file, 9, file.getFileName()); + + // FileOutputStream appends to an existing file by default - delete + // first so a re-export always produces a clean, exact bank rather + // than a stale one with leftover trailing bytes. + destination.deleteFile(); + + std::unique_ptr outputStream (destination.createOutputStream()); + + if (outputStream == nullptr) + return false; + + return builder.writeToStream (*outputStream, nullptr); + } + + int PresetManager::importBank (const juce::File& zipFile) + { + juce::ZipFile zip (zipFile); + int importedCount = 0; + + const auto dir = getUserPresetsDirectory (config); + + if (! dir.exists() && ! dir.createDirectory()) + return 0; + + for (int i = 0; i < zip.getNumEntries(); ++i) + { + const auto* entry = zip.getEntry (i); + + if (entry == nullptr || ! entry->filename.endsWithIgnoreCase (presetFileExtension)) + continue; + + const std::unique_ptr stream (zip.createStreamForEntry (i)); + + if (stream == nullptr) + continue; + + const auto text = stream->readEntireStreamAsString(); + juce::String errorMessage; + const auto parsed = parseAndValidate (text, errorMessage); + + if (parsed.isVoid()) + continue; // wrong plugin / malformed entry - skip, don't abort the whole bank + + auto* obj = parsed.getDynamicObject(); + const auto name = obj->getProperty (nameKey).toString(); + + if (writePresetVarToFile (parsed, userPresetFileFor (name))) + ++importedCount; + } + + return importedCount; + } +} diff --git a/src/presets/PresetManager.h b/src/presets/PresetManager.h new file mode 100644 index 0000000..ebca678 --- /dev/null +++ b/src/presets/PresetManager.h @@ -0,0 +1,246 @@ +#pragma once + +#include + +#include +#include + +// Basilica Audio suite-wide M2 preset system +// (.scaffold/specs/preset-system-m2.md, binding across all 13 plugins). +// +// This file (and PresetManager.cpp) is written to have NO Nave-specific +// coupling beyond the small PresetManagerConfig/FactoryPresetAsset surface +// below - a sibling plugin can copy src/presets/PresetManager.{h,cpp} and +// src/presets/PresetBar.{h,cpp} verbatim (see docs/preset-system-notes.md +// for the exact replication recipe: what to copy unmodified, what small +// per-plugin glue to write, and the CMake/BinaryData wiring it needs). +namespace basilica::presets +{ + // A single embedded factory preset asset: the raw JSON bytes (+ size) of + // one presets/factory/*.json file, as produced by JUCE's + // juce_add_binary_data. PresetManager itself has zero BinaryData.h + // dependency - the owning plugin builds this list from its own + // BinaryData:: symbols (see PluginProcessor.cpp) and passes it into the + // constructor, which is what keeps this header copy-paste-portable. + struct FactoryPresetAsset + { + const char* data = nullptr; + int dataSize = 0; + }; + + // The small, per-plugin configuration surface PresetManager needs. + // Everything else (file format, dirty tracking, prev/next ordering, + // import/export, default resolution) is fully generic. + struct PresetManagerConfig + { + // Must match the "plugin" field factory/user preset JSON files + // carry (see the format table in PresetManager.cpp) - e.g. + // "com.yvesvogl.nave". A preset file whose "plugin" field doesn't + // match this is refused on import (see importPresetFile()). + juce::String pluginId; + + // Used to build the user-presets folder path (see + // getUserPresetsDirectory()): .../// + // on macOS, ...///Presets/ on + // Windows. + juce::String pluginName; + + // The suite's shared manufacturer folder name, e.g. "Yves Vogl". + juce::String manufacturerName; + + // Stamped into newly saved/exported presets' "pluginVersion" field. + // Purely informational - never checked on import (only "plugin" + // and "format" are). + juce::String pluginVersion; + + // Optional override for getUserPresetsDirectory(): if this is a + // non-null juce::File, it is returned verbatim instead of computing + // the platform-standard location. Exists purely for test isolation + // (see tests/PresetManagerTests.cpp) - a real plugin build always + // leaves this default-constructed (empty), so production instances + // always use the real per-user preset location. + juce::File userPresetsDirectoryOverrideForTests; + }; + + // Owns preset discovery (factory presets, embedded via BinaryData at + // construction time; user presets, scanned from disk on demand), + // load/save/rename/delete, default resolution, import/export (single + // files and zip banks), and a dirty-state flag - the model half of the + // PresetBar UI (PresetBar.h). + // + // AudioProcessorValueTreeState is the single source of truth for + // parameter values; every operation here reads/writes it only through + // its public API (setValueNotifyingHost/getParameter/etc.) - this class + // owns no parallel copy of parameter state. + // + // Real-time safety: every *public* method on this class is message- + // thread-only (JSON parsing, juce::String/juce::var allocation, file + // I/O - none of it is safe to call from processBlock, and nothing in + // NaveAudioProcessor::processBlock/CabConvolutionEngine ever calls into + // this class - see docs/preset-system-notes.md's real-time-safety + // section). The one exception is the private + // AudioProcessorValueTreeState::Listener::parameterChanged() override + // used for dirty-flag tracking: JUCE does not document that callback as + // guaranteed message-thread-only (host automation could in principle + // deliver it from another thread), so it is implemented as a single + // lock-free std::atomic store and nothing else - real-time safe + // regardless of which thread invokes it. + class PresetManager : private juce::AudioProcessorValueTreeState::Listener + { + public: + PresetManager (juce::AudioProcessorValueTreeState& stateToControl, + PresetManagerConfig configToUse, + std::vector factoryAssets); + ~PresetManager() override; + + // Applies the default-resolution order (user "Default" preset > + // factory "Default" preset > the ParameterLayout defaults the APVTS + // was already constructed with, i.e. do nothing further). Intended + // to be called exactly once, from the owning AudioProcessor's + // constructor, after the APVTS itself has been fully constructed. + void applyStartupDefault(); + + //====================================================================== + struct PresetEntry + { + juce::String name; + juce::String category; + bool isFactory = false; + }; + + // Factory presets first (in the order their BinaryData assets were + // passed to the constructor... sorted alphabetically), then user + // presets, also alphabetically - matches nextPreset()/ + // previousPreset()'s traversal order. + std::vector getAllPresets() const; + + juce::String getCurrentPresetName() const noexcept { return currentPresetName; } + bool isCurrentPresetFactory() const noexcept { return currentPresetIsFactory; } + + // True as soon as any parameter changes after a preset finishes + // loading (or after construction, before any preset has ever been + // explicitly loaded); false immediately after a successful + // load/save/import. Safe to call from the message thread at any + // time (lock-free atomic read). + bool isDirty() const noexcept { return dirty.load (std::memory_order_relaxed); } + + //====================================================================== + // Loading = every APVTS parameter is first reset to its + // ParameterLayout default, then any values the preset's JSON does + // carry are applied on top via setValueNotifyingHost() - so a + // preset load is always a complete, deterministic snapshot + // regardless of what was dialled in before loading it. This is also + // what makes "missing IDs keep their default" true for forward- + // compat imports (see importPresetFile()). Message-thread-only. + // Returns false (state left untouched) if no preset with that exact + // name exists (user presets are checked first, then factory - see + // the class-level docs). + bool loadPreset (const juce::String& name); + + void nextPreset(); + void previousPreset(); + + //====================================================================== + // Writes the current APVTS parameter values as a new user preset + // file (see getUserPresetsDirectory()), creating the directory on + // demand. Refuses (returns false, nothing written) to shadow an + // existing factory preset name - use a different name, or + // setCurrentAsDefault() for the one deliberate "Default" exception. + bool saveUserPreset (const juce::String& name, const juce::String& category); + + // Overwrites the currently loaded preset's own file, preserving its + // stored category. No-op (returns false) if the current preset is a + // factory preset (read-only in the UI) or if nothing is currently + // loaded - use saveUserPreset() with a new name ("Save As...") + // instead. + bool saveCurrentUserPreset(); + + bool renameUserPreset (const juce::String& oldName, const juce::String& newName); + bool deleteUserPreset (const juce::String& name); + + // "Set current as default" / "Reset default" from the spec: writes/ + // deletes a user preset file literally named "Default", which + // loadPreset("Default")/applyStartupDefault() will always find + // before falling back to a factory "Default" preset. Deliberately + // bypasses saveUserPreset()'s "can't shadow a factory name" guard + // (shadowing the factory Default is the entire point) and does not + // touch getCurrentPresetName()/isDirty() - this records what loads + // next time, it doesn't change what's loaded right now. + bool setCurrentAsDefault(); + bool resetDefault(); + + //====================================================================== + // Forward/backward-tolerant import: unknown parameter IDs in the + // file are silently ignored; missing IDs keep their (just-reset-to-) + // default value (see loadPreset()'s docs above); a `plugin` + // mismatch or `format` mismatch refuses the import (state left + // untouched) and reports a human-readable, already-TRANS()'d reason + // via `errorMessage`, safe to show directly in a dialog. + bool importPresetFile (const juce::File& file, juce::String& errorMessage); + + // Pretty-printed JSON. Returns false if `name` isn't a known preset + // or `destination` couldn't be written. + bool exportPreset (const juce::String& name, const juce::File& destination) const; + + // Bank export: writes the named user presets (or, if + // `userPresetNamesOnly` is empty, every user preset currently on + // disk) into a single zip at `destination`. Factory presets are + // intentionally never included (they already ship with every + // installation). Returns false if there was nothing to export or + // the zip couldn't be written. + bool exportBank (const juce::File& destination, const std::vector& userPresetNamesOnly = {}) const; + + // Bank import: extracts every entry from the zip and imports each + // individually through the same tolerance/validation rules as + // importPresetFile() (entries belonging to a different plugin, or + // that fail to parse, are silently skipped rather than aborting the + // whole bank). Persists successfully-validated entries directly to + // the user presets directory - unlike loadPreset()/ + // importPresetFile(), a bank import populates the user library + // without changing what's currently loaded or the dirty flag. + // Returns the number of presets successfully imported. + int importBank (const juce::File& zipFile); + + static juce::File getUserPresetsDirectory (const PresetManagerConfig& presetManagerConfig); + + static constexpr const char* presetFileExtension = ".basilicapreset"; + static constexpr const char* presetFormatTag = "basilica-preset-1"; + + private: + void parameterChanged (const juce::String&, float) override; + + void resetAllParametersToDefault(); + void applyPlainValues (const juce::var& parametersObject); + void applyParsedPreset (const juce::var& parsed, const juce::String& name, bool isFactory); + juce::var buildPresetVar (const juce::String& name, const juce::String& category) const; + bool writePresetVarToFile (const juce::var& presetVar, const juce::File& destination) const; + juce::File userPresetFileFor (const juce::String& name) const; + + // Returns a void var if `jsonText` doesn't parse as a well-formed, + // format/plugin-matching basilica-preset-1 object; errorMessage is + // set to a TRANS()'d, dialog-safe reason whenever that happens. + juce::var parseAndValidate (const juce::String& jsonText, juce::String& errorMessage) const; + + struct FactoryPresetRecord + { + juce::String name; + juce::String category; + juce::var parsed; + }; + + juce::AudioProcessorValueTreeState& apvts; + PresetManagerConfig config; + std::vector factoryPresets; + + // Message-thread-only state - see the class-level real-time-safety + // note for why `dirty` alone is the exception (an atomic, safely + // writable from parameterChanged() regardless of its calling + // thread). + juce::String currentPresetName; + bool currentPresetIsFactory = false; + std::atomic dirty { false }; + std::atomic applyingPreset { false }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PresetManager) + }; +} diff --git a/tests/GainProcessingTests.cpp b/tests/GainProcessingTests.cpp index 0f002f1..39a0fca 100644 --- a/tests/GainProcessingTests.cpp +++ b/tests/GainProcessingTests.cpp @@ -28,20 +28,27 @@ namespace // voicing permanently into the signal path, the plugin is no longer // level-transparent at its *default* parameters (lowCompMix defaults to // 100% wet with a -18dB threshold, and highBlend defaults to 100% wet - // Gnaw hard-clip distortion) - that's by design, not a regression. These + // Gnaw hard-clip distortion) - that's by design, not a regression. v0.2.0 + // adds a Mid band with a non-zero default Drive (30%) too - the + // testFrequencyHz probe below (1000 Hz) sits above Split High's own + // 600 Hz default so it never excites the Mid band regardless, but + // midDrive is neutralised anyway for consistency/future-proofing. These // pure gain-staging tests are about input/output trim math, not - // compressor/voicing character (which get their own dedicated tests in - // ParallelCompressorTests.cpp/VoicingTests.cpp), so they pull both - // stages' blend controls to 0% (fully dry) to isolate the gain-staging - // path being tested. + // compressor/voicing/drive character (which get their own dedicated + // tests in ParallelCompressorTests.cpp/VoicingTests.cpp/MidBandTests.cpp), + // so they pull every band's blend/drive controls to 0% (fully dry/ + // transparent) to isolate the gain-staging path being tested. void neutralizeDynamicsAndVoicing (CryptaAudioProcessor& processor) { auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + auto* midDriveParam = processor.apvts.getParameter (ParamIDs::midDrive); auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); REQUIRE (lowCompMixParam != nullptr); + REQUIRE (midDriveParam != nullptr); REQUIRE (highBlendParam != nullptr); lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + midDriveParam->setValueNotifyingHost (midDriveParam->convertTo0to1 (0.0f)); highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); } } diff --git a/tests/GainStagingTests.cpp b/tests/GainStagingTests.cpp index f8060db..5030dc8 100644 --- a/tests/GainStagingTests.cpp +++ b/tests/GainStagingTests.cpp @@ -15,18 +15,25 @@ namespace constexpr double testSampleRate = 48000.0; constexpr int testBlockSize = 512; - // Well below/above the 250 Hz crossoverFreq default, with margin so - // filter roll-off near the crossover point doesn't leak the "wrong" - // band's attenuation into the measurement. - constexpr double lowProbeFrequencyHz = 80.0; + // Well below Split Low (120 Hz default) / well above Split High (600 Hz + // default), with margin so filter roll-off near either crossover point + // doesn't leak the "wrong" band's attenuation into the measurement. v0.1.x + // used an 80 Hz low probe (safely ~1.6 octaves below v1's 250 Hz single + // crossover); v0.2.0's new splitLowHz default (120 Hz) is much closer to + // 80 Hz (only ~0.58 octaves - ~14 dB of LR4 stopband rejection), which + // measurably leaked into the Mid/High branch and polluted the + // differential-attenuation measurement below. 30 Hz sits a full 2 octaves + // below the 120 Hz default (~48 dB rejection), restoring the clean + // separation the original 80 Hz probe relied on. + constexpr double lowProbeFrequencyHz = 30.0; constexpr double highProbeFrequencyHz = 4000.0; // Runs 12 blocks of a steady, phase-continuous sine at `probeFrequencyHz` // through the processor (already prepared and parameterised by the // caller) and returns the RMS level, in dB relative to full scale, of // the last block once smoothing/filter transients have settled. Phase - // must stay continuous across the block boundaries: the LR4 crossover - // is a stateful IIR filter, so restarting the sine at phase zero every + // must stay continuous across the block boundaries: the LR4 crossovers + // are stateful IIR filters, so restarting the sine at phase zero every // block would inject a small broadband transient at each boundary and // pollute the level measurement. // Since issue #42 wired the low-band parallel compressor and high-band @@ -34,19 +41,29 @@ namespace // level-transparent at their *default* parameters (lowCompMix defaults // to 100% wet with a -18dB threshold that a 0.5-amplitude probe tone // sits well above; highBlend defaults to 100% wet Gnaw hard-clip - // distortion) - that's by design, not a regression. These tests are - // about the lowLevel/highLevel *trim* controls, not compressor/voicing - // character (which get their own dedicated tests), so both stages' - // blend controls are pulled to 0% (fully dry) up front to isolate the - // level-trim behaviour being tested. + // distortion) - that's by design, not a regression. v0.2.0 adds a Mid + // band with a non-zero default Drive (30%) too, though neither probe + // frequency below falls inside the Mid band's passband by default - it + // is neutralised anyway for the same isolation reasoning, since a + // moved splitLowHz/splitHighHz. + // These tests are about the lowLevel/highLevel *trim* controls, not + // compressor/voicing/drive character (which get their own dedicated + // tests), so all three bands' blend/drive controls are pulled to 0% + // (fully dry/transparent) up front to isolate the level-trim behaviour + // being tested. void neutralizeDynamicsAndVoicing (CryptaAudioProcessor& processor) { auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + auto* midDriveParam = processor.apvts.getParameter (ParamIDs::midDrive); auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); REQUIRE (lowCompMixParam != nullptr); + REQUIRE (midDriveParam != nullptr); REQUIRE (highBlendParam != nullptr); lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + // MidBand's 0% drive is an exact passthrough by construction (see + // cryp::MidBand's class docs) - no separate blend control to zero. + midDriveParam->setValueNotifyingHost (midDriveParam->convertTo0to1 (0.0f)); highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); } diff --git a/tests/LatencyTests.cpp b/tests/LatencyTests.cpp index 7a4d63c..08933d1 100644 --- a/tests/LatencyTests.cpp +++ b/tests/LatencyTests.cpp @@ -73,22 +73,27 @@ TEST_CASE ("Latency: re-preparing the processor recomputes latency deterministic TEST_CASE ("Latency: band-split-then-sum preserves magnitude flatness through the full processor", "[latency][dsp][crossover]") { - // Exercises the same flat-sum property as CrossoverTests.cpp, but end- - // to-end through CryptaAudioProcessor::processBlock() - i.e. - // including the low-band compensation delay line and the high-band - // voicing's own internal (DryWetMixer) dry-path delay - to confirm the - // #9/#42 latency-compensation seam doesn't perturb the #8 flat-sum - // guarantee. highBlend and lowCompMix are pulled to 0% (fully dry) so - // neither the high band's *voicing* character (deliberately non- - // transparent at its Gnaw/50% drive defaults) nor the low band's - // parallel compressor (deliberately non-transparent at its -18dB/4:1 - // defaults, which a 0.5-amplitude probe sits well above) pollute a test - // that is specifically about the delay-compensation plumbing, not - // either stage's character - both dry paths still run through their - // respective latency-compensated DryWetMixers, so the plumbing is still - // fully exercised. + // Exercises the same flat-sum property as ThreeBandFlatSumTests.cpp, but + // end-to-end through CryptaAudioProcessor::processBlock() - i.e. + // including the low-band compensation delay line and the high band's + // own internal (DryWetMixer) dry-path delay - to confirm the #9/#42 + // latency-compensation seam doesn't perturb the flat-sum guarantee. + // highBlend, lowCompMix, and midDrive are all pulled to 0%/transparent + // so neither the high band's *voicing* character (deliberately non- + // transparent at its Gnaw/50% drive defaults), the low band's parallel + // compressor (deliberately non-transparent at its -18dB/2:1 defaults, + // which a 0.5-amplitude probe sits well above), nor the mid band's + // staged drive (non-transparent at its 30% default) pollute a test that + // is specifically about the delay-compensation plumbing, not any single + // band's character - both dry paths still run through their respective + // latency-compensated DryWetMixers, so the plumbing is still fully + // exercised. constexpr double testSampleRate = 48000.0; + // Spans all three bands at the v0.2.0 defaults (Split Low 120 Hz, Split + // High 600 Hz): 60/150/250 Hz probe the low/mid bands, 600 Hz sits + // exactly at Split High (the hardest point for any crossover to keep + // flat), 2000/8000 Hz probe the high band. const double probeFrequenciesHz[] = { 60.0, 150.0, 250.0, 600.0, 2000.0, 8000.0 }; for (const auto probeFrequencyHz : probeFrequenciesHz) @@ -98,10 +103,13 @@ TEST_CASE ("Latency: band-split-then-sum preserves magnitude flatness through th auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + auto* midDriveParam = processor.apvts.getParameter (ParamIDs::midDrive); REQUIRE (highBlendParam != nullptr); REQUIRE (lowCompMixParam != nullptr); + REQUIRE (midDriveParam != nullptr); highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + midDriveParam->setValueNotifyingHost (midDriveParam->convertTo0to1 (0.0f)); juce::AudioBuffer buffer (2, testBlockSize); juce::MidiBuffer midi; diff --git a/tests/LowBandIsolationTests.cpp b/tests/LowBandIsolationTests.cpp new file mode 100644 index 0000000..469f3ba --- /dev/null +++ b/tests/LowBandIsolationTests.cpp @@ -0,0 +1,159 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "TestHelpers.h" + +#include +#include + +#include + +// v0.2.0 topology rebuild (docs/design-brief.md's Guarantee #3): "Low band +// never reaches the IR loader" - the IR loader is relocated to process only +// the Mid+High post-sum signal, structurally never the Low band. This is +// directly testable now that the IR loader has moved in the signal path +// (previously it sat post-sum, after both v1 bands were already combined). +// +// A full-processor RMS/level probe test can't isolate this cleanly on its +// own: the LR4 crossover's finite stopband rejection means some low- +// frequency energy always leaks into the Mid/High branch (tens of dB down, +// not below float32 precision), so even an unrelated IR-loader change would +// nudge the *combined* processBlock() output by a measurable, if tiny, +// amount regardless of whether the Low band itself is truly IR-independent. +// CryptaAudioProcessor::setLowBandIsolationCaptureForTests() exists +// specifically to make the *actual* structural claim - the Low band's own +// fully-processed, pre-final-sum output - directly, bit-exactly comparable. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int testBlockSize = 512; + + juce::AudioBuffer makeAggressiveSyntheticImpulseResponse (int numIrSamples = 256) + { + juce::AudioBuffer ir (2, numIrSamples); + + for (int channel = 0; channel < ir.getNumChannels(); ++channel) + { + auto* data = ir.getWritePointer (channel); + + for (int sample = 0; sample < numIrSamples; ++sample) + data[sample] = std::sin (static_cast (sample) * 0.9f) * std::exp (-static_cast (sample) / 20.0f); + } + + return ir; + } + + // A broadband-ish signal (fundamental plus a few harmonics) so the Low + // band's own compressor/level chain has something non-trivial to react + // to, not just a single pure tone. + void fillWithBroadbandSignal (juce::AudioBuffer& buffer, juce::int64 startSampleIndex) + { + TestHelpers::fillWithSine (buffer, testSampleRate, 90.0, 0.5f, startSampleIndex); + + juce::AudioBuffer harmonic (buffer.getNumChannels(), buffer.getNumSamples()); + TestHelpers::fillWithSine (harmonic, testSampleRate, 1200.0, 0.3f, startSampleIndex); + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + buffer.addFrom (channel, 0, harmonic, channel, 0, buffer.getNumSamples()); + } +} + +TEST_CASE ("Low band isolation: the Low band's own output is bit-exact identical whether the IR loader is enabled or not", "[ir][routing][dsp]") +{ + juce::AudioBuffer lowBandWithIrOff (2, testBlockSize); + juce::AudioBuffer lowBandWithIrOn (2, testBlockSize); + + CryptaAudioProcessor processorIrOff; + processorIrOff.prepareToPlay (testSampleRate, testBlockSize); + processorIrOff.setLowBandIsolationCaptureForTests (&lowBandWithIrOff); + + CryptaAudioProcessor processorIrOn; + processorIrOn.prepareToPlay (testSampleRate, testBlockSize); + processorIrOn.setLowBandIsolationCaptureForTests (&lowBandWithIrOn); + + auto* irEnabledParam = processorIrOn.apvts.getParameter (ParamIDs::irEnabled); + auto* irMixParam = processorIrOn.apvts.getParameter (ParamIDs::irMix); + REQUIRE (irEnabledParam != nullptr); + REQUIRE (irMixParam != nullptr); + irEnabledParam->setValueNotifyingHost (1.0f); + irMixParam->setValueNotifyingHost (irMixParam->convertTo0to1 (100.0f)); + + processorIrOn.loadImpulseResponse (makeAggressiveSyntheticImpulseResponse(), testSampleRate); + + juce::MidiBuffer midi; + + // Several blocks so both processors' low-band compressor/level chains + // reach a comparable settled state, and so the IR loader's own + // asynchronous engine-install has time to actually become active on the + // processorIrOn instance (irrelevant to the assertion below, but + // confirms this test would actually catch a real routing regression). + for (int i = 0; i < 8; ++i) + { + juce::AudioBuffer bufferOff (2, testBlockSize); + fillWithBroadbandSignal (bufferOff, static_cast (i) * testBlockSize); + processorIrOff.processBlock (bufferOff, midi); + + juce::AudioBuffer bufferOn (2, testBlockSize); + fillWithBroadbandSignal (bufferOn, static_cast (i) * testBlockSize); + processorIrOn.processBlock (bufferOn, midi); + } + + for (int channel = 0; channel < 2; ++channel) + { + const auto* offData = lowBandWithIrOff.getReadPointer (channel); + const auto* onData = lowBandWithIrOn.getReadPointer (channel); + + for (int sample = 0; sample < testBlockSize; ++sample) + { + INFO ("channel = " << channel << ", sample = " << sample); + CHECK (offData[sample] == Catch::Approx (onData[sample]).margin (0.0)); + } + } +} + +TEST_CASE ("Low band isolation: the Low band's own output is unaffected by which IR is loaded", "[ir][routing][dsp]") +{ + juce::AudioBuffer lowBandNoIr (2, testBlockSize); + juce::AudioBuffer lowBandWithIr (2, testBlockSize); + + CryptaAudioProcessor processorNoIr; + processorNoIr.prepareToPlay (testSampleRate, testBlockSize); + processorNoIr.setLowBandIsolationCaptureForTests (&lowBandNoIr); + + auto* irEnabledNoIrParam = processorNoIr.apvts.getParameter (ParamIDs::irEnabled); + REQUIRE (irEnabledNoIrParam != nullptr); + irEnabledNoIrParam->setValueNotifyingHost (1.0f); // enabled, but no real IR ever loaded (identity passthrough) + + CryptaAudioProcessor processorWithIr; + processorWithIr.prepareToPlay (testSampleRate, testBlockSize); + processorWithIr.setLowBandIsolationCaptureForTests (&lowBandWithIr); + + auto* irEnabledParam = processorWithIr.apvts.getParameter (ParamIDs::irEnabled); + REQUIRE (irEnabledParam != nullptr); + irEnabledParam->setValueNotifyingHost (1.0f); + processorWithIr.loadImpulseResponse (makeAggressiveSyntheticImpulseResponse (128), testSampleRate); + + juce::MidiBuffer midi; + + for (int i = 0; i < 8; ++i) + { + juce::AudioBuffer bufferNoIr (2, testBlockSize); + fillWithBroadbandSignal (bufferNoIr, static_cast (i) * testBlockSize); + processorNoIr.processBlock (bufferNoIr, midi); + + juce::AudioBuffer bufferWithIr (2, testBlockSize); + fillWithBroadbandSignal (bufferWithIr, static_cast (i) * testBlockSize); + processorWithIr.processBlock (bufferWithIr, midi); + } + + for (int channel = 0; channel < 2; ++channel) + { + const auto* noIrData = lowBandNoIr.getReadPointer (channel); + const auto* withIrData = lowBandWithIr.getReadPointer (channel); + + for (int sample = 0; sample < testBlockSize; ++sample) + { + INFO ("channel = " << channel << ", sample = " << sample); + CHECK (noIrData[sample] == Catch::Approx (withIrData[sample]).margin (0.0)); + } + } +} diff --git a/tests/MidBandTests.cpp b/tests/MidBandTests.cpp new file mode 100644 index 0000000..f29e321 --- /dev/null +++ b/tests/MidBandTests.cpp @@ -0,0 +1,176 @@ +#include "dsp/MidBand.h" +#include "dsp/Voicing.h" +#include "TestHelpers.h" + +#include +#include + +#include + +// v0.2.0 3-band rebuild: Mid band acceptance gates (docs/design-brief.md's +// "Mid band" section, Guarantees #4/#8/#9). +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 2048; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 1) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } +} + +TEST_CASE ("MidBand: 0% drive is a passthrough within a small margin (no separate blend control)", "[midband][dsp]") +{ + // Unlike Voicing's highBlend, MidBand has no dry/wet mixer at all (see + // cryp::MidBand's class docs) - "Mid Drive = 0%" must be a passthrough + // by the shaper's own math crossfading to identity, not by a separate + // mix parameter set to 0%. + cryp::MidBand midBand; + midBand.prepare (makeSpec()); + midBand.setDrive (0.0f); + + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 300.0, 0.6f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + midBand.process (block); + + const auto outRms = TestHelpers::rms (buffer); + const auto refRms = TestHelpers::rms (reference); + + CHECK (juce::Decibels::gainToDecibels (outRms / refRms) == Catch::Approx (0.0).margin (0.2)); +} + +TEST_CASE ("MidBand: reports positive, plausible oversampling latency after prepare()", "[midband][dsp]") +{ + cryp::MidBand midBand; + CHECK (midBand.getLatencySamples() == 0); + + midBand.prepare (makeSpec()); + CHECK (midBand.getLatencySamples() > 0); + CHECK (midBand.getLatencySamples() < 1000); +} + +TEST_CASE ("MidBand: latency matches Voicing's own oversampling latency exactly (shared delay-compensation contract)", "[midband][dsp][latency]") +{ + // CryptaAudioProcessor's shared Mid+High latency-compensation value + // depends on MidBand and Voicing reporting numerically identical + // latencies - see MidBand.h's class docs for why this holds by + // construction (same oversampling factor exponent/filter type) even + // though they are two physically separate Oversampling instances. + cryp::MidBand midBand; + midBand.prepare (makeSpec (2)); + + cryp::Voicing voicing; + voicing.prepare (makeSpec (2), 1.0f); + + CHECK (midBand.getLatencySamples() == voicing.getLatencySamples()); +} + +TEST_CASE ("MidBand: harmonic energy increases monotonically with drive", "[midband][dsp]") +{ + // Mirrors Voicing's own monotonic-harmonic-energy-with-drive test + // pattern (VoicingTests.cpp), extended to the new Mid stage per + // docs/design-brief.md's Guarantee #4. + cryp::MidBand lowDriveBand; + lowDriveBand.prepare (makeSpec()); + lowDriveBand.setDrive (0.0f); + + cryp::MidBand highDriveBand; + highDriveBand.prepare (makeSpec()); + highDriveBand.setDrive (1.0f); + + juce::AudioBuffer lowDriveBuffer (1, numSamples); + TestHelpers::fillWithSine (lowDriveBuffer, testSampleRate, 300.0, 0.7f); + juce::dsp::AudioBlock lowDriveBlock (lowDriveBuffer); + lowDriveBand.process (lowDriveBlock); + + juce::AudioBuffer highDriveBuffer (1, numSamples); + TestHelpers::fillWithSine (highDriveBuffer, testSampleRate, 300.0, 0.7f); + juce::dsp::AudioBlock highDriveBlock (highDriveBuffer); + highDriveBand.process (highDriveBlock); + + juce::AudioBuffer difference; + difference.makeCopyOf (highDriveBuffer); + difference.addFrom (0, 0, lowDriveBuffer, 0, 0, numSamples, -1.0f); + + CHECK (TestHelpers::rms (difference) > 0.01); + + // Monotonic across more than just the two extremes. + const float driveSteps[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f }; + double previousDifferenceRms = -1.0; + + for (const auto drive : driveSteps) + { + cryp::MidBand band; + band.prepare (makeSpec()); + band.setDrive (drive); + + juce::AudioBuffer driven (1, numSamples); + TestHelpers::fillWithSine (driven, testSampleRate, 300.0, 0.7f); + juce::dsp::AudioBlock drivenBlock (driven); + band.process (drivenBlock); + + juce::AudioBuffer stepDifference; + stepDifference.makeCopyOf (driven); + + juce::AudioBuffer dryReference (1, numSamples); + TestHelpers::fillWithSine (dryReference, testSampleRate, 300.0, 0.7f); + stepDifference.addFrom (0, 0, dryReference, 0, 0, numSamples, -1.0f); + + const auto differenceRms = TestHelpers::rms (stepDifference); + + INFO ("drive = " << drive); + CHECK (differenceRms >= previousDifferenceRms - 1.0e-6); + previousDifferenceRms = differenceRms; + } +} + +TEST_CASE ("MidBand: no NaN/Inf and no runaway output at extreme drive", "[midband][dsp][robustness]") +{ + cryp::MidBand midBand; + midBand.prepare (makeSpec()); + midBand.setDrive (1.0f); + + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 300.0, 4.0f); + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (midBand.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + + const auto* data = buffer.getReadPointer (0); + + for (int sample = 0; sample < numSamples; ++sample) + CHECK (std::abs (data[sample]) <= 10.0f); +} + +TEST_CASE ("MidBand: no NaN/Inf across a denormal-range sweep", "[midband][dsp][robustness]") +{ + cryp::MidBand midBand; + midBand.prepare (makeSpec (2)); + midBand.setDrive (1.0f); + + juce::AudioBuffer buffer (2, numSamples); + const auto denormalValue = std::numeric_limits::denorm_min() * 4.0f; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + data[sample] = (sample % 2 == 0) ? denormalValue : -denormalValue; + } + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (midBand.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); +} diff --git a/tests/ParameterTests.cpp b/tests/ParameterTests.cpp index 907c024..ae8efb7 100644 --- a/tests/ParameterTests.cpp +++ b/tests/ParameterTests.cpp @@ -62,29 +62,30 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p SECTION ("all documented parameter IDs resolve") { static constexpr const char* allIds[] = { - ParamIDs::inputGain, ParamIDs::outputGain, ParamIDs::bypass, - ParamIDs::outputClip, ParamIDs::gateEnabled, ParamIDs::gateThreshold, - ParamIDs::gateRatio, ParamIDs::gateAttack, ParamIDs::gateRelease, - ParamIDs::crossoverFreq, ParamIDs::lowCompThreshold, ParamIDs::lowCompRatio, - ParamIDs::lowCompAttack, ParamIDs::lowCompRelease, ParamIDs::lowCompMakeup, - ParamIDs::lowCompMix, ParamIDs::lowLevel, ParamIDs::highVoicing, - ParamIDs::highDrive, ParamIDs::highTone, ParamIDs::highBlend, - ParamIDs::highLevel, ParamIDs::eqEnabled, ParamIDs::eqLowShelfFreq, - ParamIDs::eqLowShelfGain, ParamIDs::eqPeak1Freq, ParamIDs::eqPeak1Gain, - ParamIDs::eqPeak1Q, ParamIDs::eqPeak2Freq, ParamIDs::eqPeak2Gain, - ParamIDs::eqPeak2Q, ParamIDs::eqHighShelfFreq, ParamIDs::eqHighShelfGain, - ParamIDs::irEnabled, ParamIDs::irMix, + ParamIDs::inputGain, ParamIDs::outputGain, ParamIDs::bypass, + ParamIDs::outputClip, ParamIDs::gateEnabled, ParamIDs::gateThreshold, + ParamIDs::gateRatio, ParamIDs::gateAttack, ParamIDs::gateRelease, + ParamIDs::splitLowHz, ParamIDs::splitHighHz, ParamIDs::lowCompThreshold, + ParamIDs::lowCompRatio, ParamIDs::lowCompAttack, ParamIDs::lowCompRelease, + ParamIDs::lowCompMakeup, ParamIDs::lowCompMix, ParamIDs::lowLevel, + ParamIDs::midDrive, ParamIDs::midLevel, ParamIDs::highTightHz, + ParamIDs::highVoicing, ParamIDs::highDrive, ParamIDs::highTone, + ParamIDs::highBlend, ParamIDs::highLevel, ParamIDs::eqEnabled, + ParamIDs::eqLowShelfFreq, ParamIDs::eqLowShelfGain, ParamIDs::eqPeak1Freq, + ParamIDs::eqPeak1Gain, ParamIDs::eqPeak1Q, ParamIDs::eqPeak2Freq, + ParamIDs::eqPeak2Gain, ParamIDs::eqPeak2Q, ParamIDs::eqHighShelfFreq, + ParamIDs::eqHighShelfGain, ParamIDs::irEnabled, ParamIDs::irMix, }; for (const auto* id : allIds) CHECK (apvts.getParameter (id) != nullptr); } - SECTION ("total parameter count matches the full v1.0 layout") + SECTION ("total parameter count matches the full v0.2.0 3-band layout") { - // 4 IO/global + 5 gate + 1 crossover + 7 low band + 5 high band - // + 11 EQ + 2 IR = 35. - CHECK (apvts.processor.getParameters().size() == 35); + // 4 IO/global + 5 gate + 2 crossover + 7 low band + 2 mid band + // + 6 high band + 11 EQ + 2 IR = 39. + CHECK (apvts.processor.getParameters().size() == 39); } SECTION ("IO / global defaults") @@ -112,18 +113,21 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p checkFloatRange (apvts, ParamIDs::gateRelease, 5.0f, 500.0f); } - SECTION ("crossover defaults and range") + SECTION ("crossover defaults and ranges (two cascaded LR4 splits, v0.2.0)") { - checkFloatDefault (apvts, ParamIDs::crossoverFreq, 250.0f); - checkFloatRange (apvts, ParamIDs::crossoverFreq, 60.0f, 1000.0f); + checkFloatDefault (apvts, ParamIDs::splitLowHz, 120.0f); + checkFloatRange (apvts, ParamIDs::splitLowHz, 60.0f, 400.0f); + + checkFloatDefault (apvts, ParamIDs::splitHighHz, 600.0f); + checkFloatRange (apvts, ParamIDs::splitHighHz, 300.0f, 2000.0f); } - SECTION ("low band defaults and ranges") + SECTION ("low band defaults and ranges (v0.2.0 re-sourced glue-compressor ballistics)") { checkFloatDefault (apvts, ParamIDs::lowCompThreshold, -18.0f); - checkFloatDefault (apvts, ParamIDs::lowCompRatio, 4.0f); - checkFloatDefault (apvts, ParamIDs::lowCompAttack, 10.0f); - checkFloatDefault (apvts, ParamIDs::lowCompRelease, 120.0f); + checkFloatDefault (apvts, ParamIDs::lowCompRatio, 2.0f); + checkFloatDefault (apvts, ParamIDs::lowCompAttack, 3.0f); + checkFloatDefault (apvts, ParamIDs::lowCompRelease, 6.0f); checkFloatDefault (apvts, ParamIDs::lowCompMakeup, 0.0f); checkFloatDefault (apvts, ParamIDs::lowCompMix, 100.0f); checkFloatDefault (apvts, ParamIDs::lowLevel, 0.0f); @@ -131,13 +135,22 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p checkFloatRange (apvts, ParamIDs::lowCompThreshold, -60.0f, 0.0f); checkFloatRange (apvts, ParamIDs::lowCompRatio, 1.0f, 20.0f); checkFloatRange (apvts, ParamIDs::lowCompAttack, 0.1f, 100.0f); - checkFloatRange (apvts, ParamIDs::lowCompRelease, 10.0f, 1000.0f); + checkFloatRange (apvts, ParamIDs::lowCompRelease, 5.0f, 1000.0f); checkFloatRange (apvts, ParamIDs::lowCompMakeup, -12.0f, 24.0f); checkFloatRange (apvts, ParamIDs::lowCompMix, 0.0f, 100.0f); checkFloatRange (apvts, ParamIDs::lowLevel, -24.0f, 12.0f); } - SECTION ("high band defaults and ranges, including the voicing choice") + SECTION ("mid band defaults and ranges (NEW in v0.2.0)") + { + checkFloatDefault (apvts, ParamIDs::midDrive, 30.0f); + checkFloatDefault (apvts, ParamIDs::midLevel, 0.0f); + + checkFloatRange (apvts, ParamIDs::midDrive, 0.0f, 100.0f); + checkFloatRange (apvts, ParamIDs::midLevel, -24.0f, 12.0f); + } + + SECTION ("high band defaults and ranges, including Tight (NEW) and the voicing choice") { auto* voicingParam = dynamic_cast (apvts.getParameter (ParamIDs::highVoicing)); REQUIRE (voicingParam != nullptr); @@ -147,30 +160,32 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p CHECK (voicingParam->choices[2] == juce::String ("Razor")); CHECK (voicingParam->getIndex() == 0); + checkFloatDefault (apvts, ParamIDs::highTightHz, 100.0f); checkFloatDefault (apvts, ParamIDs::highDrive, 50.0f); checkFloatDefault (apvts, ParamIDs::highTone, 50.0f); checkFloatDefault (apvts, ParamIDs::highBlend, 100.0f); checkFloatDefault (apvts, ParamIDs::highLevel, 0.0f); + checkFloatRange (apvts, ParamIDs::highTightHz, 20.0f, 500.0f); checkFloatRange (apvts, ParamIDs::highDrive, 0.0f, 100.0f); checkFloatRange (apvts, ParamIDs::highTone, 0.0f, 100.0f); checkFloatRange (apvts, ParamIDs::highBlend, 0.0f, 100.0f); checkFloatRange (apvts, ParamIDs::highLevel, -24.0f, 12.0f); } - SECTION ("EQ defaults and ranges") + SECTION ("EQ defaults and ranges (v0.2.0 re-anchored default frequencies)") { checkBoolDefault (apvts, ParamIDs::eqEnabled, false); - checkFloatDefault (apvts, ParamIDs::eqLowShelfFreq, 100.0f); + checkFloatDefault (apvts, ParamIDs::eqLowShelfFreq, 80.0f); checkFloatDefault (apvts, ParamIDs::eqLowShelfGain, 0.0f); checkFloatDefault (apvts, ParamIDs::eqPeak1Freq, 500.0f); checkFloatDefault (apvts, ParamIDs::eqPeak1Gain, 0.0f); checkFloatDefault (apvts, ParamIDs::eqPeak1Q, 0.7f); - checkFloatDefault (apvts, ParamIDs::eqPeak2Freq, 2500.0f); + checkFloatDefault (apvts, ParamIDs::eqPeak2Freq, 2800.0f); checkFloatDefault (apvts, ParamIDs::eqPeak2Gain, 0.0f); checkFloatDefault (apvts, ParamIDs::eqPeak2Q, 0.7f); - checkFloatDefault (apvts, ParamIDs::eqHighShelfFreq, 8000.0f); + checkFloatDefault (apvts, ParamIDs::eqHighShelfFreq, 5000.0f); checkFloatDefault (apvts, ParamIDs::eqHighShelfGain, 0.0f); checkFloatRange (apvts, ParamIDs::eqLowShelfFreq, 40.0f, 400.0f); diff --git a/tests/PresetManagerTests.cpp b/tests/PresetManagerTests.cpp new file mode 100644 index 0000000..e6350d9 --- /dev/null +++ b/tests/PresetManagerTests.cpp @@ -0,0 +1,609 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "presets/PresetManager.h" + +#include + +#include +#include + +#include +#include +#include + +// M2 preset system tests (.scaffold/specs/preset-system-m2.md's "Tests" +// section - each TEST_CASE below maps to one of that section's numbered +// items, called out in the test names/comments). Adapted from +// basilica-audio/nave's pilot implementation +// (tests/PresetManagerTests.cpp, docs/preset-system-notes.md's replication +// recipe) - PresetManager/PresetBar themselves are copied verbatim; only the +// plugin-specific glue below (factory asset list, config, parameter IDs) +// is Crypta-specific. +namespace +{ + using basilica::presets::FactoryPresetAsset; + using basilica::presets::PresetManager; + using basilica::presets::PresetManagerConfig; + + // Mirrors PluginProcessor.cpp's own makeFactoryPresetAssets() - kept as + // an independent copy here rather than exported from PluginProcessor.cpp + // so this test file can construct its own, fully isolated PresetManager + // instances (see makeIsolatedConfig() below) without depending on + // production wiring internals. + std::vector makeTestFactoryPresetAssets() + { + return { + { BinaryData::default_json, BinaryData::default_jsonSize }, + { BinaryData::glueAndGrind_json, BinaryData::glueAndGrind_jsonSize }, + { BinaryData::subLock_json, BinaryData::subLock_jsonSize }, + { BinaryData::throat_json, BinaryData::throat_jsonSize }, + { BinaryData::fuzzWall_json, BinaryData::fuzzWall_jsonSize }, + { BinaryData::cutThrough_json, BinaryData::cutThrough_jsonSize }, + { BinaryData::definitionOnly_json, BinaryData::definitionOnly_jsonSize }, + { BinaryData::cleanLowLoudTop_json, BinaryData::cleanLowLoudTop_jsonSize }, + { BinaryData::cabColoredGrind_json, BinaryData::cabColoredGrind_jsonSize }, + }; + } + + // A fresh, isolated scratch directory per test case, so this file never + // reads or writes the real ~/Library/Audio/Presets/... (or Windows + // equivalent) location on the machine running the tests - see + // PresetManagerConfig::userPresetsDirectoryOverrideForTests. Deleted on + // destruction. + struct ScopedTestDirectory + { + ScopedTestDirectory() + : dir (juce::File::getSpecialLocation (juce::File::tempDirectory) + .getChildFile ("CryptaPresetManagerTests") + .getChildFile (juce::String (juce::Time::getHighResolutionTicks()) + + "_" + juce::String (juce::Random::getSystemRandom().nextInt (1000000)))) + { + dir.createDirectory(); + } + + ~ScopedTestDirectory() + { + dir.deleteRecursively(); + } + + JUCE_DECLARE_NON_COPYABLE (ScopedTestDirectory) + + juce::File dir; + }; + + PresetManagerConfig makeIsolatedConfig (const juce::File& userDir) + { + PresetManagerConfig config; + config.pluginId = "com.yvesvogl.crypta"; + config.pluginName = "Crypta"; + config.manufacturerName = "Yves Vogl"; + config.pluginVersion = "0.2.0-test"; + config.userPresetsDirectoryOverrideForTests = userDir; + return config; + } + + void setParam (CryptaAudioProcessor& processor, const char* id, float realValue) + { + auto* param = processor.apvts.getParameter (id); + REQUIRE (param != nullptr); + param->setValueNotifyingHost (param->convertTo0to1 (realValue)); + } + + float getParam (CryptaAudioProcessor& processor, const char* id) + { + auto* param = processor.apvts.getParameter (id); + REQUIRE (param != nullptr); + return param->convertFrom0to1 (param->getValue()); + } +} + +//============================================================================== +// 1. Save -> load round-trip restores every parameter exactly. +TEST_CASE ("PresetManager: save -> load round-trip restores every parameter exactly", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::splitLowHz, 150.0f); + setParam (processor, ParamIDs::splitHighHz, 700.0f); + setParam (processor, ParamIDs::midDrive, 55.0f); + setParam (processor, ParamIDs::midLevel, -3.0f); + setParam (processor, ParamIDs::highTightHz, 200.0f); + setParam (processor, ParamIDs::highDrive, 65.0f); + + REQUIRE (manager.saveUserPreset ("Round Trip", "Bass")); + + // Perturb every parameter away from the saved values before reloading, + // so the assertions below can't pass by accident. + setParam (processor, ParamIDs::splitLowHz, 120.0f); + setParam (processor, ParamIDs::splitHighHz, 600.0f); + setParam (processor, ParamIDs::midDrive, 30.0f); + setParam (processor, ParamIDs::midLevel, 0.0f); + setParam (processor, ParamIDs::highTightHz, 100.0f); + setParam (processor, ParamIDs::highDrive, 50.0f); + + REQUIRE (manager.loadPreset ("Round Trip")); + + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (150.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (700.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::midDrive) == Catch::Approx (55.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::midLevel) == Catch::Approx (-3.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::highTightHz) == Catch::Approx (200.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::highDrive) == Catch::Approx (65.0f).margin (1.0e-3)); +} + +//============================================================================== +// 2. Import ignores unknown IDs, keeps defaults for missing IDs. +TEST_CASE ("PresetManager: import ignores unknown parameter IDs and keeps defaults for missing ones", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + // Move midDrive and midLevel away from their defaults so it's + // meaningful when the import below leaves them untouched (they're + // absent from "parameters"). + setParam (processor, ParamIDs::midDrive, 90.0f); + setParam (processor, ParamIDs::midLevel, 8.0f); + + // A fixture JSON generated inline (not committed under tests/fixtures/) + // to avoid brittle relative-path resolution across CI runners with + // different working directories (macOS vs Windows ctest invocations) - + // this is the forward/backward-compat scenario the spec's "fixture + // JSONs in tests/" line calls for: an unknown ID ("futureParameter", + // simulating a newer plugin version's preset) and two known IDs + // (splitLowHz/splitHighHz), deliberately omitting midDrive/midLevel. + const juce::String fixtureJson = R"({ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "9.9.9", + "name": "Forward Compat Fixture", + "category": "Bass", + "parameters": { "splitLowHz": 200.0, "splitHighHz": 1000.0, "futureParameter": 42.0 } + })"; + + const auto fixtureFile = juce::File::createTempFile (".basilicapreset"); + REQUIRE (fixtureFile.replaceWithText (fixtureJson)); + + juce::String errorMessage; + REQUIRE (manager.importPresetFile (fixtureFile, errorMessage)); + CHECK (errorMessage.isEmpty()); + + // Known IDs present in the fixture were applied... + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (200.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (1000.0f).margin (1.0e-3)); + + // ...IDs absent from the fixture were reset to their ParameterLayout + // defaults (loadPreset()/importPresetFile() always reset-then-apply - + // see PresetManager.h), not left at the pre-import 90%/8dB values. + auto* midDriveParam = processor.apvts.getParameter (ParamIDs::midDrive); + auto* midLevelParam = processor.apvts.getParameter (ParamIDs::midLevel); + CHECK (getParam (processor, ParamIDs::midDrive) == Catch::Approx (midDriveParam->convertFrom0to1 (midDriveParam->getDefaultValue())).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::midLevel) == Catch::Approx (midLevelParam->convertFrom0to1 (midLevelParam->getDefaultValue())).margin (1.0e-3)); + + fixtureFile.deleteFile(); +} + +//============================================================================== +// 3. Import refuses wrong-plugin and wrong-format files. +TEST_CASE ("PresetManager: import refuses a preset belonging to a different plugin", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + const juce::String wrongPluginJson = R"({ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Not Crypta's", + "category": "Bass", + "parameters": { "splitLowHz": 999.0 } + })"; + + const auto file = juce::File::createTempFile (".basilicapreset"); + REQUIRE (file.replaceWithText (wrongPluginJson)); + + juce::String errorMessage; + CHECK_FALSE (manager.importPresetFile (file, errorMessage)); + CHECK (errorMessage.isNotEmpty()); + + // State must be left untouched - splitLowHz must NOT have picked up 999 + // (out of its own 60-400 Hz range too, which would be a separate bug on + // top). + CHECK (getParam (processor, ParamIDs::splitLowHz) != Catch::Approx (999.0f)); + + file.deleteFile(); +} + +TEST_CASE ("PresetManager: import refuses a file with an incompatible format tag", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + const juce::String wrongFormatJson = R"({ + "format": "some-other-format-2", + "plugin": "com.yvesvogl.crypta", + "pluginVersion": "0.2.0", + "name": "Wrong Format", + "category": "Bass", + "parameters": { "splitLowHz": 999.0 } + })"; + + const auto file = juce::File::createTempFile (".basilicapreset"); + REQUIRE (file.replaceWithText (wrongFormatJson)); + + juce::String errorMessage; + CHECK_FALSE (manager.importPresetFile (file, errorMessage)); + CHECK (errorMessage.isNotEmpty()); + + file.deleteFile(); +} + +//============================================================================== +// 4. Factory presets all parse and load. +TEST_CASE ("PresetManager: every factory preset parses and loads without error", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + const auto all = manager.getAllPresets(); + const auto factoryCount = std::count_if (all.begin(), all.end(), [] (auto& e) { return e.isFactory; }); + + REQUIRE (factoryCount == 9); // docs/presets.md - Default + 8 brief-table presets + + for (auto& entry : all) + { + if (! entry.isFactory) + continue; + + CAPTURE (entry.name); + CHECK (manager.loadPreset (entry.name)); + CHECK (manager.isCurrentPresetFactory()); + CHECK (manager.getCurrentPresetName() == entry.name); + } +} + +TEST_CASE ("PresetManager: factory preset content is plausible (Default is Init category, all parameters in range, no NaN/Inf/silence)", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + const auto all = manager.getAllPresets(); + const auto defaultEntry = std::find_if (all.begin(), all.end(), [] (auto& e) { return e.name == "Default"; }); + + REQUIRE (defaultEntry != all.end()); + CHECK (defaultEntry->category == "Init"); + CHECK (defaultEntry->isFactory); + + juce::MidiBuffer midi; + + // 10. Preset round-trip (docs/design-brief.md's Guarantee #10): every + // factory preset loads, parameter values land in range, and produces no + // NaN/Inf/silence on a standard test signal. + for (auto& entry : all) + { + if (! entry.isFactory) + continue; + + REQUIRE (manager.loadPreset (entry.name)); + + CHECK (getParam (processor, ParamIDs::splitLowHz) >= 60.0f); + CHECK (getParam (processor, ParamIDs::splitLowHz) <= 400.0f); + CHECK (getParam (processor, ParamIDs::splitHighHz) >= 300.0f); + CHECK (getParam (processor, ParamIDs::splitHighHz) <= 2000.0f); + CHECK (getParam (processor, ParamIDs::midDrive) >= 0.0f); + CHECK (getParam (processor, ParamIDs::midDrive) <= 100.0f); + + juce::AudioBuffer buffer (2, 512); + + for (int channel = 0; channel < 2; ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < 512; ++sample) + data[sample] = 0.5f * std::sin (static_cast (sample) * 0.05f); + } + + CAPTURE (entry.name); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + + bool allFinite = true; + double sumOfSquares = 0.0; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + const auto* data = buffer.getReadPointer (channel); + + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + { + if (! std::isfinite (data[sample])) + allFinite = false; + + sumOfSquares += static_cast (data[sample]) * static_cast (data[sample]); + } + } + + CHECK (allFinite); + CHECK (sumOfSquares > 0.0); // not silent + } +} + +//============================================================================== +// 5. Default resolution order (user Default > factory Default > plain defaults). +TEST_CASE ("PresetManager: applyStartupDefault() loads the factory Default when no user Default exists", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::splitLowHz, 350.0f); // perturb first + + manager.applyStartupDefault(); + + CHECK (manager.getCurrentPresetName() == "Default"); + CHECK (manager.isCurrentPresetFactory()); + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (120.0f).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: a user Default preset wins over the factory Default", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::splitLowHz, 321.0f); + REQUIRE (manager.setCurrentAsDefault()); // writes a user preset literally named "Default" + + setParam (processor, ParamIDs::splitLowHz, 60.0f); // perturb away before the resolution check + + manager.applyStartupDefault(); + + CHECK (manager.getCurrentPresetName() == "Default"); + CHECK_FALSE (manager.isCurrentPresetFactory()); // resolved to the *user* Default, not the factory one + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (321.0f).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: resetDefault() removes the user Default so the factory Default resolves again", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::splitLowHz, 321.0f); + REQUIRE (manager.setCurrentAsDefault()); + REQUIRE (manager.resetDefault()); + + manager.applyStartupDefault(); + + CHECK (manager.isCurrentPresetFactory()); + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (120.0f).margin (1.0e-3)); +} + +//============================================================================== +// 6. Dirty flag: clean after load, dirty after any param change, clean after save. +TEST_CASE ("PresetManager: dirty flag lifecycle - clean after load, dirty after a change, clean after save", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + REQUIRE (manager.loadPreset ("Default")); + CHECK_FALSE (manager.isDirty()); + + setParam (processor, ParamIDs::splitLowHz, 300.0f); + CHECK (manager.isDirty()); + + REQUIRE (manager.saveUserPreset ("Dirty Flag Preset", "Bass")); + CHECK_FALSE (manager.isDirty()); +} + +//============================================================================== +// 7. prev/next ordering and wrap-around. +TEST_CASE ("PresetManager: nextPreset()/previousPreset() traverse alphabetically and wrap around", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + const auto all = manager.getAllPresets(); + REQUIRE (all.size() >= 2); + + REQUIRE (manager.loadPreset (all.front().name)); + + manager.nextPreset(); + CHECK (manager.getCurrentPresetName() == all[1].name); + + manager.previousPreset(); + CHECK (manager.getCurrentPresetName() == all.front().name); + + // Wrap backward from the first entry to the last. + manager.previousPreset(); + CHECK (manager.getCurrentPresetName() == all.back().name); + + // Wrap forward from the last entry back to the first. + manager.nextPreset(); + CHECK (manager.getCurrentPresetName() == all.front().name); +} + +//============================================================================== +// Additional coverage beyond the spec's minimum list: save/rename/delete +// guards, single-file export round-trip, and bank import/export. + +TEST_CASE ("PresetManager: saveUserPreset() refuses to shadow a factory preset name", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + CHECK_FALSE (manager.saveUserPreset ("Default", "Init")); // "Default" already exists as a factory preset + CHECK_FALSE (manager.saveUserPreset ("Sub Lock", "Bass")); +} + +TEST_CASE ("PresetManager: renameUserPreset() moves a user preset to a new name and preserves its parameters", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::splitHighHz, 1234.0f); + REQUIRE (manager.saveUserPreset ("Old Name", "Bass")); + + REQUIRE (manager.renameUserPreset ("Old Name", "New Name")); + + setParam (processor, ParamIDs::splitHighHz, 600.0f); // perturb before reloading + + CHECK_FALSE (manager.loadPreset ("Old Name")); // gone + REQUIRE (manager.loadPreset ("New Name")); + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (1234.0f).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: deleteUserPreset() removes a user preset but never a factory preset", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + REQUIRE (manager.saveUserPreset ("Temporary", "Bass")); + REQUIRE (manager.deleteUserPreset ("Temporary")); + CHECK_FALSE (manager.loadPreset ("Temporary")); + + // A factory preset name isn't a file on disk in the user directory, so + // there's nothing to delete - deleteUserPreset() must return false, and + // the factory preset must still load afterwards. + CHECK_FALSE (manager.deleteUserPreset ("Default")); + CHECK (manager.loadPreset ("Default")); +} + +TEST_CASE ("PresetManager: exportPreset()/importPresetFile() single-file round-trip", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::highTightHz, 333.0f); + REQUIRE (manager.saveUserPreset ("Exportable", "Bass")); + + const auto exportFile = juce::File::createTempFile (".basilicapreset"); + REQUIRE (manager.exportPreset ("Exportable", exportFile)); + REQUIRE (exportFile.existsAsFile()); + + REQUIRE (manager.deleteUserPreset ("Exportable")); // remove the original before reimporting + + juce::String errorMessage; + REQUIRE (manager.importPresetFile (exportFile, errorMessage)); + CHECK (getParam (processor, ParamIDs::highTightHz) == Catch::Approx (333.0f).margin (1.0e-3)); + + exportFile.deleteFile(); +} + +TEST_CASE ("PresetManager: exportBank()/importBank() round-trips every user preset through a zip", "[presets]") +{ + ScopedTestDirectory sourceScratch; + ScopedTestDirectory destScratch; + + CryptaAudioProcessor sourceProcessor; + sourceProcessor.prepareToPlay (48000.0, 512); + PresetManager sourceManager (sourceProcessor.apvts, makeIsolatedConfig (sourceScratch.dir), makeTestFactoryPresetAssets()); + + setParam (sourceProcessor, ParamIDs::splitLowHz, 111.0f); + REQUIRE (sourceManager.saveUserPreset ("Bank Preset A", "Bass")); + + setParam (sourceProcessor, ParamIDs::splitLowHz, 222.0f); + REQUIRE (sourceManager.saveUserPreset ("Bank Preset B", "Bass")); + + const auto bankFile = juce::File::createTempFile (".zip"); + REQUIRE (sourceManager.exportBank (bankFile)); + REQUIRE (bankFile.existsAsFile()); + + CryptaAudioProcessor destProcessor; + destProcessor.prepareToPlay (48000.0, 512); + PresetManager destManager (destProcessor.apvts, makeIsolatedConfig (destScratch.dir), makeTestFactoryPresetAssets()); + + const auto importedCount = destManager.importBank (bankFile); + CHECK (importedCount == 2); + + REQUIRE (destManager.loadPreset ("Bank Preset A")); + CHECK (getParam (destProcessor, ParamIDs::splitLowHz) == Catch::Approx (111.0f).margin (1.0e-3)); + + REQUIRE (destManager.loadPreset ("Bank Preset B")); + CHECK (getParam (destProcessor, ParamIDs::splitLowHz) == Catch::Approx (222.0f).margin (1.0e-3)); + + bankFile.deleteFile(); +} + +//============================================================================== +// 8. PresetManager never allocates or locks on the audio thread. +// +// Verified primarily *by design*: nothing in CryptaAudioProcessor:: +// processBlock()/the dsp:: classes ever calls into PresetManager (see +// PluginProcessor.cpp - presetManager is only touched from the constructor +// and from PresetBar's message-thread-only UI callbacks), so there is no +// code path for this test to exercise in the first place. The one nuance is +// PresetManager::parameterChanged() (an AudioProcessorValueTreeState:: +// Listener callback that JUCE does not document as guaranteed message- +// thread-only) - it is implemented as a single lock-free std::atomic +// store and nothing else (see PresetManager.h/.cpp), which this test +// exercises indirectly by driving parameter changes and processBlock() back +// to back and confirming nothing misbehaves. +TEST_CASE ("PresetManager: parameter-driven dirty tracking coexists safely with real-time audio processing", "[presets]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 256); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + REQUIRE (manager.loadPreset ("Default")); + CHECK_FALSE (manager.isDirty()); + + juce::AudioBuffer buffer (2, 256); + juce::MidiBuffer midi; + + for (int block = 0; block < 8; ++block) + { + // Every parameterChanged() callback below happens interleaved with + // real audio processing - if it ever became audio-thread-unsafe + // (e.g. someone later added a lock or allocation to it), a helgrind/ + // TSan CI run would be the real detector; this test's job is just to + // confirm normal operation isn't disrupted by the two coexisting. + setParam (processor, ParamIDs::splitLowHz, 60.0f + static_cast (block) * 20.0f); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + } + + CHECK (manager.isDirty()); +} diff --git a/tests/SplitGapTests.cpp b/tests/SplitGapTests.cpp new file mode 100644 index 0000000..9e015ae --- /dev/null +++ b/tests/SplitGapTests.cpp @@ -0,0 +1,67 @@ +#include "dsp/SplitGap.h" + +#include +#include + +#include + +// docs/design-brief.md's Topology/Split Low/Split High sections: splitHighHz +// must always stay above splitLowHz by at least a 1/3-octave musical gap, +// even though the two parameters' independent ranges overlap (splitLowHz up +// to 400 Hz, above splitHighHz's own 300 Hz floor). These tests exercise +// cryp::clampSplitHighHz() - the single pure function that enforces that +// clamp - directly at and around its boundary. +TEST_CASE ("clampSplitHighHz: leaves a requested value alone when it already clears the minimum gap", "[splitgap][dsp]") +{ + // 120 Hz -> minimum gap floor is 120 * 2^(1/3) ~= 151.2 Hz; 600 Hz clears + // it by a wide margin, so the clamp must be a no-op. + CHECK (cryp::clampSplitHighHz (120.0f, 600.0f) == Catch::Approx (600.0f)); +} + +TEST_CASE ("clampSplitHighHz: raises a too-close requested value up to exactly the minimum gap", "[splitgap][dsp]") +{ + constexpr float splitLowHz = 120.0f; + const auto expectedFloorHz = splitLowHz * std::pow (2.0f, 1.0f / 3.0f); + + // Requesting splitHighHz *equal to* splitLowHz (a degenerate zero-width + // Mid band if left unclamped) must be raised to the floor. + CHECK (cryp::clampSplitHighHz (splitLowHz, splitLowHz) == Catch::Approx (expectedFloorHz)); + + // Requesting splitHighHz *below* splitLowHz (an inverted Mid band) must + // also be raised to the same floor, not just left at the requested + // (inverted) value. + CHECK (cryp::clampSplitHighHz (splitLowHz, 50.0f) == Catch::Approx (expectedFloorHz)); +} + +TEST_CASE ("clampSplitHighHz: tracks the floor as splitLowHz moves, across the full overlapping range", "[splitgap][dsp]") +{ + // splitLowHz's own range extends to 400 Hz, above splitHighHz's 300 Hz + // floor - exercise every combination where the raw parameter values + // alone would violate the minimum gap. + const float splitLowValuesHz[] = { 60.0f, 120.0f, 200.0f, 300.0f, 400.0f }; + + for (const auto splitLowHz : splitLowValuesHz) + { + const auto expectedFloorHz = splitLowHz * std::pow (2.0f, 1.0f / 3.0f); + + INFO ("splitLowHz = " << splitLowHz); + // A requested splitHighHz pinned to splitLowHz's own value is always + // a violation (zero gap) regardless of which splitLowHz value it is. + CHECK (cryp::clampSplitHighHz (splitLowHz, splitLowHz) == Catch::Approx (expectedFloorHz)); + + // A requested splitHighHz comfortably above the floor is always left + // untouched. + CHECK (cryp::clampSplitHighHz (splitLowHz, expectedFloorHz * 2.0f) == Catch::Approx (expectedFloorHz * 2.0f)); + } +} + +TEST_CASE ("clampSplitHighHz: is a real-time-safe pure function (no allocation, deterministic)", "[splitgap][dsp]") +{ + // No explicit allocation-tracking here (this codebase verifies real-time + // safety primarily by design/API, matching the rest of the suite) - this + // asserts the determinism property that safety actually depends on: + // repeated calls with the same inputs always produce the exact same + // output, with no hidden state. + for (int i = 0; i < 100; ++i) + CHECK (cryp::clampSplitHighHz (150.0f, 700.0f) == Catch::Approx (700.0f)); +} diff --git a/tests/StateMigrationTests.cpp b/tests/StateMigrationTests.cpp new file mode 100644 index 0000000..f837a2d --- /dev/null +++ b/tests/StateMigrationTests.cpp @@ -0,0 +1,172 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" + +#include +#include + +// v0.1.x -> v0.2.0 structural state migration (docs/design-brief.md's +// Guarantee #7): a v1 saved session serializes a single "crossoverFreq" +// PARAM element - that parameter ID no longer exists in v0.2.0's +// ParameterLayout, replaced by the splitLowHz/splitHighHz pair. These tests +// hand-craft v1-shaped APVTS state XML directly (the same +// ... shape +// AudioProcessorValueTreeState itself produces/consumes - JUCE 8.0.14, +// juce_AudioProcessorValueTreeState.h's valueType="PARAM"/idPropertyID="id"/ +// valuePropertyID="value") and feed it through +// CryptaAudioProcessor::setStateInformation(), exercising the migration path +// a real v0.1.x-saved session would hit. +namespace +{ + juce::MemoryBlock makeStateBlock (const std::vector>& idsAndValues) + { + juce::XmlElement root ("PARAMETERS"); + + for (auto& [id, value] : idsAndValues) + { + auto* param = new juce::XmlElement ("PARAM"); + param->setAttribute ("id", id); + param->setAttribute ("value", value); + root.addChildElement (param); + } + + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary (root, block); + return block; + } + + float getParam (CryptaAudioProcessor& processor, const char* id) + { + auto* param = processor.apvts.getParameter (id); + REQUIRE (param != nullptr); + return param->convertFrom0to1 (param->getValue()); + } +} + +TEST_CASE ("State migration: an untouched v1 session (shipped default crossoverFreq=250Hz) lands splitHighHz exactly at the new 300Hz floor", "[state][migration]") +{ + // The single most common migration path per docs/design-brief.md's + // Guarantee #7: v1's shipped default (250 Hz) is below splitHighHz's new + // 300-2000 Hz range, so this is the dedicated regression test asserting + // that specific, explicitly-called-out clamp lands exactly on the floor. + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto legacyState = makeStateBlock ({ { "crossoverFreq", 250.0 } }); + processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize())); + + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (300.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: a legacy crossoverFreq already inside the new range passes through unclamped", "[state][migration]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto legacyState = makeStateBlock ({ { "crossoverFreq", 500.0 } }); + processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize())); + + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (500.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: a legacy crossoverFreq above the new range's ceiling is clamped down to 2000Hz", "[state][migration]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + // v1's crossoverFreq range extended up to 1000 Hz (never actually above + // splitHighHz's 2000 Hz ceiling in practice), but the migration's own + // jlimit() must still be exercised defensively at/above the ceiling. + const auto legacyState = makeStateBlock ({ { "crossoverFreq", 1000.0 } }); + processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize())); + + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (1000.0f).margin (1.0e-3)); + + CryptaAudioProcessor processorAboveCeiling; + processorAboveCeiling.prepareToPlay (48000.0, 512); + const auto extremeState = makeStateBlock ({ { "crossoverFreq", 5000.0 } }); + processorAboveCeiling.setStateInformation (extremeState.getData(), static_cast (extremeState.getSize())); + + CHECK (getParam (processorAboveCeiling, ParamIDs::splitHighHz) == Catch::Approx (2000.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: splitLowHz and every new mid-band/Tight parameter fall back to v0.2.0 defaults, never garbage", "[state][migration]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto legacyState = makeStateBlock ({ { "crossoverFreq", 250.0 } }); + processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize())); + + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (120.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::midDrive) == Catch::Approx (30.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::midLevel) == Catch::Approx (0.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::highTightHz) == Catch::Approx (100.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: an explicitly-changed v1 lowCompRatio/Attack/Release is preserved as-is, not forced to the new v0.2.0 defaults", "[state][migration]") +{ + // Only the *shipped default* changed (4:1/10ms/120ms -> 2:1/3ms/6ms) - + // a user's deliberate old setting must round-trip untouched. + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto legacyState = makeStateBlock ({ + { "crossoverFreq", 250.0 }, + { "lowCompRatio", 8.0 }, + { "lowCompAttack", 25.0 }, + { "lowCompRelease", 300.0 }, + }); + processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize())); + + CHECK (getParam (processor, ParamIDs::lowCompRatio) == Catch::Approx (8.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::lowCompAttack) == Catch::Approx (25.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::lowCompRelease) == Catch::Approx (300.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: a v0.2.0+ session (no crossoverFreq element) is untouched by the migration path", "[state][migration]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto v2State = makeStateBlock ({ { "splitLowHz", 150.0 }, { "splitHighHz", 700.0 } }); + processor.setStateInformation (v2State.getData(), static_cast (v2State.getSize())); + + CHECK (getParam (processor, ParamIDs::splitLowHz) == Catch::Approx (150.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (700.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: an already-present splitHighHz is never overwritten by a stray legacy crossoverFreq element", "[state][migration]") +{ + // Defensive case (docs/design-brief.md notes this can't happen from a + // genuine v1 or v2 session, only from a malformed/hand-edited file) - + // migrateLegacySingleCrossover() must not clobber an explicit + // splitHighHz value that's already present. + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto malformedState = makeStateBlock ({ { "crossoverFreq", 250.0 }, { "splitHighHz", 800.0 } }); + processor.setStateInformation (malformedState.getData(), static_cast (malformedState.getSize())); + + CHECK (getParam (processor, ParamIDs::splitHighHz) == Catch::Approx (800.0f).margin (1.0e-3)); +} + +TEST_CASE ("State migration: never crashes and produces finite output on a legacy v1 session", "[state][migration][robustness]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto legacyState = makeStateBlock ({ + { "crossoverFreq", 250.0 }, + { "lowCompRatio", 4.0 }, + { "lowCompAttack", 10.0 }, + { "lowCompRelease", 120.0 }, + { "highVoicing", 0.0 }, + { "highDrive", 50.0 }, + }); + + CHECK_NOTHROW (processor.setStateInformation (legacyState.getData(), static_cast (legacyState.getSize()))); + + juce::AudioBuffer buffer (2, 512); + juce::MidiBuffer midi; + CHECK_NOTHROW (processor.processBlock (buffer, midi)); +} diff --git a/tests/StateTests.cpp b/tests/StateTests.cpp index f017f8d..74c4bc5 100644 --- a/tests/StateTests.cpp +++ b/tests/StateTests.cpp @@ -46,29 +46,29 @@ TEST_CASE ("State round-trip preserves non-default parameter values", "[state]") CHECK (bypassParam->getValue() == Catch::Approx (savedBypassValue).margin (1e-6)); } -TEST_CASE ("State round-trip preserves non-default values of the full v1.0 parameter set", "[state][parameters]") +TEST_CASE ("State round-trip preserves non-default values of the full v0.2.0 parameter set", "[state][parameters]") { CryptaAudioProcessor processor; processor.prepareToPlay (48000.0, 512); // Exercise a representative float (log-skewed frequency), a bool, and // the AudioParameterChoice - one of each parameter kind declared by the - // full v1.0 layout, not just the M0 IO parameters. - auto* crossoverParam = processor.apvts.getParameter (ParamIDs::crossoverFreq); + // full v0.2.0 layout, not just the M0 IO parameters. + auto* splitHighParam = processor.apvts.getParameter (ParamIDs::splitHighHz); auto* gateEnabledParam = processor.apvts.getParameter (ParamIDs::gateEnabled); auto* voicingParam = processor.apvts.getParameter (ParamIDs::highVoicing); - REQUIRE (crossoverParam != nullptr); + REQUIRE (splitHighParam != nullptr); REQUIRE (gateEnabledParam != nullptr); REQUIRE (voicingParam != nullptr); - crossoverParam->setValueNotifyingHost (crossoverParam->convertTo0to1 (400.0f)); + splitHighParam->setValueNotifyingHost (splitHighParam->convertTo0to1 (900.0f)); gateEnabledParam->setValueNotifyingHost (1.0f); // "Razor" is index 2 of {Gnaw, Wool, Razor}; normalise via the choice // parameter's own range so this doesn't hardcode the 0-1 step size. voicingParam->setValueNotifyingHost (voicingParam->convertTo0to1 (2.0f)); - const auto savedCrossoverValue = crossoverParam->getValue(); + const auto savedSplitHighValue = splitHighParam->getValue(); const auto savedGateEnabledValue = gateEnabledParam->getValue(); const auto savedVoicingValue = voicingParam->getValue(); @@ -78,17 +78,17 @@ TEST_CASE ("State round-trip preserves non-default values of the full v1.0 param // Reset back to defaults before restoring, so the round-trip assertion // below can't pass by accident. - crossoverParam->setValueNotifyingHost (crossoverParam->getDefaultValue()); + splitHighParam->setValueNotifyingHost (splitHighParam->getDefaultValue()); gateEnabledParam->setValueNotifyingHost (gateEnabledParam->getDefaultValue()); voicingParam->setValueNotifyingHost (voicingParam->getDefaultValue()); - REQUIRE (crossoverParam->getValue() != Catch::Approx (savedCrossoverValue)); + REQUIRE (splitHighParam->getValue() != Catch::Approx (savedSplitHighValue)); REQUIRE (gateEnabledParam->getValue() != Catch::Approx (savedGateEnabledValue)); REQUIRE (voicingParam->getValue() != Catch::Approx (savedVoicingValue)); processor.setStateInformation (savedState.getData(), static_cast (savedState.getSize())); - CHECK (crossoverParam->getValue() == Catch::Approx (savedCrossoverValue).margin (1e-6)); + CHECK (splitHighParam->getValue() == Catch::Approx (savedSplitHighValue).margin (1e-6)); CHECK (gateEnabledParam->getValue() == Catch::Approx (savedGateEnabledValue).margin (1e-6)); CHECK (voicingParam->getValue() == Catch::Approx (savedVoicingValue).margin (1e-6)); } diff --git a/tests/ThreeBandFlatSumTests.cpp b/tests/ThreeBandFlatSumTests.cpp new file mode 100644 index 0000000..8ccad8e --- /dev/null +++ b/tests/ThreeBandFlatSumTests.cpp @@ -0,0 +1,192 @@ +#include "dsp/Crossover.h" +#include "dsp/PhaseAlignFilter.h" +#include "dsp/SplitGap.h" +#include "TestHelpers.h" + +#include +#include + +#include + +// v0.2.0 3-band rebuild (docs/design-brief.md's Guarantee #1): extends +// CrossoverTests.cpp's 2-band flat-sum property to the cascaded 3-band +// topology - two cryp::Crossover instances in series (Low, then Mid/High) +// must still reconstruct the original signal within +-0.1 dB across the +// band, for a swept combination of splitLowHz/splitHighHz settings, +// including at the minimum-gap clamp's own boundary. +// +// A naive cascade of two independent 2-way LR4 crossovers does NOT +// flat-sum on its own (confirmed empirically while building this test file: +// deviations up to -10 dB at close splitLowHz/splitHighHz ratios) - the +// harness below applies the same cryp::PhaseAlignFilter compensation +// PluginProcessor.cpp's processChunk() applies to the Low band, exactly +// mirroring the real production signal path. See PhaseAlignFilter.h's +// class-level docs for the algebraic proof of why this specific +// compensation (an allpass matching midHighSplit's own cutoff, applied to +// the Low band before the final sum) makes the three-way sum exactly flat. +namespace +{ + constexpr double testSampleRate = 48000.0; + // Generous enough to settle three cascaded 4th-order IIR stages + // (lowSplit + midHighSplit + the lowBandPhaseAlign compensation, each + // its own transient) even for the swept-combination test's lowest probe + // frequencies (as low as splitLowHz * 0.5, down to 30 Hz - one period is + // 1600 samples at 48kHz, so settleSamples below covers >10 periods). + constexpr int numSamples = 32768; + constexpr int settleSamples = 16384; + + // Runs a steady-state probe tone through the cascaded Low -> Mid/High + // split and returns the level difference, in dB, between the + // low+mid+high sum and the original input signal, measured over the + // settled (post-transient) tail. + double measureThreeBandFlatSumDeviationDb (double probeFrequencyHz, float splitLowHz, float requestedSplitHighHz) + { + cryp::Crossover lowSplit; + cryp::Crossover midHighSplit; + cryp::PhaseAlignFilter lowBandPhaseAlign; + + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = 1; + + lowSplit.prepare (spec); + lowSplit.setCutoffFrequency (splitLowHz); + + const auto effectiveSplitHighHz = cryp::clampSplitHighHz (splitLowHz, requestedSplitHighHz); + + midHighSplit.prepare (spec); + midHighSplit.setCutoffFrequency (effectiveSplitHighHz); + + lowBandPhaseAlign.prepare (spec); + lowBandPhaseAlign.setCutoffFrequency (effectiveSplitHighHz); + + juce::AudioBuffer input (1, numSamples); + TestHelpers::fillWithSine (input, testSampleRate, probeFrequencyHz, 1.0f); + + juce::AudioBuffer low (1, numSamples); + juce::AudioBuffer remainder (1, numSamples); + juce::AudioBuffer mid (1, numSamples); + juce::AudioBuffer high (1, numSamples); + + const juce::dsp::AudioBlock inputBlock (input); + juce::dsp::AudioBlock lowBlock (low); + juce::dsp::AudioBlock remainderBlock (remainder); + juce::dsp::AudioBlock midBlock (mid); + juce::dsp::AudioBlock highBlock (high); + + lowSplit.process (inputBlock, lowBlock, remainderBlock); + midHighSplit.process (juce::dsp::AudioBlock (remainder), midBlock, highBlock); + lowBandPhaseAlign.process (lowBlock); + + double sumOfSquaresInput = 0.0; + double sumOfSquaresSum = 0.0; + int countedSamples = 0; + + const auto* inData = input.getReadPointer (0); + const auto* lowData = low.getReadPointer (0); + const auto* midData = mid.getReadPointer (0); + const auto* highData = high.getReadPointer (0); + + for (int i = settleSamples; i < numSamples; ++i) + { + const auto summed = lowData[i] + midData[i] + highData[i]; + sumOfSquaresInput += static_cast (inData[i]) * static_cast (inData[i]); + sumOfSquaresSum += static_cast (summed) * static_cast (summed); + ++countedSamples; + } + + REQUIRE (countedSamples > 0); + + const auto inputRms = std::sqrt (sumOfSquaresInput / static_cast (countedSamples)); + const auto sumRms = std::sqrt (sumOfSquaresSum / static_cast (countedSamples)); + + REQUIRE (inputRms > 0.0); + + return juce::Decibels::gainToDecibels (sumRms / inputRms); + } +} + +TEST_CASE ("Three-band cascaded LR4: low+mid+high sum reconstructs the input within +-0.1 dB at the v0.2.0 defaults", "[crossover][dsp][three-band]") +{ + constexpr float splitLowHz = 120.0f; + constexpr float splitHighHz = 600.0f; + + // Spans well below Split Low, at Split Low, inside the Mid band, at + // Split High (the hardest case for either crossover stage), and well + // above Split High. + const double probeFrequenciesHz[] = { + 30.0, 60.0, 100.0, + 120.0, // exactly at Split Low + 200.0, 400.0, + 600.0, // exactly at Split High + 1000.0, 2000.0, 5000.0, 10000.0, 15000.0, 19000.0 + }; + + for (const auto probeFrequencyHz : probeFrequenciesHz) + { + INFO ("probe frequency = " << probeFrequencyHz << " Hz"); + const auto deviationDb = measureThreeBandFlatSumDeviationDb (probeFrequencyHz, splitLowHz, splitHighHz); + CHECK (deviationDb == Catch::Approx (0.0).margin (0.1)); + } +} + +TEST_CASE ("Three-band cascaded LR4: flat sum holds across a swept combination of splitLowHz/splitHighHz settings", "[crossover][dsp][three-band]") +{ + struct SplitCombo + { + float splitLowHz; + float splitHighHz; + }; + + const SplitCombo combos[] = { + { 60.0f, 300.0f }, // both at their range floors + { 90.0f, 500.0f }, // Sub Lock preset + { 180.0f, 900.0f }, // Cut Through preset + { 400.0f, 2000.0f }, // both at their range ceilings + }; + + for (const auto& combo : combos) + { + const double probeFrequenciesHz[] = { + combo.splitLowHz * 0.5, combo.splitLowHz, + (combo.splitLowHz + combo.splitHighHz) * 0.5, + combo.splitHighHz, combo.splitHighHz * 2.0 + }; + + for (const auto probeFrequencyHz : probeFrequenciesHz) + { + INFO ("splitLowHz = " << combo.splitLowHz << ", splitHighHz = " << combo.splitHighHz + << ", probe = " << probeFrequencyHz << " Hz"); + const auto deviationDb = measureThreeBandFlatSumDeviationDb (probeFrequencyHz, combo.splitLowHz, combo.splitHighHz); + CHECK (deviationDb == Catch::Approx (0.0).margin (0.1)); + } + } +} + +TEST_CASE ("Three-band cascaded LR4: flat sum holds exactly at the minimum-gap clamp's own boundary", "[crossover][dsp][three-band][splitgap]") +{ + // Requests splitHighHz pinned to splitLowHz itself (a degenerate, + // unclamped zero-width Mid band) - clampSplitHighHz() raises the + // *effective* splitHighHz fed to midHighSplit up to the 1/3-octave + // floor (see SplitGapTests.cpp), and the flat-sum property must still + // hold at that clamped boundary, not just away from it. + constexpr float splitLowHz = 250.0f; + const auto effectiveSplitHighHz = cryp::clampSplitHighHz (splitLowHz, splitLowHz); + + const double probeFrequenciesHz[] = { + splitLowHz * 0.5, splitLowHz, + effectiveSplitHighHz, // exactly at the clamped boundary + effectiveSplitHighHz * 2.0 + }; + + for (const auto probeFrequencyHz : probeFrequenciesHz) + { + INFO ("probe frequency = " << probeFrequencyHz << " Hz"); + // Pass the raw (unclamped) requested splitHighHz value + // (splitLowHz itself) - measureThreeBandFlatSumDeviationDb() + // applies clampSplitHighHz() internally, matching production code. + const auto deviationDb = measureThreeBandFlatSumDeviationDb (probeFrequencyHz, splitLowHz, splitLowHz); + CHECK (deviationDb == Catch::Approx (0.0).margin (0.1)); + } +} diff --git a/tests/TopologyRobustnessTests.cpp b/tests/TopologyRobustnessTests.cpp new file mode 100644 index 0000000..270c257 --- /dev/null +++ b/tests/TopologyRobustnessTests.cpp @@ -0,0 +1,113 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "TestHelpers.h" + +#include + +// v0.2.0 3-band rebuild (docs/design-brief.md's Guarantee #8): extends the +// existing robustness-sweep pattern (RobustnessTests.cpp, +// SampleRateAndRobustnessTests.cpp) to every new/changed control - +// splitLowHz, splitHighHz (including their minimum-gap clamp boundary), +// midDrive, and highTightHz - combined with extreme Drive/Level/EQ settings, +// asserting no NaN/Inf ever propagates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int testBlockSize = 512; + + void setParam (CryptaAudioProcessor& processor, const char* id, float plainValue) + { + auto* parameter = processor.apvts.getParameter (id); + REQUIRE (parameter != nullptr); + parameter->setValueNotifyingHost (parameter->convertTo0to1 (plainValue)); + } + + void setBoolParam (CryptaAudioProcessor& processor, const char* id, bool value) + { + auto* parameter = processor.apvts.getParameter (id); + REQUIRE (parameter != nullptr); + parameter->setValueNotifyingHost (value ? 1.0f : 0.0f); + } +} + +TEST_CASE ("Topology robustness: extreme splitLowHz/splitHighHz combinations (incl. the minimum-gap boundary) never produce NaN/Inf", "[robustness][topology]") +{ + struct SplitCombo + { + float splitLowHz; + float splitHighHz; + }; + + const SplitCombo combos[] = { + { 60.0f, 300.0f }, // both range floors + { 400.0f, 2000.0f }, // both range ceilings + { 400.0f, 300.0f }, // inverted - exercises the minimum-gap clamp hard + { 250.0f, 250.0f }, // pinned equal - degenerate zero-gap request + { 60.0f, 2000.0f }, // widest possible Mid band + }; + + for (const auto& combo : combos) + { + CryptaAudioProcessor processor; + processor.prepareToPlay (testSampleRate, testBlockSize); + + setParam (processor, ParamIDs::splitLowHz, combo.splitLowHz); + setParam (processor, ParamIDs::splitHighHz, combo.splitHighHz); + + // Extreme Drive/Level/EQ settings stacked on top, per the brief's + // Guarantee #8 wording. + setParam (processor, ParamIDs::midDrive, 100.0f); + setParam (processor, ParamIDs::highTightHz, 500.0f); + setParam (processor, ParamIDs::highDrive, 100.0f); + setParam (processor, ParamIDs::lowLevel, 12.0f); + setParam (processor, ParamIDs::midLevel, 12.0f); + setParam (processor, ParamIDs::highLevel, 12.0f); + setBoolParam (processor, ParamIDs::eqEnabled, true); + setParam (processor, ParamIDs::eqLowShelfGain, 18.0f); + setParam (processor, ParamIDs::eqPeak1Gain, -18.0f); + setParam (processor, ParamIDs::eqPeak2Gain, 18.0f); + setParam (processor, ParamIDs::eqHighShelfGain, -18.0f); + + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + for (int i = 0; i < 8; ++i) + { + TestHelpers::fillWithSine (buffer, testSampleRate, 220.0, 0.9f, static_cast (i) * testBlockSize); + + INFO ("splitLowHz = " << combo.splitLowHz << ", splitHighHz = " << combo.splitHighHz << ", block = " << i); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + } +} + +TEST_CASE ("Topology robustness: extreme midDrive/highTightHz sweep at extreme splits never produces NaN/Inf", "[robustness][topology]") +{ + const float midDriveValues[] = { 0.0f, 50.0f, 100.0f }; + const float highTightHzValues[] = { 20.0f, 100.0f, 500.0f }; + + for (const auto midDrive : midDriveValues) + { + for (const auto highTightHz : highTightHzValues) + { + CryptaAudioProcessor processor; + processor.prepareToPlay (testSampleRate, testBlockSize); + + setParam (processor, ParamIDs::splitLowHz, 400.0f); // range ceiling + setParam (processor, ParamIDs::splitHighHz, 300.0f); // range floor (below splitLowHz - exercises clamp) + setParam (processor, ParamIDs::midDrive, midDrive); + setParam (processor, ParamIDs::highTightHz, highTightHz); + setParam (processor, ParamIDs::highDrive, 100.0f); + + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + TestHelpers::fillWithSine (buffer, testSampleRate, 150.0, 0.9f); + + INFO ("midDrive = " << midDrive << ", highTightHz = " << highTightHz); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + } +} diff --git a/tests/VoicingTests.cpp b/tests/VoicingTests.cpp index 97d3435..cd17edb 100644 --- a/tests/VoicingTests.cpp +++ b/tests/VoicingTests.cpp @@ -256,6 +256,62 @@ TEST_CASE ("Voicing: setVoicing() resets midFilter state instead of ringing a co CHECK (ratio < 2.35); } +TEST_CASE ("Voicing: Tight (highTightHz) attenuates low frequencies monotonically, identically for every voicing", "[voicing][dsp][tight]") +{ + // docs/design-brief.md's Guarantee #5: Tight was promoted from a + // Razor-only fixed 200Hz internal constant to a first-class, + // voicing-independent control (v0.2.0) - this closes the gap identified + // in the brief's "Why v1 falls short" #3 by asserting the sweep behaves + // identically across all three voicings, not just Razor. + // + // The probe sits well below every swept Tight setting (including the + // 20 Hz floor) so it stays in the HPF's stopband-widening regime across + // the *entire* sweep, giving a clean monotonic attenuation trend rather + // than only a partial one; a dedicated, larger local buffer (not the + // file's shared 2048-sample numSamples) gives this specific probe + // frequency enough periods to settle/measure reliably. + constexpr double lowProbeFrequencyHz = 15.0; + constexpr int tightSweepNumSamples = 16384; + + const float tightSweepHz[] = { 20.0f, 60.0f, 100.0f, 150.0f, 250.0f, 400.0f, 500.0f }; + + for (const auto voicingType : allVoicings) + { + double previousRms = std::numeric_limits::infinity(); + + for (const auto tightHz : tightSweepHz) + { + cryp::Voicing voicing; + + juce::dsp::ProcessSpec sweepSpec; + sweepSpec.sampleRate = testSampleRate; + sweepSpec.maximumBlockSize = static_cast (tightSweepNumSamples); + sweepSpec.numChannels = 1; + + voicing.prepare (sweepSpec, 1.0f); // fully wet so Tight's effect reaches the output + voicing.setVoicing (voicingType); + voicing.setTightHz (tightHz); + voicing.setDrive (0.3f); // fixed, moderate drive + voicing.setTone (1.0f); // tone filter's pole near Nyquist, minimal own contribution + + juce::AudioBuffer buffer (1, tightSweepNumSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, lowProbeFrequencyHz, 0.7f); + + juce::dsp::AudioBlock block (buffer); + voicing.process (block); + + const auto rms = TestHelpers::rms (buffer); + + INFO ("voicing index = " << static_cast (voicingType) << ", tightHz = " << tightHz); + // Monotonically non-increasing as Tight rises (more attenuation + // of the fixed low-frequency probe) - small numerical margin for + // floating-point noise at adjacent sweep steps. + CHECK (rms <= previousRms + 1.0e-6); + previousRms = rms; + } + } +} + TEST_CASE ("Voicing: switching voicing mid-stream never produces NaN/Inf", "[voicing][dsp][robustness]") { cryp::Voicing voicing;