diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e33f7..09ddb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-07-16 + +Deep-dive voicing pass (research-derived, sourced from manuals/forums/physics standards - not measured hardware, see `docs/design-brief.md`'s Honesty section) plus the suite's first M2 preset system implementation (pilot for the other 11 plugins), a German frame-string localisation, and an app icon. Also folds in a run of fixes/housekeeping commits merged after v0.1.0 that never got their own release. + +### Changed + +- **Distance's low-shelf taper is now front-loaded, not linear** (`docs/design-brief.md`'s "Distance" module spec): real proximity effect concentrates most of its audible change early in a mic's travel and saturates, rather than growing evenly - `CabConvolutionEngine`'s low-shelf cut now scales through an "ease-out" power curve (`1 - (1 - normalisedDistance)^1.8`) instead of plain-linear-in-dB. The high-shelf keeps its linear taper (a deliberate asymmetry - see `docs/architecture.md`'s "v0.2.0 Distance taper" note). **This is parameter-behavior-breaking pre-1.0**: a session with Distance at, say, 60% will sound different after this update, even though the parameter's own range/default are unchanged and no migration is needed (see the design brief's Versioning section). +- **Doc/manual wording**: replaced "air-absorption darkening" framing for Distance's high-shelf with a directivity-first explanation (`docs/manual.md`, `docs/architecture.md`, `README.md`) - the audible effect is real, but at typical reamping distances it's driven far more by loudspeaker directivity than literal atmospheric absorption. +- **LoCut/HiCut ranges (20-800 Hz / 2-20 kHz) are now explicitly documented as a deliberate keep**, not an unexamined default, after comparison against the closest structural reference plugin (Ignite Amps NadIR: 10-400 Hz / 6-22 kHz) - see `docs/architecture.md` and the new named regression test in `tests/EngineTests.cpp`. No range or default values changed. + +### Added + +- **M2 preset system** (`.scaffold/specs/preset-system-m2.md`, binding suite-wide spec - Nave is the pilot implementation): `src/presets/PresetManager` (factory presets embedded via BinaryData, user presets on disk, load/save/rename/delete, dirty-state tracking, prev/next navigation, single-file and zip-bank import/export, user-preset-wins-over-factory default resolution) and `src/presets/PresetBar` (the editor strip, docked at the top of the existing v0.1/v0.2 layout). Written with no Nave-specific coupling beyond a small config struct, documented as a sibling-plugin replication recipe in `docs/preset-system-notes.md`. +- **8 factory presets** (`presets/factory/*.json`, documented in `docs/presets.md`): Default, Tame the Fizz, Live Stage, Dark Vintage, Pushed Back in the Room, Touch of Room Mic, Even Blend, Parallel Cab (Blended Dry) - sourced starting points from `docs/design-brief.md`, tunable/auditionable, not claimed as hardware-matched. +- **German frame-string localisation**: `resources/i18n/de.txt`, selected automatically via `SystemStats::getUserLanguage()` for PresetBar's labels/menus/dialogs. Parameter/DSP terminology (LoCut, HiCut, Distance, Mix, Level, Hz, dB, %) is never translated, in this pass or any future one. +- **App icon wired into the plugin binaries** (`ICON_BIG` in `CMakeLists.txt`, pointing at the icon asset added in v0.1.0's branding pass) - previously only used in docs/README, not embedded in the built AU/VST3/Standalone. +- `docs/design-brief.md` and `docs/research-notes.md`: the sourced design brief and research notes this release's voicing/doc changes are derived from. +- Manual now notes that loading two different real-world IRs can land at different output levels because `Convolution::Normalise::yes` is an energy normalisation, not a perceptual loudness match - pointing at Level as the fix (`docs/manual.md`). +- New Catch2 coverage: Distance taper front-loading/monotonicity test, a fixed-point taper regression snapshot, a named LoCut/HiCut range-guard test, and 16 preset-system tests (`tests/PresetManagerTests.cpp` - round-trip, forward-tolerant import, wrong-plugin/wrong-format refusal, factory preset integrity, default resolution order, dirty-flag lifecycle, prev/next wrap-around, rename/delete guards, single-file and bank import/export). + ### Fixed -- **`convolutionB` not reset on Blend's disengaged->engaged transition** (#12): unlike LoCut/HiCut/Distance, IR B's convolution engine kept no history of its own bypass state, so its internal overlap-add tail could go stale (frozen, not decaying) while Blend was disengaged and then leak into the output the moment Blend re-engaged. `CabConvolutionEngine` now tracks `blendEngagedPreviously` and calls `convolutionB.reset()` on the same disengaged->engaged transition the other stages already handle. -- **Reloading IR A after IR B silently invalidated IR B's phase alignment** (#13): `setImpulseResponse()`/`loadDefaultImpulseResponse()` recorded IR A's new onset as the phase-alignment reference but never re-ran IR B's alignment against it, leaving an already-loaded IR B aligned to a stale, overwritten onset (reintroducing comb-filtering on the next Blend crossfade). `CabConvolutionEngine` now retains a copy of IR B's raw, pre-alignment buffer and automatically re-aligns it whenever IR A's reference onset changes. +Carried over from commits merged after the v0.1.0 tag that never shipped in a release: + +- **`convolutionB` not reset on Blend's disengaged->engaged transition** (#12, PR #18): unlike LoCut/HiCut/Distance, IR B's convolution engine kept no history of its own bypass state, so its internal overlap-add tail could go stale (frozen, not decaying) while Blend was disengaged and then leak into the output the moment Blend re-engaged. `CabConvolutionEngine` now tracks `blendEngagedPreviously` and calls `convolutionB.reset()` on the same disengaged->engaged transition the other stages already handle. +- **Reloading IR A after IR B silently invalidated IR B's phase alignment** (#13, PR #18): `setImpulseResponse()`/`loadDefaultImpulseResponse()` recorded IR A's new onset as the phase-alignment reference but never re-ran IR B's alignment against it, leaving an already-loaded IR B aligned to a stale, overwritten onset (reintroducing comb-filtering on the next Blend crossfade). `CabConvolutionEngine` now retains a copy of IR B's raw, pre-alignment buffer and automatically re-aligns it whenever IR A's reference onset changes. + +### Other + +- Housekeeping merged between v0.1.0 and this release, honestly summarised rather than omitted: branding/icon assets added and embedded in README/manual (#9, #14), a tag-triggered signed release CI workflow (#10), a marketing-copy reframe from "symphonic metal" to "heavy music" (#15), and a README fix pointing at the Releases page instead of a stale "no releases yet" note (#17). ## [0.1.0] - 2026-07-14 diff --git a/CLAUDE.md b/CLAUDE.md index db0ea10..7c1e8cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,11 @@ Per-repo working memory for Claude Code sessions on this plugin. Part of the **B ## What this is Nave is the "cabinet IR loader / convolution (guitar & bass)" member of the suite. AU / VST3 / Standalone, JUCE 8. -## Status (v0.1.0 — M1 DSP completion & test coverage done) -Core DSP working, **50 Catch2 tests green**, CI (macOS + Windows, pluginval strictness 10 + auval) green. GUI is a functional v0.1 slider editor (custom LookAndFeel is roadmap M3). No signing yet (roadmap M4). M1 added IR Blend (dual-IR crossfade with inter-IR phase alignment) and simulated mic Distance; the "bundled IR library" part of M1's DSP issue is deferred (asset-sourcing/licensing, not a DSP task — see the issue comment on #1). Open work is tracked in this repo's GitHub **milestones/issues**. +## Status (v0.2.0 — M2 preset system + deep-dive voicing pass done) +Core DSP working, **73 Catch2 tests green**, CI (macOS + Windows, pluginval strictness 10 + auval) green. GUI is a functional v0.1/v0.2 slider editor plus a preset bar (custom LookAndFeel is still roadmap M3). No signing yet (roadmap M4). v0.2.0 shipped: a research-derived Distance taper revision (front-loaded low-shelf, see `docs/design-brief.md`), the suite's first M2 preset system implementation (`src/presets/`, pilot for the other 11 plugins - see `docs/preset-system-notes.md` for the replication recipe), 8 factory presets, a German frame-string localisation, and the app icon wired via `ICON_BIG`. The "bundled IR library" part of M1's DSP issue remains deferred (asset-sourcing/licensing, not a DSP task — see the issue comment on #1). Open work is tracked in this repo's GitHub **milestones/issues**. ## DSP -Nave is a cabinet IR loader built around two independent juce::dsp::Convolution slots (IR A, IR B), each constructed with the default zero-latency, uniformly-partitioned configuration (Convolution::Latency{0}), chosen because reamping IRs are short and the workflow is latency-sensitive. Signal chain: Convolution (crossfade of IR A/IR B via the IR Blend parameter) -> Distance (simulated mic-to-cab distance: proximity low-shelf + air-absorption high-shelf) -> LoCut HPF -> HiCut LPF -> juce::dsp::DryWetMixer (delay-compensated, currently 0 samples) -> Level trim. LoCut's minimum (20 Hz), HiCut's maximum (20 kHz), and Distance's minimum (0%) are each treated as explicit "off"/bypass positions where the engine skips that filter's IIR processing entirely (not just an extreme cutoff/gain) — this was necessary because even a 2nd-order Butterworth many octaves outside a test tone still imposes enough phase shift to fail a strict -80 dBFS sample-domain null; skipping it entirely gives a true bit-accurate passthrough at the default state (IR Blend defaults to 0%, i.e. IR A only, which is bit-identical to the pre-M1 single-IR path). Loading IR B applies inter-IR phase alignment (src/dsp/IrAlignment.{h,cpp}: onset detection via a relative-threshold crossing, then a time-domain shift) against IR A's most recently loaded onset, so blending the two never introduces comb-filtering from a timing mismatch. Both IR files' absolute paths are persisted as plain ValueTree properties on apvts.state (not APVTS float parameters), round-tripping automatically through copyState()/replaceState(); file I/O only ever happens off the audio thread (editor FileChooser callbacks or setStateInformation, both message-thread/session-load contexts). +Nave is a cabinet IR loader built around two independent juce::dsp::Convolution slots (IR A, IR B), each constructed with the default zero-latency, uniformly-partitioned configuration (Convolution::Latency{0}), chosen because reamping IRs are short and the workflow is latency-sensitive. Signal chain: Convolution (crossfade of IR A/IR B via the IR Blend parameter) -> Distance (simulated mic-to-cab distance: a front-loaded/"ease-out"-tapered proximity low-shelf + a linear directivity-darkening high-shelf, v0.2.0) -> LoCut HPF -> HiCut LPF -> juce::dsp::DryWetMixer (delay-compensated, currently 0 samples) -> Level trim. LoCut's minimum (20 Hz), HiCut's maximum (20 kHz), and Distance's minimum (0%) are each treated as explicit "off"/bypass positions where the engine skips that filter's IIR processing entirely (not just an extreme cutoff/gain) — this was necessary because even a 2nd-order Butterworth many octaves outside a test tone still imposes enough phase shift to fail a strict -80 dBFS sample-domain null; skipping it entirely gives a true bit-accurate passthrough at the default state (IR Blend defaults to 0%, i.e. IR A only, which is bit-identical to the pre-M1 single-IR path). Loading IR B applies inter-IR phase alignment (src/dsp/IrAlignment.{h,cpp}: onset detection via a relative-threshold crossing, then a time-domain shift) against IR A's most recently loaded onset, so blending the two never introduces comb-filtering from a timing mismatch. Both IR files' absolute paths are persisted as plain ValueTree properties on apvts.state (not APVTS float parameters), round-tripping automatically through copyState()/replaceState(); file I/O only ever happens off the audio thread (editor FileChooser callbacks or setStateInformation, both message-thread/session-load contexts). ## Build & test ```sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 8bbc640..b29ffdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,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(Nave VERSION 0.1.0 LANGUAGES C CXX) +project(Nave VERSION 0.2.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -52,6 +52,28 @@ juce_add_plugin(Nave NEEDS_MIDI_INPUT FALSE NEEDS_MIDI_OUTPUT FALSE IS_MIDI_EFFECT FALSE + 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). +# ============================================================================== + +juce_add_binary_data(NaveBinaryData SOURCES + presets/factory/default.json + presets/factory/tameTheFizz.json + presets/factory/liveStage.json + presets/factory/darkVintage.json + presets/factory/pushedBackInTheRoom.json + presets/factory/touchOfRoomMic.json + presets/factory/evenBlend.json + presets/factory/parallelCabBlendedDry.json + resources/i18n/de.txt ) # ============================================================================== @@ -87,9 +109,11 @@ target_link_libraries(SharedCode INTERFACE juce::juce_audio_formats 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 + NaveBinaryData ) target_link_libraries(Nave PRIVATE SharedCode) diff --git a/README.md b/README.md index 48f44a1..9c651bd 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Nave is a cabinet impulse-response (IR) loader built on JUCE 8, aimed at reampin - **IR loading, two independent slots** - load any WAV/AIFF impulse response into IR A and/or IR B via file choosers; loading happens off the audio thread and never blocks or allocates during playback - **IR Blend** - crossfades between IR A and IR B (e.g. two cabs, or two mic positions on the same cab), with automatic inter-IR phase alignment so blending never introduces comb-filtering from a timing mismatch between the two IRs - **Zero-latency convolution** - `juce::dsp::Convolution`'s zero-latency uniformly partitioned algorithm for both IR slots, so Nave never adds plugin delay compensation overhead -- **Distance** - simulated mic-to-cab distance (reduced proximity-effect bass + high-frequency air-absorption darkening as the value increases); an explicit "off" position at its default +- **Distance** - simulated mic-to-cab distance (a front-loaded proximity-effect bass cut plus high-frequency darkening - driven far more by loudspeaker directivity than literal air absorption at reamping distances - as the value increases); an explicit "off" position at its default +- **Presets** - 8 factory presets plus full user preset save/load/import/export (single files and zip banks), with German frame-string localisation - **LoCut** - post-convolution high-pass, 20 Hz - 800 Hz (default 20 Hz, an explicit "off"/bypassed position), removes low-end mud - **HiCut** - post-convolution low-pass, 2 kHz - 20 kHz (default 20 kHz, also an explicit "off" position), tames fizz - **Mix** - dry/wet, default 100% (fully wet) - a cabinet IR is normally run fully in the signal path @@ -58,7 +59,7 @@ Full musical context and usage tips: [`docs/manual.md`](docs/manual.md). |---|---|---| | M0 | Bootstrap - project skeleton, CI, docs | Done | | M1 | DSP completion & test coverage - IR Blend, Distance emulation, inter-IR phase alignment, broadened Catch2 suite | Done (IR browser + bundled IR library deferred - see issue tracker) | -| M2 | Presets & state recall - preset system, factory presets | Planned | +| M2 | Presets & state recall - preset system, factory presets, DE frame localisation | Done | | M3 | GUI & accessibility - custom LookAndFeel, accessibility pass | Planned | | M4 | Release - code signing, notarization, installers, v1.0.0 | Planned | diff --git a/docs/architecture.md b/docs/architecture.md index 94f6074..a353bb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ flowchart LR IN --> CONVB[Convolution IR B] CONVA --> BLEND[IR Blend
crossfade] CONVB --> BLEND - BLEND --> DIST[Distance
proximity + air-absorption shelves] + BLEND --> DIST[Distance
proximity + directivity-darkening shelves] DIST --> LOCUT[LoCut
HPF, 20-800 Hz] LOCUT --> HICUT[HiCut
LPF, 2-20 kHz] HICUT --> MIX[Dry/Wet Mix] @@ -25,10 +25,11 @@ Everything from the convolution through HiCut is the "wet" path, owned by `CabCo |---|---| | `src/dsp` | All audio-thread DSP: `CabConvolutionEngine` (two convolution slots + IR Blend crossfade, Distance shelving filters, LoCut/HiCut filters, dry/wet mix, output level) and `IrAlignment` (pure, off-audio-thread helper functions for inter-IR phase alignment). No allocation, locks, or file I/O once `prepare()` has run. Independent of `juce::AudioProcessor` so it is directly unit-testable (see `tests/EngineTests.cpp`, `tests/IrAlignmentTests.cpp`). | | `src/params` | Parameter layout and `AudioProcessorValueTreeState` definitions - parameter IDs, ranges, defaults. Single source of truth for what a preset captures (aside from the IR file paths, which are not APVTS parameters - see [IR file loading and state](#ir-file-loading-and-state)). | -| `src/PluginProcessor.*` | Host plumbing: APVTS construction, `prepareToPlay`/`processBlock`/`reset`, latency reporting, state save/load, and IR file I/O for both slots (`loadImpulseResponseFromFile[B]`/`loadDefaultImpulseResponse[B]`). Reads APVTS values and pushes them into `CabConvolutionEngine` every block; does not implement any DSP itself. | -| `src/PluginEditor.*` | A simple, functional v0.1 GUI: one rotary slider per parameter bound via `SliderAttachment`, plus "Load IR.../Default" and "Load IR B.../Default" button pairs and labels showing each loaded IR's file name. A custom vector-drawn GUI is a later milestone. | +| `src/presets` | The M2 suite-wide preset system (`.scaffold/specs/preset-system-m2.md`): `PresetManager` (factory/user preset discovery, load/save/import/export, dirty tracking, default resolution) and `PresetBar` (its editor strip), plus `Localisation` (the M2 i18n frame). Written with no Nave-specific coupling beyond a small config struct passed in from `PluginProcessor.cpp` - see `docs/preset-system-notes.md` for the sibling-plugin replication recipe. | +| `src/PluginProcessor.*` | Host plumbing: APVTS construction, `prepareToPlay`/`processBlock`/`reset`, latency reporting, state save/load, IR file I/O for both slots (`loadImpulseResponseFromFile[B]`/`loadDefaultImpulseResponse[B]`), and constructing/owning `presetManager`. Reads APVTS values and pushes them into `CabConvolutionEngine` every block; does not implement any DSP itself. | +| `src/PluginEditor.*` | A simple, functional v0.1/v0.2 GUI: a `PresetBar` strip docked at the top, one rotary slider per parameter bound via `SliderAttachment`, plus "Load IR.../Default" and "Load IR B.../Default" button pairs and labels showing each loaded IR's file name. A custom vector-drawn GUI is a later milestone. | -Dependency direction is one-way: `PluginEditor` -> `params` (via attachments) and `PluginProcessor` -> `params` + `dsp`. `src/params` depends on `src/dsp` only for its `CabConvolutionEngine::loCutMinHz`/`loCutMaxHz`/`hiCutMinHz`/`hiCutMaxHz`/`distanceMinPercent`/`distanceMaxPercent` range constants, so the parameter ranges and the engine's own bypass-threshold logic can never drift out of sync. `src/dsp` itself has no upward dependency on the processor, params, or UI, which is what keeps `CabConvolutionEngine` testable in isolation and free of any file-I/O concerns. +Dependency direction is one-way: `PluginEditor` -> `params` + `presets` (via attachments/PresetBar) and `PluginProcessor` -> `params` + `dsp` + `presets`. `src/params` depends on `src/dsp` only for its `CabConvolutionEngine::loCutMinHz`/`loCutMaxHz`/`hiCutMinHz`/`hiCutMaxHz`/`distanceMinPercent`/`distanceMaxPercent` range constants, so the parameter ranges and the engine's own bypass-threshold logic can never drift out of sync. `src/dsp` itself has no upward dependency on the processor, params, presets, or UI, which is what keeps `CabConvolutionEngine` testable in isolation and free of any file-I/O concerns. `src/presets` has no downward dependency on `src/dsp`/`src/params` either - it only knows about `juce::AudioProcessorValueTreeState`'s generic API, which is what makes it portable to sibling plugins. ## Filter bypass at the range extremes @@ -38,6 +39,8 @@ When a filter transitions from bypassed to engaged, `CabConvolutionEngine::proce IR Blend uses an analogous, but *value-driven rather than range-extreme*, optimisation: `convolutionB.process()` only runs when Blend is above a small epsilon above 0%, since IR A alone is the entire signal at Blend = 0%. Unlike LoCut/HiCut/Distance, Blend = 0% is not a special-cased "different code path" for correctness reasons - it falls out naturally from the crossfade math (`a * (1 - blend) + b * blend`) - skipping IR B's convolution there is purely a CPU optimisation, verified by `tests/EngineTests.cpp`'s "IR Blend at 0%" test. +**LoCut/HiCut ranges are a deliberate keep, not an oversight.** `docs/design-brief.md`'s v0.2.0 pass considered narrowing both ranges to match the closest structural reference plugin (Ignite Amps NadIR: 10-400 Hz HPF / 6-22 kHz LPF) and explicitly rejected it: Nave's wider LoCut ceiling (800 Hz) and lower HiCut floor (2 kHz) give headroom for deliberately extreme "telephone/lo-fi" tones, and narrowing would be a pure regression with no sourced justification for removing that headroom. `tests/EngineTests.cpp` has a named test asserting these four constants explicitly, as insurance against silent range drift now that the reasoning is documented here rather than only inside `CabConvolutionEngine.h`. + ## IR Blend and inter-IR phase alignment `CabConvolutionEngine` owns two independent `juce::dsp::Convolution` instances ("IR A" and "IR B"), each with its own default (zero-latency) configuration and its own default delta IR. `process()` always runs IR A; it additionally runs IR B (into a pre-allocated scratch buffer sized in `prepare()`, never resized on the audio thread) and crossfades the two per-block whenever the smoothed IR Blend value is above a small epsilon - see [Filter bypass at the range extremes](#filter-bypass-at-the-range-extremes) above. If a host ever sends a block larger than `prepare()` promised, the scratch buffer's capacity is checked before writing into it; if the block wouldn't fit, Blend is simply treated as disengaged for that one block (falling back to IR A only) rather than risking an out-of-bounds write. @@ -48,7 +51,11 @@ Two independently-captured impulse responses rarely share the same "time zero" - ## Distance emulation -The Distance parameter is a deliberately simplified, musically-motivated approximation of moving a mic further from a cabinet, not a physically exact model - it applies no timing/pre-delay change, only two shelving filters (`distanceLowShelfFilter`, `distanceHighShelfFilter`) whose gain scales linearly (in dB) with the normalised Distance value: a low-shelf cut around 200 Hz (reduced proximity-effect bass buildup as the simulated mic moves away) and a high-shelf cut around 5 kHz (high-frequency air absorption / off-axis darkening). Both are driven by a single parameter and share one bypass gate (see above), applied post-convolution/post-Blend and pre-LoCut/HiCut, so a user's own tone-shaping filters always act on top of whatever Distance coloration is dialled in, not the other way around. +The Distance parameter is a deliberately simplified, musically-motivated approximation of moving a mic further from a cabinet, not a physically exact model - it applies no timing/pre-delay change, only two shelving filters (`distanceLowShelfFilter`, `distanceHighShelfFilter`), applied post-convolution/post-Blend and pre-LoCut/HiCut so a user's own tone-shaping filters always act on top of whatever Distance coloration is dialled in, not the other way around: a low-shelf cut around 200 Hz (reduced proximity-effect bass buildup as the simulated mic moves away) and a high-shelf cut around 5 kHz (high-frequency darkening - see the "v0.2.0 Distance taper" note below on why this is framed as directivity-driven, not "air absorption"). Both are driven by a single parameter and share one bypass gate (see above). + +**v0.2.0 Distance taper** (`docs/design-brief.md`'s "Distance" module spec): the low-shelf's gain no longer scales linearly against the normalised Distance value - real proximity effect is front-loaded (research: "accelerates exponentially... then saturates" as a mic gets closer), so `CabConvolutionEngine.cpp`'s `tapered()` helper applies an "ease-out" power curve (`1 - (1 - normalisedDistance)^1.8`) that concentrates most of the audible cut into roughly the first third of the knob's travel and flattens out approaching 100%. The high-shelf deliberately keeps its plain-linear taper: Two Notes' own reference model attributes off-axis darkening to a *separate* control (their Center) from distance, so Nave's single-knob high-shelf is already a simplification of that other axis, not the front-loaded proximity effect - see `CabConvolutionEngine::distanceLowShelfTaperExponent`'s doc comment and `docs/design-brief.md`'s Honesty section for the full rationale and what this taper shape is/isn't calibrated against. This is a parameter-*behavior*-breaking change pre-1.0 (same Distance range/default, different curve) - see `CHANGELOG.md`. + +Also worth knowing when loading impulse responses: `juce::dsp::Convolution::Normalise::yes` (used for every user-loaded IR) is an *energy* normalisation, not a perceptual loudness match - two different real-world cab IRs can land at different post-load output levels purely because of how their energy is distributed, independent of anything Distance/LoCut/HiCut/Blend do. See `docs/manual.md`'s "Loading impulse responses" section for the user-facing version of this callout, and `tests/EngineTests.cpp`'s comment near its IR-loading tests for the sourced reference (JUCE forum, `normalizationFactor = 0.125f / sqrt(sumOfSquaredMagnitudes)`). ## Latency and the convolution engine diff --git a/docs/design-brief.md b/docs/design-brief.md new file mode 100644 index 0000000..eb2129d --- /dev/null +++ b/docs/design-brief.md @@ -0,0 +1,110 @@ +# Nave — Design Brief v2 (target: v0.2.0) + +Status: draft, for review. Supersedes the implicit v1 "brief" embedded in `docs/architecture.md`/`docs/manual.md`. Research basis: `nave-research-notes.md` (same directory) — manual/forum/standards-sourced, no hardware measured. See **Honesty** section before treating any number below as gospel. + +## Why v1 falls short of the reference class + +Nave v1 (M1, "DSP completion & test coverage") is an **engineered core**: the convolution/blend/filter/mix signal chain is architecturally sound, real-time-safe, and thoroughly null-tested. But its tonal-control defaults were derived from internal reasoning ("linearly scale a shelf in dB against a percentage knob," "pick round numbers for filter ranges") rather than from how the reference class — Two Notes Torpedo, Ignite Amps NadIR, Fractal Audio's Cab block, and the underlying microphone physics all three are modelling — actually behaves. Concretely: + +1. **Distance conflates two physically distinct effects into one linear taper.** The reference class (Two Notes) treats "how far back" and "how far off-axis" as two separate control axes because they *are* physically separate (proximity effect vs. loudspeaker directivity). Nave's single knob approximates both at once with a plain linear-in-dB shelf pair — defensible as a simplification, but the taper shape doesn't reflect that real proximity effect is front-loaded (most of the audible change happens early in the travel, then it saturates), not evenly spread across 0–100%. +2. **The "air absorption" framing is a mislabel.** At cab-to-mic reamping distances (well under Two Notes' own 3 m Distance ceiling), literal atmospheric absorption is close to inaudible; the real high-frequency loss Two Notes documents is loudspeaker directivity (off-axis rolloff), not air. Nave's docs/manual currently claim the wrong physical cause for a real, correctly-shaped effect. +3. **No factory presets exist**, and M1's own filter/blend ranges were never checked against a structural sibling plugin. NadIR (the closest existing plugin to what Nave already is) publishes HPF 10–400 Hz / LPF 6–22 kHz; Nave's 20–800 Hz / 2–20 kHz is wider at both open ends without that being a stated, deliberate choice anywhere in the docs. +4. **No user-facing acknowledgement of a real gotcha**: JUCE's `Convolution::Normalise::yes` is an energy-normalisation, not a loudness match — loading two different real-world cab IRs can land at different post-load output levels, and nothing in Nave's UI/manual currently prepares a user for that or points them at Level as the fix. +5. **IR Blend has no sourced target ratios.** The reference workflow (close mic + room mic) reaches for a small number of discrete, well-known ratios (10–25%, 50%, 75/25, etc.), not a uniform sweep — Nave's manual gestures at "10–25%" once but nothing else in the product reflects this. + +None of this is a DSP bug — the M1 engine (bypass-at-extremes, phase alignment, zero-latency convolution, real-time safety) holds up and is *independently validated* by the research (Fractal's own 20/20,000 "off" convention matches Nave's bypass defaults exactly). v2 is a **tonal-authenticity pass on the existing topology**, not a re-architecture. + +## Topology (unchanged from v1) + +``` +Input --> Convolution (crossfade IR A / IR B) --> Distance --> LoCut (HPF) --> HiCut (LPF) + | + Output <-- Level (output trim) <-- Mix <---------------+ + ^ + | + delay-compensated dry path +``` + +No new blocks, no new latency, no change to the convolution/phase-alignment/bypass-at-extremes architecture. Every change below is a parameter range, taper, or default revision inside the existing chain, plus new preset data (M2-facing) and doc/UI copy corrections — deliberately scoped to stay a fast-follow rather than a re-architecture, consistent with "breaking parameter changes allowed pre-1.0." + +## Module specs (sourced defaults) + +### Distance (mic-to-cab proximity + off-axis coloration) + +- **Range**: 0–100%, unchanged. +- **Default**: 0% (off), unchanged — independently correct; a cab IR loader's default state must be a true passthrough (already covered by the bypass-at-extremes test suite). +- **Taper — CHANGE**: replace the current plain-linear-in-% dB scaling with a **skewed taper** (e.g. `normalisedDistance^1.6`–`^2` applied before the existing linear-in-dB shelf-gain scaling, tuned by ear against the sourced curve shape, not derived analytically) so the low-shelf cut (proximity) is front-loaded — most of the audible change happens in the first third of the knob's travel, tapering off toward 100% — mirroring the documented "accelerates exponentially at close range, then saturates" physical curve (research notes §1). The high-shelf (directivity/darkening) side can keep a gentler, closer-to-linear taper since Two Notes explicitly separates "high end returns as you move back" (Distance) from "high end drops as you go off-axis" (their Center) — Nave's single knob is intentionally modelling something closer to the *darkening* half of that story for its high-shelf, so a more linear taper there is the honest simplification, not an oversight. +- **Corner frequencies — unchanged**: low-shelf ~200 Hz (inside the sourced 200–300 Hz proximity-effect range), high-shelf ~5 kHz (inside the sourced "air absorption/directivity becomes audible above ~1 kHz, increasingly so higher" range). +- **Max cut magnitude**: keep existing max-cut dB constants unless listening confirms the new taper needs rebalancing to avoid over-darkening at 100% — this is a taper-shape change, not a range change, so the existing `distanceLowShelfMaxCutDb`/`distanceHighShelfMaxCutDb` ceiling values are a reasonable starting point to re-audition, not to blindly keep. +- **Naming**: keep "Distance" (generic, already correct — no brand names anywhere in v1 or this brief). + +### LoCut (post-convolution high-pass) + +- **Range — CHANGE**: keep 20–800 Hz. *(Considered narrowing to NadIR's 400 Hz ceiling; rejected — Nave's wider ceiling gives headroom for deliberately extreme "telephone/lo-fi" tones some engineers do reach for, and narrowing is a pure regression with no sourced justification for removing headroom. Documented here as a deliberate keep, closing the "unexamined" gap from the research notes.)* +- **Default**: 20 Hz (off), unchanged — matches Fractal's own 20 Hz "off" convention exactly (sourced, §4). +- **Taper**: unchanged (existing log-frequency `NormalisableRange`, correct for a frequency control). + +### HiCut (post-convolution low-pass) + +- **Range**: keep 2 kHz – 20 kHz, same reasoning as LoCut (deliberate keep, wider floor than NadIR's 6 kHz for extreme dark/vintage tones referenced in Fractal community lore, §4). +- **Default**: 20 kHz (off), unchanged — matches Fractal's 20,000 Hz "off" convention exactly. +- **Taper**: unchanged. + +### IR Blend + +- **Range/default**: unchanged (0–100%, default 0%). +- **CHANGE — no new parameter, but new preset-facing guidance**: expose the sourced discrete blend ratios (10%, 25%, 50%, 75%) as the values factory presets actually land on, rather than presets landing on arbitrary numbers. No DSP or range change; this is purely a defaults-for-presets decision informed by §6. + +### Mix / Level + +- Unchanged. Both are already at industry-standard defaults (100% wet — cab IR is normally fully in-chain; 0 dB trim) with no sourced reference class disagreement found. + +### New: manual/UI copy corrections (not a parameter change, but part of "authentically voiced") + +- Replace "air-absorption darkening" framing in README/manual/architecture.md with a directivity-first explanation ("as a mic moves off-axis and further back, a real cabinet's high end rolls off — this models that darkening; it's driven far more by speaker directivity than by literal air absorption at these distances"). +- Add one sentence to the manual's IR-loading section noting that two different loaded IRs can land at different output levels because loading normalises each IR's *energy*, not its perceived loudness — pointing at Level as the fix. Sourced from the JUCE-forum-documented `0.125f / sqrt(energy)` normalisation behaviour (§5) — no DSP change, just setting correct user expectations. + +## Factory Presets (proposed set for the M2 preset system, 8 presets) + +All values below are **starting points to audition against the new Distance taper**, not final locked numbers — treat as the brief's proposal, confirm by ear once the taper change lands. + +1. **Default / Transparent** — intent: the certified passthrough state, exposed as an explicit preset so users always have a one-click way back to "no coloration." LoCut 20 Hz (off), HiCut 20 kHz (off), Distance 0%, Blend 0%, Mix 100%, Level 0 dB. +2. **Tame the Fizz** — intent: general-purpose high-gain cleanup, sourced from Fractal community consensus (§4). LoCut ~100 Hz, HiCut ~5 kHz, Distance 0%, Blend 0%, Mix 100%, Level 0 dB. +3. **Live Stage** — intent: tighter, more aggressive cut for a live monitoring/tracking chain where mud and fizz both cost headroom, sourced from the "80/8000 for live" community datapoint (§4). LoCut ~80 Hz, HiCut ~8 kHz, Distance 0%, Blend 0%, Mix 100%, Level 0 dB. +4. **Dark Vintage** — intent: darker, narrower-band tone for a vintage/lo-fi cab character, sourced from the "180 Hz/4.5 kHz" community datapoint (§4). LoCut ~180 Hz, HiCut ~4.5 kHz, Distance ~25% (light proximity push), Blend 0%, Mix 100%, Level 0 dB. +5. **Pushed Back in the Room** — intent: showcase Distance alone as the sourced "finishing touch" it's documented to be. LoCut 20 Hz (off), HiCut 20 kHz (off), Distance ~60%, Blend 0%, Mix 100%, Level +1 to +2 dB (to compensate for shelving cuts, per the manual's own "check Level" tip). +6. **Touch of Room Mic** — intent: showcase IR Blend at the sourced low-ratio end (§6) — requires IR B loaded by the user; preset only sets the knob. LoCut 20 Hz (off), HiCut 20 kHz (off), Distance 0%, Blend 15%, Mix 100%, Level 0 dB. +7. **Even Blend** — intent: the sourced 50/50 discrete stopping point (§6) for two genuinely complementary IRs (two cabs, or close+room). Blend 50%, everything else at defaults. +8. **Parallel Cab (Blended Dry)** — intent: showcase Mix as a genuine parallel-processing tool (not just "on/off wet"), pairing a moderate Distance push with a partial Mix for a thickened, less "all-or-nothing" cab tone. LoCut 20 Hz (off), HiCut 20 kHz (off), Distance ~20%, Blend 0%, Mix ~65%, Level +1 dB. + +Presets 1–4 are single-IR-slot-safe (never require IR B); 6–7 are explicitly Blend-focused and should carry UI copy noting they need an IR loaded into slot B to be audible (mirroring the manual's own existing warning that Blend "has no audible effect unless an IR is loaded into slot B"). + +## Catch2 test guarantees (additions for v2) + +Existing suite (50 tests, `tests/*.cpp`) already covers: bypass-at-extremes null tests, IR Blend crossfade math (including the "must convolve the same dry input, not cascade" ordering), inter-IR phase alignment, latency reporting, state round-trip, NaN/Inf robustness. v2 adds: + +- **Distance taper monotonicity + shape**: for a swept set of Distance values (e.g. 0/10/25/50/75/100%), assert the low-shelf cut magnitude is (a) monotonically non-decreasing, (b) covers *more* of its total cut range in the first half of the sweep than the second half (a measurable proxy for "front-loaded" — e.g. `cutAt50Percent - cutAt0Percent > cutAt100Percent - cutAt50Percent`), directly encoding the "front-loaded, not linear" requirement from the brief rather than just re-testing the existing linear behaviour. +- **Distance taper regression guard**: a fixed-point snapshot test (Distance at 25/50/75/100%, tolerance-banded expected shelf gains in dB) so a future accidental taper-curve regression fails loudly, the same pattern `EngineTests.cpp` already uses for bypass epsilon checks. +- **LoCut/HiCut range-unchanged guard**: assert `loCutMinHz == 20`, `loCutMaxHz == 800`, `hiCutMinHz == 2000`, `hiCutMaxHz == 20000` as an explicit, named test (`"LoCut/HiCut ranges match the v2 brief's deliberate-keep decision"`) — cheap insurance against silent range drift now that the brief has explicitly documented *why* these differ from the NadIR reference rather than leaving that reasoning only in a markdown file nobody re-reads before touching `CabConvolutionEngine.h`'s constants. +- **Preset-value smoke test** (once M2's preset system lands): for each of the 8 proposed presets, assert `process()` produces finite, non-NaN, non-clipping (peak below some sane ceiling, e.g. +6 dBFS on a known test signal) output — a coverage-style guard, not a golden-audio comparison. +- **Normalisation-awareness regression note**: not a new automated test (there's no way to unit-test "is this perceptually loud-matched" without reference audio), but add a comment in `EngineTests.cpp` near the IR-loading tests cross-referencing the manual's new normalisation callout, so a future contributor investigating a "why do these two IRs sound different loudness" bug report finds the documented, sourced explanation instead of re-discovering it. + +## Honesty + +Every number and curve-shape decision in this brief is **research-derived from manuals, forum measurements, physics standards summaries, and one other plugin's published spec sheet — not from measuring real hardware, real cabinet IRs, or real Two Notes/Fractal/NadIR units.** Specifically: + +- The Two Notes Distance/Center split, its 3 m/1 m ranges, and its qualitative behaviour description come from Two Notes' own manual text (paraphrased/quoted), not from operating a physical Torpedo unit or capturing IRs at varying documented mic distances and comparing curves. +- The proximity-effect corner frequency and magnitude figures (200–300 Hz, +6 to +20 dB) are **generic microphone-physics figures** from audio-engineering reference sites, not measurements of any specific mic/cab pairing Nave's cabinet IRs will actually be loaded with — real proximity effect varies significantly by microphone polar pattern and model, which Nave (correctly, per its own architecture docs) does not attempt to model per-mic. +- The proposed "front-loaded" Distance taper exponent (`^1.6`–`^2`) is a **reasoned approximation of a qualitative physical description** ("accelerates exponentially... then saturates"), not a curve fit to measured data — it should be tuned by ear during implementation, and the Catch2 "shape" test above is deliberately a *coarse* monotonicity/front-loading check, not a tolerance-banded match to a specific published curve, precisely because no such curve was sourced. +- The "air absorption is a mislabel, directivity is the real cause" claim is inferred by cross-referencing two separate sources (ISO 9613's frequency threshold + Two Notes' own explicit attribution to loudspeaker directivity), not stated together in any single primary source. +- The LoCut/HiCut community-lore preset frequencies (100/5k, 80/8k, 180/4.5k) are paraphrased from Fractal Audio forum threads — user opinion and anecdote, explicitly not a spec, and explicitly acknowledged in those same threads as a matter of taste rather than correctness. +- JUCE's `Normalise::yes` normalisation-factor formula is sourced from a JUCE forum post by a community member (not Anthropic-verified against JUCE 8.0.14 source directly in this pass) — worth a source-code cross-check (`juce_Convolution.cpp`) before the manual copy ships, flagged here rather than silently assumed correct. + +**What this licenses**: confidently revising Nave's *taper shapes*, *default philosophy documentation*, *preset starting points*, and *doc/manual wording* to be more defensible and better-sourced than v1's internally-reasoned numbers. **What it does not license**: claiming Nave now "sounds like" any specific reference hardware, or treating the taper exponent / preset frequencies as precision-calibrated rather than reasoned-and-auditioned starting points. Any marketing copy building on this brief must not claim hardware-matched accuracy — this is engineering-documentation-derived plausibility, not measurement. + +## Versioning + +- **Target**: v0.2.0 (SemVer, pre-1.0 — breaking parameter changes are explicitly allowed per the project's own roadmap/status). +- **Breaking changes in this brief**: the Distance taper shape change is parameter-*behavior*-breaking (same range/default, different curve) — a saved session with Distance at, say, 60% will sound different after this ships. This is acceptable pre-1.0 per project convention, but should be called out plainly in `CHANGELOG.md` under a "Changed" heading, not buried. +- **State migration**: tolerant import — `AudioProcessorValueTreeState`'s existing round-trip (including the non-APVTS `irFilePathProperty`/`irFilePathBProperty` tree properties) already handles missing/extra parameters gracefully; no new migration code needed since no parameter IDs, ranges, or defaults change — only the internal taper curve applied to the existing Distance value changes, which is inherently forward/backward compatible with old saved states (a stored Distance value of 60.0 remains meaningful, just mapped through a new curve). +- **No M2/M3 scope creep**: this brief does not add GUI, does not add a preset *system* (that's M2's own scope) — it only supplies the *content* (8 preset value sets) M2's preset system will need once it exists, and the parameter/taper/doc changes M2 doesn't block on. diff --git a/docs/manual.md b/docs/manual.md index d558f0f..53d64d7 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -46,6 +46,8 @@ Both slots' file paths are saved with your session/preset, so a project reopens ### IR Blend +Two different loaded IRs can end up sounding noticeably different in level even at identical Distance/LoCut/HiCut/Mix settings — Nave normalises each loaded IR's *energy* to a consistent reference (not its perceived loudness), and real-world cab IRs vary enough in length/spectral content that the same energy target can still land at different subjective volumes. This isn't a bug to work around with EQ — reach for **Level** to match gain staging after swapping IRs. + The **IR Blend** knob crossfades between IR A (0%) and IR B (100%). Typical uses: - **Two different cabs** — blend a tight 4x12 with a boomier 2x12 to taste, without needing a separate blending plugin. @@ -57,7 +59,7 @@ Blend defaults to 0% (IR A only) — loading an IR B and leaving Blend at 0% has ### Distance (simulated mic distance) -The **Distance** knob is a simplified emulation of moving the mic further from the cab: at higher settings it gently reduces low-end proximity buildup and dulls the top end slightly (simulating high-frequency air absorption and off-axis darkening). It is *not* a physically exact distance model — no pre-delay/timing change is applied — just a musically useful tonal shift for pushing a too-close/too-bright IR back in the mix, without reaching for a separate EQ. +The **Distance** knob is a simplified emulation of moving the mic further from the cab: at higher settings it reduces low-end proximity buildup and dulls the top end slightly. The top-end darkening is modelled as a real cabinet's high end rolling off as a mic moves further back and off-axis — that's driven far more by loudspeaker directivity than by literal air absorption at typical reamping distances, so don't read it as "the air between the mic and the cab" so much as "how the speaker itself radiates less high end off to the side." It is *not* a physically exact distance model — no pre-delay/timing change is applied — just a musically useful tonal shift for pushing a too-close/too-bright IR back in the mix, without reaching for a separate EQ. The low end responds faster near the start of the knob's travel and tapers off toward 100%, mirroring how real proximity effect behaves — most of the change happens early, not spread evenly across the full sweep. Distance defaults to 0% ("off" — no coloration applied at all, a true passthrough at this stage of the chain). @@ -72,6 +74,10 @@ Distance defaults to 0% ("off" — no coloration applied at all, a true passthro | **Mix** | 0 – 100 | 100 (fully wet) | % | Dry/wet blend of the fully-processed signal against your original input. Lower it for a parallel/blended cab tone, or to taste-test how much of the IR's character you actually want. | | **Level** | -24 – +24 | 0 | dB | Output trim, applied last. Use it to match gain staging after swapping IRs or dialling in Mix/Blend/Distance, all of which can shift the overall level. | +## Presets + +A preset bar sits at the top of Nave's editor: `[<] [PresetName] [>] [Save] [Save As...] [Delete] [Import...] [Export...]`. Click the preset name to open the full list (factory presets first, then your own, both alphabetical); `<`/`>` step through the same list. Eight factory presets ship with Nave — see [`docs/presets.md`](presets.md) for what each one is for. Your own presets save to `~/Library/Audio/Presets/Yves Vogl/Nave/` on macOS (`%APPDATA%\Yves Vogl\Nave\Presets\` on Windows); "Set current as default" (in the preset menu) controls what a freshly inserted instance of Nave loads. Import/Export both accept single preset files; Import also accepts a `.zip` preset bank exported by `PresetManager::exportBank()`. + ## Latency Nave uses JUCE's zero-latency convolution algorithm by design — cab IRs used for reamping are short, and reamping/tracking workflows are latency-sensitive, so Nave never reports plugin delay compensation to the host. This holds regardless of how many of the above features (IR Blend, Distance, LoCut/HiCut) are engaged. diff --git a/docs/preset-system-notes.md b/docs/preset-system-notes.md new file mode 100644 index 0000000..0288ffc --- /dev/null +++ b/docs/preset-system-notes.md @@ -0,0 +1,143 @@ +# M2 preset system - implementation notes and sibling replication recipe + +Nave is the **pilot** implementation of the Basilica Audio suite-wide M2 +preset system (binding spec: `.scaffold/specs/preset-system-m2.md`). This +document is the replication recipe for the other 11 plugins: what to copy +verbatim, what small per-plugin glue to write, and the CMake/BinaryData +wiring it needs. + +## Files and their portability + +| File | Portability | +|---|---| +| `src/presets/PresetManager.h` / `.cpp` | **Copy verbatim.** Zero Nave-specific code - all plugin-specific data flows in through the constructor's `PresetManagerConfig` + `std::vector` arguments. | +| `src/presets/PresetBar.h` / `.cpp` | **Copy verbatim.** Only depends on `PresetManager`'s public API. | +| `src/presets/Localisation.h` / `.cpp` | **Copy verbatim.** Takes the German `BinaryData::` symbol pointer/size as arguments rather than including `BinaryData.h` itself. | +| `resources/i18n/de.txt` | **Copy verbatim** if the frame strings are identical (they should be - PresetBar's button/menu/dialog text never changes across plugins). If a sibling's PresetBar ever needs additional TRANS()'d strings beyond what Nave's PresetBar uses, add the extra key/value pairs here too. | +| `presets/factory/*.json` | **Do not copy.** Each plugin's factory preset *content* is designed per plugin by its own implementation agent (spec: "factory preset CONTENT is designed per plugin by its implementation agent") - only the JSON *format* (`"basilica-preset-1"`) is shared. | +| `PluginProcessor.cpp`'s `makePresetManagerConfig()`/`makeFactoryPresetAssets()` helpers | **Rewrite per plugin.** This is the "small config surface" - see below. | +| `PluginProcessor.h`'s `presetManager` member + `PluginProcessor.cpp`'s constructor wiring | **Adapt per plugin** (mechanical - same pattern, different processor class name). | +| `PluginEditor.h`/`.cpp`'s `presetBar` member + layout | **Adapt per plugin** (mechanical - same pattern, different editor class/layout). | +| `docs/presets.md` | **Do not copy.** Per-plugin factory preset documentation. | + +## The per-plugin config surface + +Everything PresetManager needs beyond generic file-format/dirty-tracking/ +ordering logic is exactly these four fields (`PresetManagerConfig` in +`PresetManager.h`): + +```cpp +struct PresetManagerConfig +{ + juce::String pluginId; // e.g. "com.yvesvogl.overture" - must match BUNDLE_ID + juce::String pluginName; // e.g. "Overture" - JucePlugin_Name + juce::String manufacturerName; // "Yves Vogl" for every suite plugin + juce::String pluginVersion; // JucePlugin_VersionString + juce::File userPresetsDirectoryOverrideForTests; // leave default-constructed in production +}; +``` + +`pluginId` comes from `JUCE_STRINGIFY (JucePlugin_CFBundleIdentifier)` (a +JUCE-generated macro that expands to a raw, unquoted token sequence - +`JUCE_STRINGIFY()` is required to turn it into a string literal; see +`PluginProcessor.cpp`'s `makePresetManagerConfig()` for the exact pattern and +its comment on why the naive `JucePlugin_CFBundleIdentifier` alone would not +compile). + +`FactoryPresetAsset` (also in `PresetManager.h`) is just a `{ const char* +data; int dataSize; }` pair - built from each plugin's own +`BinaryData::_json` / `BinaryData::_jsonSize` symbols in that +plugin's own `makeFactoryPresetAssets()` helper. + +## CMake / BinaryData wiring checklist + +1. Add `ICON_BIG "docs/assets/icon.png"` to the plugin's own `juce_add_plugin(...)` call (unrelated to presets, but bundled in the same v0.2.0 wave - see the top-level `juce_add_plugin` call in `CMakeLists.txt`). +2. Author `presets/factory/*.json` for the plugin (6-10 presets per spec, one `Init`-category `Default`, English names, no brand names). +3. Author (or copy) `resources/i18n/de.txt`. +4. Add a `juce_add_binary_data(BinaryData SOURCES ...)` call listing every `presets/factory/*.json` file plus `resources/i18n/de.txt` (see `CMakeLists.txt`'s block right after `juce_add_plugin`). BinaryData symbol names are derived from file names with `.`/other invalid characters replaced by `_` (e.g. `default.json` -> `BinaryData::default_json`/`BinaryData::default_jsonSize`) - **avoid leading digits in preset file names**, since a leading digit would produce an invalid C++ identifier. +5. Link `BinaryData` and `juce::juce_gui_basics` into `SharedCode` (PresetBar needs `juce_gui_basics` for `AlertWindow`/`FileChooser`/`PopupMenu`; most plugin editors already pull it in via `juce_audio_utils`, but linking it explicitly is what Nave now does and costs nothing extra). +6. `#include ` in `PluginProcessor.cpp` (factory assets) and `PluginEditor.cpp` (the `de.txt` symbols for `installLocalisation()`). + +## Processor/editor wiring checklist + +**`PluginProcessor.h`**: add `#include "presets/PresetManager.h"` and a +public `basilica::presets::PresetManager presetManager;` member, declared +**after** `apvts` (construction order follows declaration order - see the +comment on this member in Nave's `PluginProcessor.h`). + +**`PluginProcessor.cpp`**: add `makePresetManagerConfig()`/ +`makeFactoryPresetAssets()` helpers (anonymous namespace), construct +`presetManager` in the constructor's initialiser list right after `apvts`, +and call `presetManager.applyStartupDefault();` in the constructor body +(after the parameter-pointer `jassert`s, before the closing brace) so a +fresh instance loads the user-or-factory `Default` preset per the spec's +default-resolution order. + +**`PluginEditor.h`**: add `#include "presets/PresetBar.h"` and a +`basilica::presets::PresetBar presetBar;` member. + +**`PluginEditor.cpp`**: the trickiest part is **initialisation order**. +`installLocalisation()` must run *before* `presetBar`'s own constructor +calls `TRANS()` on its button labels - and since C++ initialises members in +declaration order regardless of the order they're written in the +initialiser list, a plain `installLocalisation()` call in the constructor +*body* runs too late (after `presetBar` already exists). Nave's +`PluginEditor.cpp` solves this with a small helper function called from +`presetBar`'s own initialiser expression: + +```cpp +namespace +{ + basilica::presets::PresetManager& initLocalisationThenGetPresetManager (NaveAudioProcessor& processor) + { + basilica::presets::installLocalisation (BinaryData::de_txt, BinaryData::de_txtSize); + return processor.presetManager; + } +} + +NaveAudioProcessorEditor::NaveAudioProcessorEditor (NaveAudioProcessor& processorToEdit) + : juce::AudioProcessorEditor (&processorToEdit), + audioProcessor (processorToEdit), + presetBar (initLocalisationThenGetPresetManager (processorToEdit)) +{ + addAndMakeVisible (presetBar); + // ... rest of the editor's existing setup, shifted down to make room + // for presetBar at the top of resized()'s layout ... +} +``` + +Copy this pattern (renaming the processor/editor class names) rather than +re-deriving it - the ordering bug it avoids is easy to reintroduce +otherwise. + +Finally, in `resized()`, reserve a row at the very top of the editor for +`presetBar.setBounds(...)` before laying out the plugin's existing controls. + +## Real-time safety (for the doc-discipline record) + +`PresetManager`'s only audio-thread-adjacent code is its private +`AudioProcessorValueTreeState::Listener::parameterChanged()` override, used +for dirty-flag tracking. JUCE 8.0.14 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. Every other `PresetManager`/ +`PresetBar` method does file I/O, JSON parsing, and `juce::String`/`juce::var` +allocation, and is called only from the message thread (constructor, +`PresetBar` button/menu/dialog callbacks) - never from `processBlock()`. + +## Test isolation + +`PresetManagerConfig::userPresetsDirectoryOverrideForTests` exists purely so +`tests/PresetManagerTests.cpp` never reads or writes the real per-user +preset directory on the machine running the tests. Production code (the +`PluginProcessor.cpp` helpers above) always leaves this field +default-constructed (empty), so real plugin instances use the genuine +platform-standard location +(`~/Library/Audio/Presets/Yves Vogl//` on macOS, +`%APPDATA%/Yves Vogl//Presets/` on Windows). Copy this pattern into +every sibling's own preset tests. + +## What Nave's v0.2.0 pass did *not* build + +- No bespoke preset-browser UI beyond the plain `PresetBar` strip - matches the spec's explicit "M3 restyles it, do not gold-plate" instruction. +- `PresetBar` only exposes single-preset export via its "Export..." button; `PresetManager::exportBank()`/`importBank()` (zip banks) are fully implemented and tested at the `PresetManager` layer (`tests/PresetManagerTests.cpp`), but bank export isn't yet wired to a dedicated UI affordance - `promptAndImport()` does auto-detect and import `.zip` banks, so bank *import* is reachable through the existing "Import..." button today. A future M3 pass can add an explicit "Export Bank..." menu item without any `PresetManager`-level changes. diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000..a062ccc --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,24 @@ +# Factory presets + +Eight factory presets ship with Nave v0.2.0, embedded via BinaryData from +`presets/factory/*.json` (see `docs/preset-system-notes.md` for the build +wiring). All are sourced starting points from `docs/design-brief.md`'s +"Factory Presets" section - see that document's own Honesty section for what +these numbers are and aren't calibrated against (research/forum/manual- +derived, not measured hardware). + +| Preset | Category | Intent | +|---|---|---| +| **Default** | Init | The certified passthrough state (all parameters at their off/default position), exposed as an explicit preset so there's always a one-click way back to "no coloration." Also this plugin's out-of-the-box default (see the M2 default-resolution order in `docs/preset-system-notes.md`). | +| **Tame the Fizz** | Guitar | General-purpose high-gain cleanup (LoCut ~100 Hz / HiCut ~5 kHz), sourced from Fractal Audio community consensus. | +| **Live Stage** | Guitar | Tighter, more aggressive cut (LoCut ~80 Hz / HiCut ~8 kHz) for a live monitoring/tracking chain where mud and fizz both cost headroom. | +| **Dark Vintage** | Guitar | Darker, narrower-band vintage/lo-fi cab character (LoCut ~180 Hz / HiCut ~4.5 kHz) plus a light Distance push (~25%) for extra proximity darkening. | +| **Pushed Back in the Room** | Guitar | Showcases Distance alone (~60%) as the sourced "finishing touch" it's documented to be, with a small Level compensation for the resulting shelving cuts. | +| **Touch of Room Mic** | Guitar | Showcases IR Blend at the sourced low-ratio end (15%) for "a touch of a second mic" - requires an IR loaded into slot B to be audible. | +| **Even Blend** | Guitar | The sourced 50/50 discrete stopping point for two genuinely complementary IRs (two cabs, or close+room) - requires an IR loaded into slot B to be audible. | +| **Parallel Cab (Blended Dry)** | Guitar | Showcases Mix as a genuine parallel-processing tool: a moderate Distance push (~20%) blended with a partial Mix (~65%) for a thickened, less "all-or-nothing" cab tone. | + +None of the presets reference specific IR files - loading an IR into slot A/B +is always a separate, explicit user action (see `docs/manual.md`'s +"Loading impulse responses" section). "Touch of Room Mic" and "Even Blend" +only become audible once something real is loaded into slot B. diff --git a/docs/research-notes.md b/docs/research-notes.md new file mode 100644 index 0000000..c0c3ab3 --- /dev/null +++ b/docs/research-notes.md @@ -0,0 +1,104 @@ +# Nave — Deep-Dive Research Notes (v1 → v2) + +Reference class for "guitar/bass cabinet IR loader (convolution)": **Two Notes Torpedo (Wall of Sound / C.A.B. / Torpedo Live)** — the closest thing this category has to a hardware/software de-facto standard, especially for mic-position/distance modelling; **Ignite Amps NadIR** — the free/open reference for a *pure convolution loader with tone-shaping filters*, structurally the closest existing plugin to what Nave already is; **Fractal Audio Cab block / Cab-Lab 4** — the reference for community-standard LoCut/HiCut conventions on hardware-class modelers; general **microphone-technique / reamping-engineer lore** (proximity effect physics, multi-mic blending workflow) as the grounding for what "Distance" and "IR Blend" are actually standing in for. + +No hardware was measured for this pass — everything below is manual/doc/forum-sourced. See the Honesty section of the brief for what that does and doesn't license. + +--- + +## 1. Distance / mic-proximity modelling + +### Two Notes Torpedo Wall of Sound — Distance & Center are TWO separate parameters, not one + +Source: [Torpedo Wall of Sound User's Manual](https://wiki.two-notes.com/doku.php?id=torpedo_wall_of_sound%3Atorpedo_wall_of_sound_user_s_manual) + +> "Placing a microphone close to the cabinet will result in a precise sound with a large amount of proximity effect (depending on the chosen microphone model). When you move the microphone away from the cabinet, you increase the proportion of the studio's acoustics (early reflections) in the overall sound texture." + +> "Moving the microphone away can bring some higher frequencies back. This is simply due to the directivity of the loudspeakers." + +> Distance range: at maximum (100%) the simulated mic sits **~3 m (10 ft)** from the cabinet. + +Two Notes' **Center** parameter (separate control) governs off-axis (lateral) placement: + +> "The in-axis position (0%) allows for a maximum amount of treble sounds, which are highly directional. Moving the microphone away from the axis decreases the treble to the benefit of the bass response." Range: at 100%, mic is at the speaker edge (close) or 1 m off-axis (far). + +**Gap vs. v1**: Two Notes' reference model treats "moving back" (Distance) and "moving off-axis" (Center) as two independent, physically distinct axes — distance changes proximity bass + brings some highs *back* via directivity/room reflections; off-axis position is what actually darkens the top end. Nave's single Distance knob conflates both effects into one linear-in-dB shelf pair. This is a legitimate, documented simplification (Nave's own architecture.md already says so explicitly), not an error — but the *curve shape* should still take a cue from the reference: real proximity effect is not linear in dB per % of knob travel (see §2), and it should not be "distance also darkens highs" without qualification — the reference model attributes brightening/darkening more to axis than distance. + +### Proximity effect physics — corner frequency and magnitude + +Source: [DPA Microphones — proximity effect explained](https://www.dpamicrophones.com/mic-university/background-knowledge/proximity-effect-in-microphones-explained/); [MyNewMicrophone — in-depth guide](https://mynewmicrophone.com/proximity-effect/); [Sweetwater InSync](https://www.sweetwater.com/insync/proximity-effect/) + +- Boost concentrates **below ~200–300 Hz**, in some sources up to 500 Hz depending on mic. +- Magnitude: commonly cited **+6 dB to +20 dB** low-frequency boost at very close range (some sources cite up to +18 dB); this is directional-mic (cardioid/figure-8) physics, strongest for pressure-gradient designs. +- Distance relationship is **not linear** — the effect "accelerates exponentially once a source drops below thirty centimetres," and inverse-square amplitude falloff (quartering per doubling of distance) means the boost decays quickly with distance rather than ramping down evenly across a knob's travel. + +**Implication for v2**: the reference physics is front-loaded (most of the effect happens in the first ~20–30% of "closeness"), which argues for the Distance parameter's low-shelf cut to use a *skewed/logarithmic* rather than pure-linear-in-dB taper against knob percentage — most of the perceptible bass-cut change should happen in the lower portion of the travel, tapering off at the far end, mirroring how real proximity effect saturates rather than growing forever. + +## 2. Air absorption / high-frequency darkening over distance + +Source: [ISO 9613-1 overview](https://www.iso.org/standard/17426.html), summarised via search (standard itself is not freely readable in full) + +- Atmospheric absorption is a function of frequency, temperature, humidity, and pressure; it is **negligible below ~1 kHz and increases with frequency above that**, growing "considerably" as frequency rises. +- This confirms the general shape Nave already uses (a high-shelf *cut* that grows with distance) is directionally correct, but the standard's own coefficients only start mattering above ~1 kHz — Nave's high-shelf corner at 5 kHz is squarely in the range where real air absorption is active, which is a reasonable choice, not something to change. +- Note: at guitar-cab reamping distances (a few metres, not the tens-to-hundreds of metres ISO 9613 was written for), air absorption itself is nearly inaudible — the real-world "darkening as mic moves back" documented by Two Notes above is attributed to loudspeaker *directivity* (off-axis high-frequency rolloff), not atmospheric absorption. This matters for the *honesty section*: Nave's "air-absorption" framing in its own docs is a simplification/mislabel worth softening — the audible effect being modelled is much more speaker-directivity-driven than literal air absorption at these distances. + +## 3. Reference plugin: Ignite Amps NadIR (closest structural sibling to Nave) + +Sources: [NadIR v2.0.0 manual, via pdfcoffee mirror](https://pdfcoffee.com/nadir-v200-user-manual-pdf-free.html); [Bedroom Producers Blog write-up](https://bedroomproducersblog.com/2013/11/23/ignite-amps-nadir/); [Audio Plugin Guy IR loader roundup](https://www.audiopluginguy.com/apg-guide-to-guitar-cabinet-ir-loaders/) + +- **HPF range: 10 Hz – 400 Hz.** (Nave's LoCut: 20–800 Hz — wider ceiling than the reference; NadIR's 400 Hz ceiling reflects that cab-IR low cut is normally a mud-control move, not a deep tonal EQ move.) +- **LPF range: 6 kHz – 22 kHz.** (Nave's HiCut: 2–20 kHz — Nave's floor of 2 kHz goes considerably lower than NadIR's 6 kHz floor; useful headroom for extreme "vintage/muffled" tones, see Fractal community lore below, but worth flagging as a deliberately wider-than-reference range in the honesty section.) +- **Resonance control**: simulates "the power amp + speaker interaction in tube amplifiers," a boost at the cab's resonant frequency; high values suit solid-state-amp IRs, lower for tone-shaping. No exact dB/Hz spec published. (Not adopted for Nave v2 — flagged as a possible M-later feature, out of scope for this brief; a resonance peak needs a dedicated parametric band, which is a bigger addition than the "sourced defaults" scope of this pass.) +- **Delay control**: 0 (default) to 20 ms, "to emulate phase interactions between multiple microphones at different distances," also usable for stereo widening. This is the direct structural analogue of what Nave's own inter-IR phase alignment (`IrAlignment::alignOnsetToReference`) automates — NadIR exposes it as a manual knob, Nave automates it. This is a legitimate, defensible design divergence (Nave's docs already justify it), not a gap. +- **Balance control**: crossfades between two loaded IRs — direct analogue of Nave's IR Blend. +- **Room control**: adds a short reverb tail "to restore room character lost when IRs are truncated" — a feature Nave does not have. Out of scope for this brief (would require a convolution/reverb addition, not a parameter-tuning pass). +- Default gain 0 dB, default delay 0 ms — i.e. NadIR's own "off" defaults mirror Nave's own default-is-transparent-passthrough philosophy. + +## 4. Community-standard LoCut/HiCut conventions (Fractal Audio Cab block) + +Source: [Fractal Audio Systems Forum — multiple threads](https://forum.fractalaudio.com/threads/thoughts-on-high-and-low-cuts-and-default-values.219736/), [Cab block wiki](https://wiki.fractalaudio.com/wiki/index.php?title=Cab_block) + +> "By default the high/low cuts in the cab mixer page are 20/20,000 which means they aren't really doing anything" — **this exactly matches Nave's own bypass-at-extremes convention** (LoCut default 20 Hz = off, HiCut default 20 kHz = off). Independent validation that v1's default philosophy is industry-standard, not idiosyncratic. + +> Community-recommended "tame the fizz" starting points, paraphrased from multiple threads: **~100 Hz low cut / ~5 kHz high cut** for general cleanup; **80 Hz / 8 kHz** favoured live; some engineers go as tight as **180 Hz low / 4.5 kHz high** for a darker vintage tone, noting "most real cabs have very narrow frequency responses" already baked into the IR itself. + +> Also flagged repeatedly: adding cuts on top of an already-captured IR is *redundant* with what the mic/cab response already did — cutting is a mixing/taste decision, not a "correctness" one. Reinforces that Nave's filters are a deliberate general-purpose utility, not part of "accurately voicing" the cab itself — this belongs in the honesty section and the manual, not as a change to defaults. + +**Implication for v2**: adopt "~100 Hz LoCut / ~5 kHz HiCut" and "80 Hz / 8 kHz" as sourced starting points for two of the factory presets (a "Tame the Fizz" style preset and a live-oriented preset), not as new defaults — the *default* (fully off) is already correct and independently validated above. + +## 5. IR loading level/normalisation behaviour — what `Normalise::yes` actually does + +Source: [JUCE forum — "Convolution normalisation factor"](https://forum.juce.com/t/convolution-normalisation-factor/44960) + +> `normalizationFactor = 0.125f / sqrt(sumOfSquaredMagnitudes)`, i.e. JUCE normalises each loaded IR to a fixed total-energy target, using an admittedly "arbitrary" 0.125 constant (acknowledged by a JUCE team member on the thread as "a very good choice to start with," not a precisely justified reference level). + +> Consequence: because the factor is driven by *total energy*, two real-world cab IRs of different length/spectral content/reverb tail can still land at meaningfully different perceived loudness after JUCE's own normalisation — the thread explicitly notes gains from the same nominal setup can vary by tens of dB depending on spectral density of the source IR (dense reverb IR vs. sparse impulse behave very differently under the same energy-based normalisation). + +**Gap vs. v1**: Nave calls `Convolution::Normalise::yes` for every user-loaded IR (both slots) and currently offers no messaging that this is an *energy-normalisation*, not a *perceptual loudness match* — two real-world cab IRs captured at different mic distances/gain levels can land at audibly different output levels after loading, with only the manual Level trim as recourse. This is worth an explicit callout in the manual/honesty section (not a DSP change — replacing JUCE's normalisation with a custom LUFS-style match is out of scope for a v0.2.0 parameter-tuning pass) and a factory-preset-adjacent tip. + +## 6. IR blending workflow lore (close mic + room mic, blend ratios) + +Sources: [Overdriven.fr — "Impulse responses part 9: mixing IRs"](https://overdriven.fr/overdriven/index.php/2021/05/30/impulse-responses-part-9-mixing-irs/); [Fractal Audio Forum — reamping/multi-mic thread](https://forum.fractalaudio.com/threads/reamping-and-multiple-mics-on-a-cab-questions.52841/) + +- Common engineer practice: close-mic the cab (often a dynamic, e.g. SM57-style, for edge/attack), place a second mic (ribbon or condenser) further back for room/body, blend to taste. +- Cited blend ratios in circulation: **75/25, 50/50, 25/75, 10/90** — i.e. discrete, musically meaningful stopping points rather than a continuous sweep being explored uniformly. +- Phase caution: if blending two mic signals "makes the sound smaller," that's the signature of a phase/timing mismatch, fixed by nudging one source into alignment — this is exactly the problem Nave's `IrAlignment::alignOnsetToReference()` automates away, and worth stating plainly as the payoff of that feature in a preset's description text. + +**Implication for v2**: factory presets built around IR Blend should target the sourced discrete ratios (10–25% for "add a touch of room/second mic," 50% for an even blend) rather than arbitrary numbers, and preset copy can legitimately claim "the same discrete blend ratios engineers reach for when compositing close + room mics, without the manual phase-alignment work." + +--- + +## Summary: what v1 gets right vs. what's generic/missing + +**Already correct / independently validated by research** (do not change): +- Bypass-at-extremes convention (20 Hz LoCut / 20 kHz HiCut off) — matches Fractal's own default convention exactly. +- Proximity-effect corner frequency choice (~200 Hz low shelf) is inside the physically documented 200–300 Hz range. +- High-shelf darkening for "distance" is directionally correct (frequency-dependent attenuation growing with distance/off-axis is real), even if the literal "air absorption" framing is a mislabel (see §2). +- Zero-latency convolution choice is architecturally sound and matches the reamping/tracking latency-sensitivity rationale used industry-wide. + +**Generic / under-sourced, worth revising in v2:** +- Distance's linear-in-dB-per-% taper doesn't reflect the front-loaded, non-linear physical curve of real proximity effect (§1–2). +- "Air absorption" framing in docs/manual overstates a physically negligible effect at these distances vs. the actual (directivity-driven) cause — honesty/manual wording issue, not a DSP bug. +- No factory presets yet exist (M2 is explicitly deferred) — this pass proposes sourced starting points. +- No user-facing acknowledgement that loading two different real-world IRs can produce different post-load loudness due to JUCE's energy-based (not perceptual) normalisation (§5). +- LoCut/HiCut ranges are wider than the closest structural reference (NadIR: 10–400 Hz / 6–22 kHz) at the low-cut ceiling and high-cut floor; Nave's wider range (20–800 Hz / 2–20 kHz) is defensible (more headroom for extreme tones) but should be stated as a deliberate divergence, not treated as unexamined. diff --git a/presets/factory/darkVintage.json b/presets/factory/darkVintage.json new file mode 100644 index 0000000..a1ac2cf --- /dev/null +++ b/presets/factory/darkVintage.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Dark Vintage", + "category": "Guitar", + "parameters": { + "loCut": 180.0, + "hiCut": 4500.0, + "irBlend": 0.0, + "micDistance": 25.0, + "mix": 100.0, + "level": 0.0 + } +} diff --git a/presets/factory/default.json b/presets/factory/default.json new file mode 100644 index 0000000..7fcdb15 --- /dev/null +++ b/presets/factory/default.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Default", + "category": "Init", + "parameters": { + "loCut": 20.0, + "hiCut": 20000.0, + "irBlend": 0.0, + "micDistance": 0.0, + "mix": 100.0, + "level": 0.0 + } +} diff --git a/presets/factory/evenBlend.json b/presets/factory/evenBlend.json new file mode 100644 index 0000000..3a7f672 --- /dev/null +++ b/presets/factory/evenBlend.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Even Blend", + "category": "Guitar", + "parameters": { + "loCut": 20.0, + "hiCut": 20000.0, + "irBlend": 50.0, + "micDistance": 0.0, + "mix": 100.0, + "level": 0.0 + } +} diff --git a/presets/factory/liveStage.json b/presets/factory/liveStage.json new file mode 100644 index 0000000..e522c22 --- /dev/null +++ b/presets/factory/liveStage.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Live Stage", + "category": "Guitar", + "parameters": { + "loCut": 80.0, + "hiCut": 8000.0, + "irBlend": 0.0, + "micDistance": 0.0, + "mix": 100.0, + "level": 0.0 + } +} diff --git a/presets/factory/parallelCabBlendedDry.json b/presets/factory/parallelCabBlendedDry.json new file mode 100644 index 0000000..2671fa2 --- /dev/null +++ b/presets/factory/parallelCabBlendedDry.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Parallel Cab (Blended Dry)", + "category": "Guitar", + "parameters": { + "loCut": 20.0, + "hiCut": 20000.0, + "irBlend": 0.0, + "micDistance": 20.0, + "mix": 65.0, + "level": 1.0 + } +} diff --git a/presets/factory/pushedBackInTheRoom.json b/presets/factory/pushedBackInTheRoom.json new file mode 100644 index 0000000..47b9857 --- /dev/null +++ b/presets/factory/pushedBackInTheRoom.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Pushed Back in the Room", + "category": "Guitar", + "parameters": { + "loCut": 20.0, + "hiCut": 20000.0, + "irBlend": 0.0, + "micDistance": 60.0, + "mix": 100.0, + "level": 1.5 + } +} diff --git a/presets/factory/tameTheFizz.json b/presets/factory/tameTheFizz.json new file mode 100644 index 0000000..087a673 --- /dev/null +++ b/presets/factory/tameTheFizz.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Tame the Fizz", + "category": "Guitar", + "parameters": { + "loCut": 100.0, + "hiCut": 5000.0, + "irBlend": 0.0, + "micDistance": 0.0, + "mix": 100.0, + "level": 0.0 + } +} diff --git a/presets/factory/touchOfRoomMic.json b/presets/factory/touchOfRoomMic.json new file mode 100644 index 0000000..b703280 --- /dev/null +++ b/presets/factory/touchOfRoomMic.json @@ -0,0 +1,15 @@ +{ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "0.2.0", + "name": "Touch of Room Mic", + "category": "Guitar", + "parameters": { + "loCut": 20.0, + "hiCut": 20000.0, + "irBlend": 15.0, + "micDistance": 0.0, + "mix": 100.0, + "level": 0.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 c1a16b6..46fb9b3 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -1,6 +1,9 @@ #include "PluginEditor.h" #include "PluginProcessor.h" #include "params/ParameterIds.h" +#include "presets/Localisation.h" + +#include namespace { @@ -11,14 +14,35 @@ namespace constexpr int numKnobs = 6; constexpr int irRowHeight = 30; constexpr int buttonWidth = 100; + constexpr int presetBarHeight = 28; constexpr int editorWidth = margin * 2 + numKnobs * knobSize + (numKnobs - 1) * margin; - constexpr int editorHeight = margin * 4 + irRowHeight * 2 + labelHeight + knobSize + textBoxHeight; + constexpr int editorHeight = margin * 5 + presetBarHeight + irRowHeight * 2 + labelHeight + knobSize + textBoxHeight; + + // 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 (NaveAudioProcessor& processor) + { + basilica::presets::installLocalisation (BinaryData::de_txt, BinaryData::de_txtSize); + return processor.presetManager; + } } NaveAudioProcessorEditor::NaveAudioProcessorEditor (NaveAudioProcessor& processorToEdit) : juce::AudioProcessorEditor (&processorToEdit), - audioProcessor (processorToEdit) + audioProcessor (processorToEdit), + presetBar (initLocalisationThenGetPresetManager (processorToEdit)) { + addAndMakeVisible (presetBar); + configureKnob (loCutKnob, ParamIDs::loCut, "LoCut"); configureKnob (hiCutKnob, ParamIDs::hiCut, "HiCut"); configureKnob (blendKnob, ParamIDs::irBlend, "IR Blend"); @@ -139,6 +163,9 @@ void NaveAudioProcessorEditor::resized() { auto bounds = getLocalBounds().reduced (margin); + presetBar.setBounds (bounds.removeFromTop (presetBarHeight)); + bounds.removeFromTop (margin); + auto irRow = bounds.removeFromTop (irRowHeight); defaultIrButton.setBounds (irRow.removeFromRight (buttonWidth)); irRow.removeFromRight (margin / 2); diff --git a/src/PluginEditor.h b/src/PluginEditor.h index b394606..8041e0e 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -2,6 +2,8 @@ #include +#include "presets/PresetBar.h" + class NaveAudioProcessor; // A simple, functional v0.1 editor: one rotary slider per parameter, bound @@ -36,6 +38,13 @@ class NaveAudioProcessorEditor final : public juce::AudioProcessorEditor NaveAudioProcessor& audioProcessor; + // 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; + Knob loCutKnob; Knob hiCutKnob; Knob blendKnob; diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index 7ccb02a..5f8c6b9 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -5,14 +5,63 @@ #include +#include + #include +namespace +{ + // The small, Nave-specific config surface PresetManager needs (see + // src/presets/PresetManager.h's class docs) - everything else about the + // preset system is fully generic and portable to sibling plugins (see + // docs/preset-system-notes.md). + 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 (see JUCE's own + // juce_CoreMidi_mac.mm for the same pattern). This is always + // "com.yvesvogl.nave" 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::tameTheFizz_json, BinaryData::tameTheFizz_jsonSize }, + { BinaryData::liveStage_json, BinaryData::liveStage_jsonSize }, + { BinaryData::darkVintage_json, BinaryData::darkVintage_jsonSize }, + { BinaryData::pushedBackInTheRoom_json, BinaryData::pushedBackInTheRoom_jsonSize }, + { BinaryData::touchOfRoomMic_json, BinaryData::touchOfRoomMic_jsonSize }, + { BinaryData::evenBlend_json, BinaryData::evenBlend_jsonSize }, + { BinaryData::parallelCabBlendedDry_json, BinaryData::parallelCabBlendedDry_jsonSize }, + }; + } +} + //============================================================================== NaveAudioProcessor::NaveAudioProcessor() : 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()) { loCutHz = apvts.getRawParameterValue (ParamIDs::loCut); hiCutHz = apvts.getRawParameterValue (ParamIDs::hiCut); @@ -27,6 +76,11 @@ NaveAudioProcessor::NaveAudioProcessor() jassert (levelDb != nullptr); jassert (irBlendPercent != nullptr); jassert (micDistancePercent != 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(); } NaveAudioProcessor::~NaveAudioProcessor() = default; diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index c3931c5..829887a 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -4,6 +4,7 @@ #include #include "dsp/CabConvolutionEngine.h" +#include "presets/PresetManager.h" // Nave: a cabinet impulse-response (IR) loader for reamping guitar/bass DI // tracks. Signal flow lives in CabConvolutionEngine (src/dsp) so it stays @@ -79,6 +80,14 @@ class NaveAudioProcessor final : public juce::AudioProcessor juce::AudioProcessorValueTreeState apvts; + // M2 preset system (.scaffold/specs/preset-system-m2.md, + // src/presets/PresetManager.h). Constructed after apvts (its + // constructor registers APVTS parameter listeners) and public so + // NaveAudioProcessorEditor's PresetBar can talk to it directly - the + // same "processor owns it, editor references it" pattern apvts itself + // already uses. + basilica::presets::PresetManager presetManager; + private: CabConvolutionEngine engine; diff --git a/src/dsp/CabConvolutionEngine.cpp b/src/dsp/CabConvolutionEngine.cpp index 731dfc9..373e639 100644 --- a/src/dsp/CabConvolutionEngine.cpp +++ b/src/dsp/CabConvolutionEngine.cpp @@ -1,6 +1,8 @@ #include "CabConvolutionEngine.h" #include "IrAlignment.h" +#include + namespace { // Keeps a requested filter frequency safely below Nyquist regardless of @@ -24,6 +26,24 @@ namespace buffer.setSample (0, 0, 1.0f); return buffer; } + + // v0.2.0 Distance taper (design-brief.md): "ease-out" power curve - + // applies the exponent to the *complement* of normalisedDistance and + // inverts, rather than raising normalisedDistance itself to the + // exponent. A plain pow(normalisedDistance, exponent) with exponent > 1 + // is convex on [0, 1] (slow start, accelerating near 1) - a back-loaded + // shape, the opposite of the brief's "most of the audible change + // happens in the first third of the knob's travel, tapering off toward + // 100%". This formulation is concave instead: a fast initial rise that + // flattens out approaching 1, mirroring real proximity effect's + // "accelerates then saturates" curve. See + // CabConvolutionEngine::distanceLowShelfTaperExponent's doc comment for + // the full rationale. The high-shelf is intentionally excluded from + // this - it keeps the plain-linear taper it always had. + float tapered (float normalisedDistance, float exponent) noexcept + { + return 1.0f - std::pow (1.0f - normalisedDistance, exponent); + } } CabConvolutionEngine::CabConvolutionEngine() = default; @@ -121,7 +141,7 @@ void CabConvolutionEngine::prepare (const juce::dsp::ProcessSpec& spec) / (distanceMaxPercent - distanceMinPercent); *distanceLowShelfFilter.state = *juce::dsp::IIR::Coefficients::makeLowShelf ( sampleRate, distanceLowShelfFrequencyHz, filterQ, - juce::Decibels::decibelsToGain (normalisedDistance * distanceLowShelfMaxCutDb)); + juce::Decibels::decibelsToGain (tapered (normalisedDistance, distanceLowShelfTaperExponent) * distanceLowShelfMaxCutDb)); *distanceHighShelfFilter.state = *juce::dsp::IIR::Coefficients::makeHighShelf ( sampleRate, distanceHighShelfFrequencyHz, filterQ, juce::Decibels::decibelsToGain (normalisedDistance * distanceHighShelfMaxCutDb)); @@ -370,7 +390,7 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) *distanceLowShelfFilter.state = *juce::dsp::IIR::Coefficients::makeLowShelf ( sampleRate, distanceLowShelfFrequencyHz, filterQ, - juce::Decibels::decibelsToGain (normalisedDistance * distanceLowShelfMaxCutDb)); + juce::Decibels::decibelsToGain (tapered (normalisedDistance, distanceLowShelfTaperExponent) * distanceLowShelfMaxCutDb)); *distanceHighShelfFilter.state = *juce::dsp::IIR::Coefficients::makeHighShelf ( sampleRate, distanceHighShelfFrequencyHz, filterQ, juce::Decibels::decibelsToGain (normalisedDistance * distanceHighShelfMaxCutDb)); diff --git a/src/dsp/CabConvolutionEngine.h b/src/dsp/CabConvolutionEngine.h index 0a68197..7b5393a 100644 --- a/src/dsp/CabConvolutionEngine.h +++ b/src/dsp/CabConvolutionEngine.h @@ -31,10 +31,25 @@ // applies "inter-IR phase alignment" (see IrAlignment.h) beforehand, so the // two IRs' transient onsets line up before they're ever summed. // -// Distance emulates the effect of mic-to-cab distance: a gentle proximity- -// effect low-shelf cut plus a high-shelf "air absorption" cut, both scaling -// with the Distance parameter. Distance defaults to 0% ("off"), the same -// explicit-bypass-at-the-extreme pattern used by LoCut/HiCut below. +// Distance emulates the effect of mic-to-cab distance: a proximity-effect +// low-shelf cut plus a high-shelf darkening cut (driven far more by +// loudspeaker directivity than literal air absorption at reamping distances +// - see docs/research-notes.md SS2), both scaling with the Distance +// parameter. Distance defaults to 0% ("off"), the same explicit-bypass-at- +// the-extreme pattern used by LoCut/HiCut below. +// +// v0.2.0 (design-brief.md, "Distance" module spec): the low-shelf's gain no +// longer scales linearly against the normalised Distance value. Real +// proximity effect is front-loaded - "accelerates exponentially... then +// saturates" (docs/research-notes.md SS1) - so the low-shelf now scales +// against normalisedDistance raised to distanceLowShelfTaperExponent (>1), +// which concentrates most of the audible cut in the first third or so of +// the knob's travel and tapers off toward 100%. The high-shelf intentionally +// keeps its plain-linear taper: Two Notes' own reference model attributes +// off-axis darkening to a *separate* axis (their Center control) from +// distance, so Nave's single-knob high-shelf is already a simplification of +// that other axis, not the front-loaded proximity effect - a linear taper +// there is the honest choice, not an oversight (see design-brief.md). class CabConvolutionEngine { public: @@ -155,16 +170,37 @@ class CabConvolutionEngine static constexpr float blendBypassEpsilon = 0.001f; static constexpr float distanceBypassEpsilonPercent = 0.5f; - // Distance emulation: fixed shelf frequencies, gain scaling linearly - // (in dB) with the normalised Distance parameter. Deliberately gentle - + // Distance emulation: fixed shelf frequencies. Deliberately gentle - // this approximates the two most audible effects of mic-to-cab distance - // (reduced proximity-effect bass buildup, and high-frequency air - // absorption/off-axis darkening), not a physically exact model. + // (reduced proximity-effect bass buildup, and high-frequency + // directivity-driven darkening), not a physically exact model. static constexpr float distanceLowShelfFrequencyHz = 200.0f; static constexpr float distanceLowShelfMaxCutDb = -6.0f; static constexpr float distanceHighShelfFrequencyHz = 5000.0f; static constexpr float distanceHighShelfMaxCutDb = -9.0f; + // v0.2.0 taper exponent for the low-shelf only (see the class-level + // v0.2.0 comment above and the `tapered()` helper in + // CabConvolutionEngine.cpp for the exact curve). Chosen from the + // design-brief's sourced "^1.6-^2" range and tuned by ear against the + // qualitative "accelerates then saturates" reference curve - see + // design-brief.md's Honesty section for what this number is and isn't + // calibrated against. + // + // Implementation note: a plain pow(normalisedDistance, exponent) with + // exponent > 1 is *convex* on [0, 1] (slow start, accelerating near 1) - + // that is a back-loaded curve, the opposite of what the brief specifies + // ("most of the audible change happens in the first third of the knob's + // travel, tapering off toward 100%"). `tapered()` instead applies the + // exponent to the *complement* and inverts + // (1 - (1 - normalisedDistance)^exponent, the standard "ease-out" power + // curve), which is concave - a fast initial rise that flattens out + // approaching 100%, the genuinely front-loaded shape the brief describes + // and tests/EngineTests.cpp's taper-shape test asserts. The high-shelf + // deliberately has no equivalent constant - it keeps a plain-linear + // taper (see the class-level comment). + static constexpr float distanceLowShelfTaperExponent = 1.8f; + double sampleRate = 44100.0; int numChannelsPrepared = 2; 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/EngineTests.cpp b/tests/EngineTests.cpp index 94d4cb2..5108a2a 100644 --- a/tests/EngineTests.cpp +++ b/tests/EngineTests.cpp @@ -1,6 +1,7 @@ #include "dsp/CabConvolutionEngine.h" #include "TestHelpers.h" +#include #include #include @@ -173,6 +174,19 @@ TEST_CASE ("HiCut attenuates high-frequency energy once engaged", "[dsp][engine] CHECK (engagedRms < bypassedRms * 0.5); // at least -6 dB of attenuation } +// Normalisation-awareness note (design-brief.md SS5, research-notes.md SS5): +// juce::dsp::Convolution's Normalise::yes rescales each loaded IR to a fixed +// *energy* target (JUCE forum: normalizationFactor = 0.125f / +// sqrt(sumOfSquaredMagnitudes)), not a perceptual loudness match - two +// real-world cab IRs of different length/spectral density can land at +// audibly different output levels after loading even though both were +// "normalised". There is no way to unit-test perceptual loudness-matching +// without reference audio, so this is deliberately just a comment, not a +// new TEST_CASE: docs/manual.md's "Loading impulse responses" section now +// carries the user-facing version of this same callout (pointing at Level +// as the fix), so a future contributor investigating a "why do these two +// IRs sound different loudness" bug report finds the documented explanation +// here and in the manual instead of re-discovering it from scratch. TEST_CASE ("A loaded (non-delta) impulse response measurably changes the output", "[dsp][engine][convolution]") { CabConvolutionEngine engine; @@ -744,3 +758,122 @@ TEST_CASE ("reset() clears filter/convolution/mixer state without crashing", "[d CHECK_NOTHROW (engine.process (block)); CHECK (TestHelpers::allSamplesFinite (buffer)); } + +//============================================================================== +// v0.2.0 (design-brief.md, "Distance" module spec): the low-shelf's taper +// changed from plain-linear-in-dB to a front-loaded curve. These three +// TEST_CASEs are the brief's own "Catch2 test guarantees" section, verbatim. + +TEST_CASE ("Distance's low-shelf taper is front-loaded: more cut in the first half of the sweep than the second", + "[dsp][engine][distance][taper]") +{ + // 40 Hz sits comfortably below the 200 Hz low-shelf corner, so the + // measured attenuation closely tracks the shelf's f->0 plateau gain - + // the same value CabConvolutionEngine.cpp's tapered() helper targets. + constexpr double lowShelfProbeFrequencyHz = 40.0; + constexpr float inputAmplitude = 0.5f; + + const auto measureCutDb = [] (float distancePercent) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setDistancePercent (distancePercent); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + juce::AudioBuffer buffer (2, testBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, lowShelfProbeFrequencyHz, inputAmplitude); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (buffer)); + + const auto outRms = TestHelpers::rms (buffer); + const auto inRms = static_cast (inputAmplitude) / std::sqrt (2.0); + + return juce::Decibels::gainToDecibels (outRms / inRms); + }; + + const auto cutAt0 = measureCutDb (0.0f); + const auto cutAt50 = measureCutDb (50.0f); + const auto cutAt100 = measureCutDb (100.0f); + + // Monotonically non-increasing: more Distance never means less cut. + REQUIRE (cutAt50 <= cutAt0 + 1.0e-2); + REQUIRE (cutAt100 <= cutAt50 + 1.0e-2); + + // The brief's own measurable proxy for "front-loaded": the first half of + // the sweep (0 -> 50%) must cover *more* of the total cut range than the + // second half (50% -> 100%). + const auto firstHalfCutDb = cutAt0 - cutAt50; + const auto secondHalfCutDb = cutAt50 - cutAt100; + + CHECK (firstHalfCutDb > secondHalfCutDb); +} + +TEST_CASE ("Distance's low-shelf taper regression guard: fixed-point snapshot at 25/50/75/100%", + "[dsp][engine][distance][taper]") +{ + // Guards against a future accidental taper-curve regression (same + // pattern as the existing bypass-epsilon snapshot checks elsewhere in + // this file). The expected figures are the analytic f->0 plateau values + // of CabConvolutionEngine.cpp's tapered() ease-out curve + // (1 - (1 - normalisedDistance)^1.8, scaled by the -6 dB max cut) - not + // a match to any sourced hardware curve (design-brief.md's Honesty + // section: no such curve was sourced for the exact taper shape). The + // tolerance is wide enough to absorb the small, expected gap between a + // finite test frequency's measured attenuation and that analytic + // plateau. + constexpr double lowShelfProbeFrequencyHz = 40.0; + constexpr int snapshotBlockSize = 65536; // long enough for a stable RMS reading at 40 Hz + constexpr float inputAmplitude = 0.5f; + constexpr float toleranceDb = 1.0f; + + const auto measureCutDb = [] (float distancePercent) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setDistancePercent (distancePercent); + + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (snapshotBlockSize); + spec.numChannels = 2; + engine.prepare (spec); + + juce::AudioBuffer buffer (2, snapshotBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, lowShelfProbeFrequencyHz, inputAmplitude); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (buffer)); + + const auto outRms = TestHelpers::rms (buffer); + const auto inRms = static_cast (inputAmplitude) / std::sqrt (2.0); + + return juce::Decibels::gainToDecibels (outRms / inRms); + }; + + CHECK (measureCutDb (25.0f) == Catch::Approx (-2.42).margin (toleranceDb)); + CHECK (measureCutDb (50.0f) == Catch::Approx (-4.28).margin (toleranceDb)); + CHECK (measureCutDb (75.0f) == Catch::Approx (-5.51).margin (toleranceDb)); + CHECK (measureCutDb (100.0f) == Catch::Approx (-6.00).margin (toleranceDb)); +} + +TEST_CASE ("LoCut/HiCut ranges match the v2 brief's deliberate-keep decision", "[dsp][engine][ranges]") +{ + // design-brief.md's LoCut/HiCut module specs: both ranges were + // considered against the NadIR reference (10-400 Hz / 6-22 kHz) and + // *deliberately* kept wider than that reference rather than narrowed - + // cheap insurance against that documented decision silently drifting + // the next time someone touches these constants. + CHECK (CabConvolutionEngine::loCutMinHz == 20.0f); + CHECK (CabConvolutionEngine::loCutMaxHz == 800.0f); + CHECK (CabConvolutionEngine::hiCutMinHz == 2000.0f); + CHECK (CabConvolutionEngine::hiCutMaxHz == 20000.0f); +} diff --git a/tests/PresetManagerTests.cpp b/tests/PresetManagerTests.cpp new file mode 100644 index 0000000..43b1090 --- /dev/null +++ b/tests/PresetManagerTests.cpp @@ -0,0 +1,570 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "presets/PresetManager.h" +#include "dsp/CabConvolutionEngine.h" + +#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). +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::tameTheFizz_json, BinaryData::tameTheFizz_jsonSize }, + { BinaryData::liveStage_json, BinaryData::liveStage_jsonSize }, + { BinaryData::darkVintage_json, BinaryData::darkVintage_jsonSize }, + { BinaryData::pushedBackInTheRoom_json, BinaryData::pushedBackInTheRoom_jsonSize }, + { BinaryData::touchOfRoomMic_json, BinaryData::touchOfRoomMic_jsonSize }, + { BinaryData::evenBlend_json, BinaryData::evenBlend_jsonSize }, + { BinaryData::parallelCabBlendedDry_json, BinaryData::parallelCabBlendedDry_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 ("NavePresetManagerTests") + .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.nave"; + config.pluginName = "Nave"; + config.manufacturerName = "Yves Vogl"; + config.pluginVersion = "0.2.0-test"; + config.userPresetsDirectoryOverrideForTests = userDir; + return config; + } + + void setParam (NaveAudioProcessor& processor, const char* id, float realValue) + { + auto* param = processor.apvts.getParameter (id); + REQUIRE (param != nullptr); + param->setValueNotifyingHost (param->convertTo0to1 (realValue)); + } + + float getParam (NaveAudioProcessor& 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]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::loCut, 250.0f); + setParam (processor, ParamIDs::hiCut, 3500.0f); + setParam (processor, ParamIDs::mix, 42.0f); + setParam (processor, ParamIDs::level, -6.5f); + setParam (processor, ParamIDs::irBlend, 35.0f); + setParam (processor, ParamIDs::micDistance, 60.0f); + + REQUIRE (manager.saveUserPreset ("Round Trip", "Init")); + + // Perturb every parameter away from the saved values before reloading, + // so the assertions below can't pass by accident. + setParam (processor, ParamIDs::loCut, 20.0f); + setParam (processor, ParamIDs::hiCut, 20000.0f); + setParam (processor, ParamIDs::mix, 100.0f); + setParam (processor, ParamIDs::level, 0.0f); + setParam (processor, ParamIDs::irBlend, 0.0f); + setParam (processor, ParamIDs::micDistance, 0.0f); + + REQUIRE (manager.loadPreset ("Round Trip")); + + CHECK (getParam (processor, ParamIDs::loCut) == Catch::Approx (250.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::hiCut) == Catch::Approx (3500.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::mix) == Catch::Approx (42.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::level) == Catch::Approx (-6.5f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::irBlend) == Catch::Approx (35.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::micDistance) == Catch::Approx (60.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]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + // Move mix and level away from their defaults so it's meaningful when + // the import below leaves them untouched (they're absent from + // "parameters"). + setParam (processor, ParamIDs::mix, 55.0f); + setParam (processor, ParamIDs::level, 3.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 + // (loCut/hiCut), deliberately omitting mix/level/irBlend/micDistance. + const juce::String fixtureJson = R"({ + "format": "basilica-preset-1", + "plugin": "com.yvesvogl.nave", + "pluginVersion": "9.9.9", + "name": "Forward Compat Fixture", + "category": "Init", + "parameters": { "loCut": 120.0, "hiCut": 6000.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::loCut) == Catch::Approx (120.0f).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::hiCut) == Catch::Approx (6000.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 55%/3 dB values. + auto* mixParam = processor.apvts.getParameter (ParamIDs::mix); + auto* levelParam = processor.apvts.getParameter (ParamIDs::level); + CHECK (getParam (processor, ParamIDs::mix) == Catch::Approx (mixParam->convertFrom0to1 (mixParam->getDefaultValue())).margin (1.0e-3)); + CHECK (getParam (processor, ParamIDs::level) == Catch::Approx (levelParam->convertFrom0to1 (levelParam->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]") +{ + NaveAudioProcessor 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.overture", + "pluginVersion": "0.2.0", + "name": "Not Nave's", + "category": "Init", + "parameters": { "loCut": 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 - loCut must NOT have picked up 999 (out + // of its own 20-800 Hz range too, which would be a separate bug on top). + CHECK (getParam (processor, ParamIDs::loCut) != Catch::Approx (999.0f)); + + file.deleteFile(); +} + +TEST_CASE ("PresetManager: import refuses a file with an incompatible format tag", "[presets]") +{ + NaveAudioProcessor 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.nave", + "pluginVersion": "0.2.0", + "name": "Wrong Format", + "category": "Init", + "parameters": { "loCut": 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]") +{ + NaveAudioProcessor 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 == 8); // design-brief.md's Factory Presets section + + 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)", "[presets]") +{ + NaveAudioProcessor 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); + + // Loading every factory preset must leave every parameter's live value + // inside its own ParameterLayout range - APVTS's setValueNotifyingHost() + // clamps out-of-range normalised input, so an out-of-range preset value + // wouldn't crash, but it would silently mean the JSON doesn't say what + // the plugin actually does - worth catching explicitly. + for (auto& entry : all) + { + if (! entry.isFactory) + continue; + + REQUIRE (manager.loadPreset (entry.name)); + + CHECK (getParam (processor, ParamIDs::loCut) >= CabConvolutionEngine::loCutMinHz); + CHECK (getParam (processor, ParamIDs::loCut) <= CabConvolutionEngine::loCutMaxHz); + CHECK (getParam (processor, ParamIDs::hiCut) >= CabConvolutionEngine::hiCutMinHz); + CHECK (getParam (processor, ParamIDs::hiCut) <= CabConvolutionEngine::hiCutMaxHz); + CHECK (getParam (processor, ParamIDs::mix) >= 0.0f); + CHECK (getParam (processor, ParamIDs::mix) <= 100.0f); + } +} + +//============================================================================== +// 5. Default resolution order (user Default > factory Default > plain defaults). +TEST_CASE ("PresetManager: applyStartupDefault() loads the factory Default when no user Default exists", "[presets]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::loCut, 555.0f); // perturb first + + manager.applyStartupDefault(); + + CHECK (manager.getCurrentPresetName() == "Default"); + CHECK (manager.isCurrentPresetFactory()); + CHECK (getParam (processor, ParamIDs::loCut) == Catch::Approx (CabConvolutionEngine::loCutMinHz).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: a user Default preset wins over the factory Default", "[presets]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::loCut, 321.0f); + REQUIRE (manager.setCurrentAsDefault()); // writes a user preset literally named "Default" + + setParam (processor, ParamIDs::loCut, 20.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::loCut) == Catch::Approx (321.0f).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: resetDefault() removes the user Default so the factory Default resolves again", "[presets]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::loCut, 321.0f); + REQUIRE (manager.setCurrentAsDefault()); + REQUIRE (manager.resetDefault()); + + manager.applyStartupDefault(); + + CHECK (manager.isCurrentPresetFactory()); + CHECK (getParam (processor, ParamIDs::loCut) == Catch::Approx (CabConvolutionEngine::loCutMinHz).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]") +{ + NaveAudioProcessor 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::loCut, 300.0f); + CHECK (manager.isDirty()); + + REQUIRE (manager.saveUserPreset ("Dirty Flag Preset", "Init")); + CHECK_FALSE (manager.isDirty()); +} + +//============================================================================== +// 7. prev/next ordering and wrap-around. +TEST_CASE ("PresetManager: nextPreset()/previousPreset() traverse alphabetically and wrap around", "[presets]") +{ + NaveAudioProcessor 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]") +{ + NaveAudioProcessor 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 ("Tame the Fizz", "Guitar")); +} + +TEST_CASE ("PresetManager: renameUserPreset() moves a user preset to a new name and preserves its parameters", "[presets]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::hiCut, 4321.0f); + REQUIRE (manager.saveUserPreset ("Old Name", "Init")); + + REQUIRE (manager.renameUserPreset ("Old Name", "New Name")); + + setParam (processor, ParamIDs::hiCut, 20000.0f); // perturb before reloading + + CHECK_FALSE (manager.loadPreset ("Old Name")); // gone + REQUIRE (manager.loadPreset ("New Name")); + CHECK (getParam (processor, ParamIDs::hiCut) == Catch::Approx (4321.0f).margin (1.0e-3)); +} + +TEST_CASE ("PresetManager: deleteUserPreset() removes a user preset but never a factory preset", "[presets]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + REQUIRE (manager.saveUserPreset ("Temporary", "Init")); + 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]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + ScopedTestDirectory scratch; + PresetManager manager (processor.apvts, makeIsolatedConfig (scratch.dir), makeTestFactoryPresetAssets()); + + setParam (processor, ParamIDs::micDistance, 77.0f); + REQUIRE (manager.saveUserPreset ("Exportable", "Init")); + + 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::micDistance) == Catch::Approx (77.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; + + NaveAudioProcessor sourceProcessor; + sourceProcessor.prepareToPlay (48000.0, 512); + PresetManager sourceManager (sourceProcessor.apvts, makeIsolatedConfig (sourceScratch.dir), makeTestFactoryPresetAssets()); + + setParam (sourceProcessor, ParamIDs::loCut, 111.0f); + REQUIRE (sourceManager.saveUserPreset ("Bank Preset A", "Init")); + + setParam (sourceProcessor, ParamIDs::loCut, 222.0f); + REQUIRE (sourceManager.saveUserPreset ("Bank Preset B", "Init")); + + const auto bankFile = juce::File::createTempFile (".zip"); + REQUIRE (sourceManager.exportBank (bankFile)); + REQUIRE (bankFile.existsAsFile()); + + NaveAudioProcessor 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::loCut) == Catch::Approx (111.0f).margin (1.0e-3)); + + REQUIRE (destManager.loadPreset ("Bank Preset B")); + CHECK (getParam (destProcessor, ParamIDs::loCut) == 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 NaveAudioProcessor:: +// processBlock()/CabConvolutionEngine 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]") +{ + NaveAudioProcessor 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::loCut, 20.0f + static_cast (block) * 10.0f); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + } + + CHECK (manager.isDirty()); +}