diff --git a/CHANGELOG.md b/CHANGELOG.md index df3a486..e7ee276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-07-14 + ### Added - Project bootstrap: README, license, contributing guide, architecture and build docs, ADRs, and CI workflow. +- Full v1.0 `AudioProcessorValueTreeState` parameter layout (frozen parameter IDs) covering IO/global, noise gate, crossover, low band, high band, EQ, and IR loader. +- LR4 (Linkwitz-Riley 4th order) crossover band-split (`src/dsp/Crossover`), flat-sum tested, with a latency-compensation framework/seam in the processor. +- **DSP completion (M1):** the full v1.0 signal path is wired and live: + - Full-band input noise gate (`src/dsp/NoiseGateStage`), off by default. + - Low-band parallel ("New York style") compressor with makeup gain and wet/dry mix (`src/dsp/ParallelCompressor`). + - High-band distortion engine (`src/dsp/Voicing`) with three selectable voicings — **Gnaw** (op-amp hard clip), **Wool** (cascaded soft-clip fuzz with mid scoop), **Razor** (tight overdrive: pre-clip highpass, soft clip, mid hump) — each running its nonlinear shaping stage 4x oversampled (FIR half-band equiripple) to control aliasing, with drive, tone, and clean/distorted blend controls. + - Post-sum 4-band EQ (`src/dsp/BandEQ`: LowShelf / Peak / Peak / HighShelf), off by default. + - Cab-sim IR loader (`src/dsp/IRLoader`, `juce::dsp::Convolution`-based), off by default, safe-by-default (bit-exact passthrough with no IR loaded, at every session sample rate); `loadImpulseResponse()` is the DSP-side seam a future GUI/preset system will call to load user or factory IRs. + - Latency compensation extended to cover the high band's oversampling latency: reported to the host via `setLatencySamples`, low band delay-compensated to match, high band's own clean/distorted `DryWetMixer` blend delay-compensated too. + - `src/dsp/RealtimeCoefficients.h`: shared real-time-safe (zero-allocation) `juce::dsp::IIR` coefficient update helper, used by `BandEQ` and `Voicing`'s mid/tone filters. +- Broadened Catch2 test suite (issue #43): dedicated test files for every new DSP stage (`NoiseGateTests`, `ParallelCompressorTests`, `VoicingTests`, `BandEQTests`, `IRLoaderTests`), plus sample-rate sweeps (44.1–192 kHz), mono/stereo bus-configuration tests, extreme-parameter-automation and long-run NaN/Inf stability soak tests (`SampleRateAndRobustnessTests`). Existing gain-staging/latency/passthrough tests updated to account for the now-live (non-transparent-by-default) compressor and voicing stages. +- `docs/manual.md`: full user manual — what the plugin is, where it sits in a symphonic-metal chain, signal-flow description, complete parameter reference, and usage tips. + +### Changed + +- `docs/architecture.md`: signal-flow diagram and module map updated to match the new full signal path; new sections documenting the real-time-safe filter-coefficient pattern, the IR loader's safe-by-default behaviour, and the extended latency-compensation design (including the `DryWetMixer` priming gotcha). +- `README.md`: feature list, signal-flow diagram, and roadmap table updated to match the live DSP and the project's actual milestone scheme (M1 DSP completion & test coverage → M2 presets & state → M3 GUI & accessibility → M4 release). diff --git a/CLAUDE.md b/CLAUDE.md index a9b33fb..fbe8adf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,14 @@ Per-repo working memory for Claude Code sessions on this plugin. Part of the **M ## What this is Twist Your Guts is a Parallax-style **parallel bass processor** for metal: it LR4-splits the bass into low/high bands, parallel-compresses the lows, runs the highs through selectable distortion voicings, then sums back through a 4-band EQ and an IR cabinet loader. AU / VST3 / Standalone. -## Status (pre-1.0) -M0 bootstrap complete (scaffold, CI, docs, ADRs). The LR4 crossover + latency framework is the first DSP landed with tests; the full v1.0 signal path (compressor, distortion voicings, gate, EQ, IR loader) is in progress. See GitHub **milestones/issues** for open work, and `README.md` for the full v1.0 feature scope and signal-flow diagram. - -## DSP (v1.0 target) -`Input Trim → Gate → LR4 split (60–1000 Hz) → [Low: parallel comp] + [High: distortion voicing (Gnaw/Wool/Razor) → drive → tone → blend] → delay-compensated sum → 4-band EQ → IR loader → Output`. -- Crossover already implemented in `src/dsp/Crossover.{h,cpp}` (LR4, flat-sum tested) — this is the canonical crossover the suite's `triptych` multiband reuses. +## Status (pre-1.0, v0.1.0) +M0 bootstrap and M1 "DSP completion & test coverage" are both done: the full v1.0 signal path is wired and tested (gate, LR4 crossover, parallel low-band compressor, three oversampled high-band voicings, post-sum 4-band EQ, IR loader, latency compensation). Not yet done: preset manager/versioned state (M2), custom GUI/metering/accessibility (M3), signing/notarization/v1.0.0 release (M4). Voicing character (drive-gain ranges, mid-filter hump/scoop settings) is engineering-tuned, not yet ear-tuned against reference material. IR loader has no bundled factory IRs and no GUI file browser yet (DSP engine is fully live; file-loading is a `loadImpulseResponse()` seam a future GUI/preset system will call). See GitHub **milestones/issues** for open work, `README.md` for the feature scope and signal-flow diagram, and `docs/manual.md` for the full parameter reference. + +## DSP (v1.0 target — fully wired as of M1) +`Input Trim → Gate → LR4 split (60–1000 Hz) → [Low: parallel comp → makeup → mix → level] + [High: voicing (Gnaw/Wool/Razor, 4x oversampled) → drive → tone → blend → level] → delay-compensated sum → 4-band EQ → IR loader → optional safety clip → Output Trim`. +- Each stage lives in its own `src/dsp/*.{h,cpp}` class with a dedicated Catch2 test file: `Crossover` (LR4 split/sum, flat-sum tested — canonical crossover the suite's `triptych` multiband reuses), `NoiseGateStage`, `ParallelCompressor`, `Voicing` (oversampling + latency reporting), `BandEQ`, `IRLoader`. +- `src/dsp/RealtimeCoefficients.h`: shared helper for updating `juce::dsp::IIR` filter coefficients from the audio thread without heap allocation (`ArrayCoefficients` stack-only computation + in-place raw-storage write). Used by `BandEQ` and `Voicing`. +- Latency: only `Voicing`'s 4x oversampling adds sample latency; reported via `setLatencySamples`, low band delay-compensated to match. See `docs/architecture.md`'s "Latency compensation" section, including the `DryWetMixer` priming gotcha. - Params via APVTS (`src/params/`). ## Build & test diff --git a/README.md b/README.md index 73eed27..319a66d 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,27 @@ [![CI](https://github.com/yves-vogl/twist-your-guts/actions/workflows/ci.yml/badge.svg)](https://github.com/yves-vogl/twist-your-guts/actions/workflows/ci.yml) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -> **Work in progress.** Twist Your Guts is pre-1.0 and under active development. There are no built binaries or releases yet — building from source is currently the only way to run it. Expect breaking changes until v1.0.0 ships (see [Roadmap](#roadmap)). +> **Work in progress.** Twist Your Guts is pre-1.0 and under active development (v0.1.0). There are no built binaries or releases yet — building from source is currently the only way to run it. Expect breaking changes until v1.0.0 ships (see [Roadmap](#roadmap)). ## What it is -Twist Your Guts is a Parallax-style bass plugin built on JUCE 8. It splits your bass signal into low and high bands with a linear-phase-adjacent Linkwitz-Riley crossover, compresses the low band in parallel, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and an impulse-response (cab sim) loader. +Twist Your Guts is a Parallax-style bass plugin built on JUCE 8. It splits your bass signal into low and high bands with a linear-phase-adjacent Linkwitz-Riley crossover, compresses the low band in parallel, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and an impulse-response (cab sim) loader. See [`docs/manual.md`](docs/manual.md) for the full parameter reference and usage tips. -## Features (v1.0 scope) +## Features +- **Noise gate** — full-band, ahead of the crossover split - **LR4 crossover band-split** — 4th-order Linkwitz-Riley split, adjustable 60 Hz – 1000 Hz (default 250 Hz) -- **Low band**: parallel compressor with wet/dry mix and output level -- **High band**: three distortion voicings, each with independent drive/tone - - **Gnaw** — hard clip - - **Wool** — fuzz - - **Razor** — tight overdrive +- **Low band**: parallel ("New York style") compressor with makeup gain, wet/dry mix, and output level +- **High band**: three distortion voicings, each 4x oversampled to keep aliasing under control, with independent drive/tone + - **Gnaw** — op-amp-style hard clip + - **Wool** — cascaded soft-clip fuzz with a mid scoop + - **Razor** — tight overdrive: pre-clip highpass, soft clip, mid hump - Clean/distorted blend control per voicing, plus output level -- **Noise gate** on the input stage -- **4-band EQ** post-sum -- **IR loader** (cabinet simulation) on the output stage -- **Presets** with full state save/recall -- **Metering** throughout the signal chain +- **4-band EQ** post-sum (LowShelf / Peak / Peak / HighShelf) +- **IR loader** (cabinet simulation) on the output stage — convolution engine is live; bundled factory IRs and a GUI file browser land in a later milestone +- **Delay-compensated signal path** — the high band's oversampling latency is reported to the host and the low band is time-aligned to match +- **Presets** with full state save/recall *(planned — a dedicated preset manager/versioning scheme is a later milestone; APVTS state save/load already round-trips today)* +- **Metering** throughout the signal chain *(planned, alongside the custom GUI)* ## Signal flow @@ -34,8 +35,9 @@ Input Trim → Gate → LR4 Split (60–1000 Hz, default 250 Hz) ┌─────────────┴─────────────┐ │ │ Low band High band - Comp → Mix → Level Voicing → Drive → Tone → Blend → Level - │ │ + Parallel Comp Voicing → Drive → Tone → Blend + → Makeup → Mix │ + │→ Level → Level └─────────────┬─────────────┘ │ Sum (delay-compensated) @@ -44,10 +46,26 @@ Input Trim → Gate → LR4 Split (60–1000 Hz, default 250 Hz) │ IR loader │ - Output + Safety Clip (optional) + │ + Output Trim ``` -The high band runs through oversampling for the distortion stage; the low band is delay-compensated to stay time-aligned with it before the sum. See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the latency-compensation strategy. +The high band runs 4x oversampled for the distortion stage; the low band is delay-compensated to stay time-aligned with it before the sum. See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the latency-compensation strategy, and [`docs/manual.md`](docs/manual.md) for the full parameter reference. + +## Parameters + +See [`docs/manual.md`](docs/manual.md) for the complete, musically-annotated parameter reference. Summary: + +| Section | Parameters | +|---|---| +| IO / Global | Input Gain, Output Gain, Bypass, Safety Clip | +| Noise Gate | Enable, Threshold, Ratio, Attack, Release | +| Crossover | Frequency (60–1000 Hz) | +| Low band | Comp Threshold/Ratio/Attack/Release/Makeup/Mix, Level | +| High band | Voicing (Gnaw/Wool/Razor), Drive, Tone, Blend, Level | +| EQ | Enable, Low Shelf Freq/Gain, Peak 1 Freq/Gain/Q, Peak 2 Freq/Gain/Q, High Shelf Freq/Gain | +| IR loader | Enable, Mix | ## Installation @@ -87,14 +105,11 @@ ctest --test-dir build --output-on-failure | Milestone | Description | Status | |---|---|---| -| M0 | Bootstrap — project skeleton, CI, docs | In progress | -| M1 | DSP core — crossover + latency framework | Planned | -| M2 | Dynamics — gate + compressor | Planned | -| M3 | Distortion engine — oversampling, 3 voicings | Planned | -| M4 | EQ + IR loader | Planned | -| M5 | State & presets | Planned | -| M6 | Custom GUI | Planned | -| M7 | Release engineering — signing, notarization, installers, v1.0.0 | Planned | +| M0 | Bootstrap — project skeleton, CI, docs | Done | +| M1 | DSP completion & test coverage — gate, crossover, parallel compressor, 3 voicings (oversampled), 4-band EQ, IR loader, latency compensation, broadened test suite | Done (v0.1.0) | +| M2 | Presets & state recall — preset manager, factory presets, versioned state | Planned | +| M3 | GUI & accessibility — custom LookAndFeel, metering UI, accessibility pass | Planned | +| M4 | Release: signing, notarization, v1.0.0 — installers, tagged release | Planned | ## License diff --git a/docs/architecture.md b/docs/architecture.md index aee399c..84c4abd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,11 +8,10 @@ flowchart LR TRIM --> GATE[Noise Gate] GATE --> SPLIT[LR4 Crossover Split
60–1000 Hz, default 250 Hz] - SPLIT -->|Low band| LCOMP[Compressor] - LCOMP --> LMIX[Mix] - LMIX --> LLEVEL[Level] + SPLIT -->|Low band| LCOMP[Parallel Compressor
+ Makeup + Mix] + LCOMP --> LLEVEL[Level] - SPLIT -->|High band| HVOICE[Voicing:
Gnaw / Wool / Razor] + SPLIT -->|High band| HVOICE[Voicing:
Gnaw / Wool / Razor
4x oversampled] HVOICE --> HDRIVE[Drive] HDRIVE --> HTONE[Tone] HTONE --> HBLEND[Clean/Dist Blend] @@ -23,7 +22,8 @@ flowchart LR SUM --> EQ[4-band EQ] EQ --> IR[IR Loader
cab sim] - IR --> OUT[Output] + IR --> CLIP[Safety Clip
optional] + CLIP --> OUT[Output Trim] ``` Both bands re-converge at the `Sum` stage. The low band carries a compensation delay so it stays time-aligned with the oversampled high band (see [Latency compensation](#latency-compensation) below). @@ -32,13 +32,30 @@ Both bands re-converge at the `Sum` stage. The low band carries a compensation d | Directory | Responsibility | |---|---| -| `src/dsp` | All audio-thread DSP: crossover (LR4 split/sum), gate, compressor, the three distortion voicings and their oversampling, EQ, IR loader/convolution, metering taps. No allocation, locks, or I/O once `prepareToPlay` has run. | +| `src/dsp` | All audio-thread DSP, each in its own class with a matching Catch2 test file: `Crossover` (LR4 split/sum), `NoiseGateStage` (full-band input gate), `ParallelCompressor` (low-band dynamics), `Voicing` (high-band Gnaw/Wool/Razor distortion + 4x oversampling + tone + blend), `BandEQ` (post-sum 4-band EQ), `IRLoader` (cab-sim convolution). `RealtimeCoefficients.h` is a shared helper for updating `juce::dsp::IIR` filter coefficients from the audio thread without heap allocation (see below). No allocation, locks, or I/O once `prepareToPlay` has run. | | `src/params` | Parameter layout and `AudioProcessorValueTreeState` definitions — parameter IDs, ranges, defaults, and value-to-DSP mapping. Single source of truth for what a preset captures. | -| `src/state` | Plugin state serialization: preset save/load, versioned state migration, `getStateInformation`/`setStateInformation` glue. Depends on `src/params` for what to persist, not on `src/dsp` directly. | -| `src/ui` | Editor/GUI code. Talks to the processor only through `src/params` (attachments) and read-only metering data — never reaches into `src/dsp` internals directly. Placeholder generic JUCE UI until the custom vector GUI lands in M6. | +| `src/state` | Plugin state serialization: preset save/load, versioned state migration, `getStateInformation`/`setStateInformation` glue. Depends on `src/params` for what to persist, not on `src/dsp` directly. Not yet built out beyond the base APVTS state save/load already wired in `PluginProcessor` - the dedicated preset manager/versioning scheme is a later milestone. | +| `src/ui` | Editor/GUI code. Talks to the processor only through `src/params` (attachments) and read-only metering data — never reaches into `src/dsp` internals directly. Placeholder generic JUCE UI until the custom vector GUI lands in a later milestone. | Dependency direction is one-way: `src/ui` → `src/params` ← `src/state`, and `src/dsp` is driven by `src/params` values but has no upward dependency on UI or state code. This keeps the DSP core testable in isolation (see `tests/`) without instantiating any UI or persistence machinery. +## Real-time-safe filter coefficient updates + +`juce::dsp::IIR::Coefficients::makeLowShelf`/`makePeakFilter`/`makeHighShelf`/... (the usual way to build filter coefficients) heap-allocate a new `Coefficients` object on every call - fine in `prepareToPlay()`, not fine on the audio thread when a parameter (an EQ band's frequency, the high-band voicing's mid-filter, its tone control, ...) is being automated continuously. `BandEQ` and `Voicing` both use `juce::dsp::IIR::ArrayCoefficients::makeXxx()` instead, which returns the same coefficients as a stack-only `std::array` (zero allocation), and `src/dsp/RealtimeCoefficients.h` writes that array's values directly into an already-allocated `Coefficients` object's raw storage (normalising by `a0` the same way `Coefficients`' own constructor does). The `Coefficients` object itself is allocated exactly once, during `prepare()`; every subsequent update on the audio thread only ever overwrites existing memory. + +## IR loader safe-by-default behaviour + +`juce::dsp::Convolution` falls back to an internal single-sample identity impulse response when `loadImpulseResponse()` has never been called - but that fallback's assumed source sample rate is hardcoded to JUCE's `ProcessSpec` default (44100 Hz), so at any *other* session sample rate it would otherwise get silently resampled (smeared/attenuated) against a mismatched rate. `IRLoader::prepare()` closes that gap by explicitly loading a correctly-rate-tagged identity impulse response itself, so "no IR loaded" is a guaranteed bit-exact passthrough at every session sample rate, not only at 44100 Hz - see the class-level comment in `src/dsp/IRLoader.h`. + ## Latency compensation -The high band's distortion voicings run oversampled (to keep aliasing out of the nonlinear stages), which introduces processing latency that the low band does not incur. To keep the two bands phase-coherent at the `Sum` stage, the low band path carries a matching compensation delay sized to the high band's oversampling latency (filter group delay plus any oversampling-induced block delay), so both bands arrive at `Sum` with equal total latency. The plugin reports its total latency to the host via `setLatencySamples`, covering the crossover's own latency (if any, depending on implementation) plus the oversampling latency — so host-side plugin delay compensation (PDC) accounts for the whole chain, not just the high band in isolation. If the oversampling factor or voicing changes at runtime in a way that changes the latency, the reported latency is updated accordingly and the compensation delay is resized to match. +The high band's voicing stage (`tyg::Voicing`) runs its nonlinear waveshaping oversampled 4x (`juce::dsp::Oversampling`, FIR half-band equiripple, max quality, integer latency), which introduces processing latency that the low band does not incur. This is the *only* source of latency in the current signal path - the gate, low-band parallel compressor, EQ, and IR loader (configured for zero-latency convolution) are all zero-latency by construction. To keep the two bands phase-coherent at the `Sum` stage: + +- The low band path carries a matching `juce::dsp::DelayLine` (integer/no-interpolation, since the delay is always a whole number of samples) sized to the high band's oversampling latency. +- The high band's own clean/distorted blend (`highBlend`) is handled by a `juce::dsp::DryWetMixer` whose dry path is *also* delay-compensated (`setWetLatency`) by that same amount, so the clean and distorted high-band signals stay phase-coherent with each other too, not just with the low band. + +`TwistYourGutsAudioProcessor::computeTotalLatencySamples()` reports `Voicing::getLatencySamples()` to the host via `setLatencySamples()`, so host-side plugin delay compensation (PDC) accounts for the whole chain. If the DSP later adds another latency source (e.g. a different oversampling factor becomes user-selectable), this seam is where it gets folded in. + +### The `DryWetMixer` priming gotcha (JUCE 8.0.14) + +`juce::dsp::DryWetMixer::prepare()` calls `reset()` internally, which snaps its smoothed dry/wet volumes to whatever `mix` was set to *at that moment* - so if `prepare()` runs before the real mix value is set, the mixer briefly snaps to a stale default before the next `setWetMixProportion()` call retargets it, causing an audible fade-in glitch on the very first block. `ParallelCompressor::prepare()`, `Voicing::prepare()`, and `IRLoader::prepare()` all take the current mix proportion as an explicit parameter and call `setWetMixProportion()` *before* `prepare()` internally, closing this gap at the API level rather than relying on call-order discipline at every call site. diff --git a/docs/manual.md b/docs/manual.md new file mode 100644 index 0000000..7ea1de6 --- /dev/null +++ b/docs/manual.md @@ -0,0 +1,143 @@ +# Twist Your Guts — User Manual + +*Split your bass. Compress the lows. Twist the guts out of the highs.* + +## What it is + +Twist Your Guts is a Parallax-style **parallel bass processor** built for metal production. It splits your bass signal into a low band and a high band with a 4th-order Linkwitz-Riley ("LR4") crossover, keeps the low band tight with a parallel compressor, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and a cabinet-simulation IR loader. + +### Where it sits in a symphonic-metal chain + +Twist Your Guts is designed to be the **bass-specific voicing stage** in the "Metal up your ass" suite: + +- Track order: **DI/amp sim → Twist Your Guts → bus compression/glue → mix bus**. It expects a reasonably clean, already-amp-sim'd or DI'd bass signal; it is not itself a full amp sim (no built-in preamp gain staging beyond the input trim and drive controls). +- The low band's parallel compressor is meant to keep the fundamental/sub content of the bass locked in place under a wall of distorted guitars, while the high band's voicing adds the upper-mid "grind" that lets the bass cut through a dense mix without competing for the same frequency range as the guitars. +- The crossover point (default 250 Hz) is deliberately tunable across the whole low-mid register (60 Hz–1000 Hz) so you can match the split to the song's tuning (drop-tunings push useful low-end content further up). +- The output stage's IR loader is meant for quick cabinet-style tone shaping without needing a separate cab-sim plugin later in the chain, though it can also be left off entirely if you're already running a dedicated cab sim elsewhere. + +## Signal flow + +``` +Input Trim → Gate → LR4 Split (60–1000 Hz, default 250 Hz) + │ + ┌─────────────┴─────────────┐ + │ │ + Low band High band + Parallel Comp → Level Voicing → Drive → Tone → Blend → Level + │ │ + └─────────────┬─────────────┘ + │ + Sum (delay-compensated) + │ + 4-band EQ + │ + IR loader + │ + Safety Clip (optional) + │ + Output +``` + +The high band runs its distortion voicing oversampled (4x) to keep aliasing under control; the low band carries a matching compensation delay so both bands stay time-aligned at the sum. See [`docs/architecture.md`](architecture.md) for the full technical breakdown, including exactly how that latency compensation works. + +## Parameter reference + +Unless noted otherwise, all continuous parameters are smoothed to avoid zipper noise when automated. + +### IO / Global + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| Input Gain | −24 … +24 | 0 | dB | Trims the signal before anything else in the chain. Use this to get a hot but not clipping signal into the gate/compressor/voicing stages - all of their thresholds are calibrated assuming a reasonably "line level" input. | +| Output Gain | −24 … +24 | 0 | dB | Final output trim, applied after everything else (including the safety clip). | +| Bypass | off/on | off | — | Forces a bit-exact passthrough of the input signal. Also exposed as the plugin's host-facing bypass parameter, so your DAW's own bypass button/automation lane works too. | +| Safety Clip | off/on | off | — | A soft (tanh) limiter on the very last stage before the output trim. Off by default; turn it on as a safety net against accidental hard-clipped overs, not as a tone-shaping tool - at typical playing levels it's inaudible, and it only starts rounding peaks once they approach 0 dBFS. | + +### Noise Gate (full-band, before the crossover split) + +Sits ahead of the crossover, so it gates the input signal as a whole rather than per band. + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| Gate Enable | off/on | **off** | — | Enables the gate. Off by default - most already-tracked bass DI/amp signals don't need one, and an incorrectly-set gate can chop off legitimate low-level playing (ghost notes, decays). | +| Gate Threshold | −80 … 0 | −60 | dB | Signal level below which the gate starts attenuating. | +| Gate Ratio | 1 … 20 | 10 | :1 | How aggressively the gate attenuates once below threshold. Higher = closer to a hard mute. | +| Gate Attack | 0.1 … 50 | 1 | ms | How fast the gate opens once the signal crosses back above threshold. | +| Gate Release | 5 … 500 | 100 | ms | How fast the gate closes once the signal drops below threshold. | + +### Crossover + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| Crossover Frequency | 60 … 1000 | 250 | Hz | The LR4 split point between the low and high bands. Log-scaled control (equal knob travel per octave). Lower it to push more of the fundamental into the (typically cleaner, compressed) low band; raise it to give the distortion voicing more of the low-mid content to work with. | + +### Low band: parallel compressor + level + +The low band is compressed **in parallel** ("New York style"): the compressed signal is blended back with its own uncompressed self via Mix, rather than replacing it outright, which is what keeps the low end feeling tight and controlled without ever sounding squashed or lifeless. + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| Low Comp Threshold | −60 … 0 | −18 | dB | Level above which the low-band compressor engages. | +| Low Comp Ratio | 1 … 20 | 4 | :1 | Compression ratio above threshold. | +| Low Comp Attack | 0.1 … 100 | 10 | ms | How fast the compressor clamps down once above threshold. | +| Low Comp Release | 10 … 1000 | 120 | ms | How fast the compressor lets go once back under threshold. | +| Low Comp Makeup | −12 … +24 | 0 | dB | Gain applied to the compressed (wet) signal before it's blended back with the dry low band - use this to bring the compressed signal back up to match the dry level, so Mix behaves as a true "how much compression character" control rather than also changing overall loudness. | +| Low Comp Mix | 0 … 100 | 100 | % | Blend between the dry (uncompressed) and wet (compressed + makeup) low band. 0% = compressor has no audible effect; 100% = fully compressed. | +| Low Level | −24 … +12 | 0 | dB | Level trim on the low band, applied after compression and before the bands are summed back together. | + +### High band: voicing, drive, tone, blend, level + +Three selectable distortion voicings, each oversampled (4x) to keep the nonlinear shaping stage's aliasing out of the audible band. + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| High Voicing | Gnaw / Wool / Razor | Gnaw | — | Selects the distortion character. See below. | +| High Drive | 0 … 100 | 50 | % | How hard the signal is pushed into the selected voicing's nonlinearity. | +| High Tone | 0 … 100 | 50 | % | Post-shaper tone control: a low-pass sweeping from dark (0%) to bright (100%), tucking away or opening up fizz/harshness from the distortion stage. | +| High Blend | 0 … 100 | 100 | % | Blend between the clean (pre-voicing) and fully distorted high band. 0% = clean high band (voicing has no audible effect); 100% = fully distorted. | +| High Level | −24 … +12 | 0 | dB | Level trim on the high band, applied after voicing/blend and before the bands are summed back together. | + +**Voicings:** + +- **Gnaw** — an op-amp-style hard clip. Symmetric, unforgiving, the most aggressive of the three; pushes hard into a square-ish waveform at high drive. Good for a raw, buzzy attack. +- **Wool** — cascaded soft-clip fuzz with a mid scoop and a touch of asymmetry for a grittier, more fuzz-pedal-like harmonic character. Good for a woolier, less "digital" grind that still cuts. +- **Razor** — a tighter overdrive: the signal is high-passed before the (comparatively mild) clipper, and a mid-hump filter afterwards keeps the low end from ever getting mushy. Good for definition and pick/finger attack without piling on low-end mud. + +*Starting points, not final voicing:* the drive-gain ranges and mid-filter hump/scoop settings for all three voicings are engineering defaults, tuned for musical usefulness and mathematically bounded (no runaway output at any drive setting), not yet finalized by ear against reference material. Expect these to be refined in a future release. + +### Post-sum 4-band EQ + +Applied after the low/high bands are summed back together. Off by default; when off, the EQ stage is skipped entirely (guaranteed transparent, not just set to unity gain). + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| EQ Enable | off/on | off | — | Enables the EQ stage. | +| EQ Low Shelf Frequency | 40 … 400 | 100 | Hz | Low shelf corner frequency. | +| EQ Low Shelf Gain | −18 … +18 | 0 | dB | Low shelf boost/cut. | +| EQ Peak 1 Frequency | 100 … 2000 | 500 | Hz | First parametric peak band's centre frequency. | +| EQ Peak 1 Gain | −18 … +18 | 0 | dB | First peak band's boost/cut. | +| EQ Peak 1 Q | 0.2 … 5.0 | 0.7 | — | First peak band's bandwidth (higher = narrower). | +| EQ Peak 2 Frequency | 500 … 8000 | 2500 | Hz | Second parametric peak band's centre frequency. | +| EQ Peak 2 Gain | −18 … +18 | 0 | dB | Second peak band's boost/cut. | +| EQ Peak 2 Q | 0.2 … 5.0 | 0.7 | — | Second peak band's bandwidth. | +| EQ High Shelf Frequency | 2000 … 16000 | 8000 | Hz | High shelf corner frequency. | +| EQ High Shelf Gain | −18 … +18 | 0 | dB | High shelf boost/cut. | + +### IR loader (cabinet simulation) + +A convolution-based cab-sim stage on the very end of the chain, before the safety clip. Off by default. With no impulse response loaded, this stage is a guaranteed bit-exact passthrough at every session sample rate, so turning it on before loading an IR never changes your sound. + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| IR Enable | off/on | off | — | Enables the IR loader stage. | +| IR Mix | 0 … 100 | 100 | % | Blend between the dry (pre-convolution) and fully convolved signal. | + +*Loading impulse responses:* v0.1.0 does not yet ship an in-plugin file browser or factory cabinet IRs (both are on the roadmap for a later milestone alongside the custom GUI). The IR-loading DSP engine itself is fully implemented and real-time safe; a GUI file picker will be wired up to it once the custom interface lands. + +## Tips + +- **Start with the low band tight, then dial in the high band's grind.** Set Low Comp Mix and Makeup first so the fundamental feels locked in, *then* pick a voicing and drive amount - it's much easier to judge how much distortion character you actually need once the low end already feels solid. +- **The crossover point is a tone decision, not just a technical one.** Pushing it up (toward 400–600 Hz) moves more of the note's body into the distorted high band, which can help a bass cut through a dense guitar wall at the cost of low-end weight; pulling it down (toward 100–150 Hz) keeps more of the note clean/compressed and reserves the distortion for genuinely high harmonic content. +- **High Blend is your "how much" knob, High Drive is your "how hard" knob.** If a voicing feels too extreme, try lowering Blend before lowering Drive - you'll often keep more of the character that way, just at a lower overall intensity, rather than flattening the nonlinearity itself. +- **Razor plus a mild EQ high-shelf cut** is a good starting point if a mix feels harsh/fizzy - Razor's pre-clip highpass already keeps the low end tight, so a touch of top-end EQ taming after the fact is usually enough rather than reaching for less drive. +- **Leave the safety clip off during tracking/mixing**, and only reach for it as insurance against unexpected automation or a hot input on a specific pass - it's a safety net, not part of the intended tone-shaping signal path. diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index 31911cc..7eda776 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -28,6 +28,39 @@ TwistYourGutsAudioProcessor::TwistYourGutsAudioProcessor() highLevelDb = apvts.getRawParameterValue (ParamIDs::highLevel); bypassParameter = apvts.getParameter (ParamIDs::bypass); + gateEnabled = apvts.getRawParameterValue (ParamIDs::gateEnabled); + gateThresholdDb = apvts.getRawParameterValue (ParamIDs::gateThreshold); + gateRatio = apvts.getRawParameterValue (ParamIDs::gateRatio); + gateAttackMs = apvts.getRawParameterValue (ParamIDs::gateAttack); + gateReleaseMs = apvts.getRawParameterValue (ParamIDs::gateRelease); + + lowCompThresholdDb = apvts.getRawParameterValue (ParamIDs::lowCompThreshold); + lowCompRatio = apvts.getRawParameterValue (ParamIDs::lowCompRatio); + lowCompAttackMs = apvts.getRawParameterValue (ParamIDs::lowCompAttack); + lowCompReleaseMs = apvts.getRawParameterValue (ParamIDs::lowCompRelease); + lowCompMakeupDb = apvts.getRawParameterValue (ParamIDs::lowCompMakeup); + lowCompMixPercent = apvts.getRawParameterValue (ParamIDs::lowCompMix); + + highVoicingChoice = apvts.getRawParameterValue (ParamIDs::highVoicing); + highDrivePercent = apvts.getRawParameterValue (ParamIDs::highDrive); + highTonePercent = apvts.getRawParameterValue (ParamIDs::highTone); + highBlendPercent = apvts.getRawParameterValue (ParamIDs::highBlend); + + eqEnabled = apvts.getRawParameterValue (ParamIDs::eqEnabled); + eqLowShelfFreqHz = apvts.getRawParameterValue (ParamIDs::eqLowShelfFreq); + eqLowShelfGainDb = apvts.getRawParameterValue (ParamIDs::eqLowShelfGain); + eqPeak1FreqHz = apvts.getRawParameterValue (ParamIDs::eqPeak1Freq); + eqPeak1GainDb = apvts.getRawParameterValue (ParamIDs::eqPeak1Gain); + eqPeak1Q = apvts.getRawParameterValue (ParamIDs::eqPeak1Q); + eqPeak2FreqHz = apvts.getRawParameterValue (ParamIDs::eqPeak2Freq); + eqPeak2GainDb = apvts.getRawParameterValue (ParamIDs::eqPeak2Gain); + eqPeak2Q = apvts.getRawParameterValue (ParamIDs::eqPeak2Q); + eqHighShelfFreqHz = apvts.getRawParameterValue (ParamIDs::eqHighShelfFreq); + eqHighShelfGainDb = apvts.getRawParameterValue (ParamIDs::eqHighShelfGain); + + irEnabled = apvts.getRawParameterValue (ParamIDs::irEnabled); + irMixPercent = apvts.getRawParameterValue (ParamIDs::irMix); + jassert (inputGainDb != nullptr); jassert (outputGainDb != nullptr); jassert (bypassFlag != nullptr); @@ -36,6 +69,39 @@ TwistYourGutsAudioProcessor::TwistYourGutsAudioProcessor() jassert (lowLevelDb != nullptr); jassert (highLevelDb != nullptr); jassert (bypassParameter != nullptr); + + jassert (gateEnabled != nullptr); + jassert (gateThresholdDb != nullptr); + jassert (gateRatio != nullptr); + jassert (gateAttackMs != nullptr); + jassert (gateReleaseMs != nullptr); + + jassert (lowCompThresholdDb != nullptr); + jassert (lowCompRatio != nullptr); + jassert (lowCompAttackMs != nullptr); + jassert (lowCompReleaseMs != nullptr); + jassert (lowCompMakeupDb != nullptr); + jassert (lowCompMixPercent != nullptr); + + jassert (highVoicingChoice != nullptr); + jassert (highDrivePercent != nullptr); + jassert (highTonePercent != nullptr); + jassert (highBlendPercent != nullptr); + + jassert (eqEnabled != nullptr); + jassert (eqLowShelfFreqHz != nullptr); + jassert (eqLowShelfGainDb != nullptr); + jassert (eqPeak1FreqHz != nullptr); + jassert (eqPeak1GainDb != nullptr); + jassert (eqPeak1Q != nullptr); + jassert (eqPeak2FreqHz != nullptr); + jassert (eqPeak2GainDb != nullptr); + jassert (eqPeak2Q != nullptr); + jassert (eqHighShelfFreqHz != nullptr); + jassert (eqHighShelfGainDb != nullptr); + + jassert (irEnabled != nullptr); + jassert (irMixPercent != nullptr); } TwistYourGutsAudioProcessor::~TwistYourGutsAudioProcessor() = default; @@ -111,11 +177,24 @@ void TwistYourGutsAudioProcessor::prepareToPlay (double sampleRate, int samplesP outputGainProcessor.prepare (spec); outputGainProcessor.setGainDecibels (outputGainDb->load (std::memory_order_relaxed)); + // Full-band input noise gate. + gate.prepare (spec); + // Issue #8: LR4 crossover, prepared for the same spec as the gain // processors so its per-channel filter state matches the bus layout. crossover.prepare (spec); crossover.setCutoffFrequency (crossoverFreqHz->load (std::memory_order_relaxed)); + // Low-band parallel compressor. The DryWetMixer inside needs its mix + // proportion primed *before* prepare() runs its internal reset() (JUCE + // 8.0.14 gotcha - see docs/architecture.md), so the current lowCompMix + // value is read and passed in here rather than set afterwards. + lowCompressor.prepare (spec, lowCompMixPercent->load (std::memory_order_relaxed) / 100.0f); + + // High-band oversampled distortion voicing. Same DryWetMixer-priming + // requirement for highBlend. + highVoicing.prepare (spec, highBlendPercent->load (std::memory_order_relaxed) / 100.0f); + // Issue #10: independent per-band level trims, smoothed the same way as // the input/output gains to avoid zipper noise on automation. lowGainProcessor.setRampDurationSeconds (gainRampDurationSeconds); @@ -126,6 +205,12 @@ void TwistYourGutsAudioProcessor::prepareToPlay (double sampleRate, int samplesP highGainProcessor.prepare (spec); highGainProcessor.setGainDecibels (highLevelDb->load (std::memory_order_relaxed)); + // Post-sum 4-band EQ. + eq.prepare (spec); + + // IR loader. Same DryWetMixer-priming requirement for irMix. + irLoader.prepare (spec, irMixPercent->load (std::memory_order_relaxed) / 100.0f); + // Issue #9: (re)allocate the low-band compensation delay line for the // new spec/max-delay bound. setMaximumDelayInSamples() may allocate, so // it must only ever be called here, never from processBlock(). @@ -146,15 +231,13 @@ void TwistYourGutsAudioProcessor::prepareToPlay (double sampleRate, int samplesP //============================================================================== int TwistYourGutsAudioProcessor::computeTotalLatencySamples() const noexcept { - // M3 (oversampling) will replace this stub with the high-band - // oversampling filter's reported latency (e.g. - // juce::dsp::Oversampling::getLatencyInSamples()) once the high band is - // oversampled ahead of the nonlinear voicing stage. Until then the high - // band is not delayed relative to the low band, so there is nothing to - // compensate for and this legitimately returns 0 - it is a real seam - // computing a real (currently zero) value, not a mislabeled no-op. - constexpr int highBandOversamplingLatencySamples = 0; - return highBandOversamplingLatencySamples; + // Issue #42: the high band's oversampled voicing stage is the only + // source of latency in the chain (the gate, low-band compressor, EQ and + // IR loader - default zero-latency Convolution - are all zero-latency), + // so the plugin's total reported latency is exactly the oversampling + // latency, and that is exactly what the low-band compensation delay + // needs to match to stay time-aligned with the high band at the sum. + return highVoicing.getLatencySamples(); } void TwistYourGutsAudioProcessor::updateLatencyCompensation() @@ -163,10 +246,10 @@ void TwistYourGutsAudioProcessor::updateLatencyCompensation() setLatencySamples (totalLatencySamples); - // The low band bypasses oversampling entirely, so once M3 lands it must - // be delayed by the same amount the high band's oversampling stage - // delays the high band, keeping both bands time-aligned when they are - // summed back together in processChunk(). + // The low band bypasses oversampling entirely, so it must be delayed by + // the same amount the high band's oversampling stage delays the high + // band, keeping both bands time-aligned when they are summed back + // together in processChunk(). lowBandLatencyDelay.setDelay (static_cast (totalLatencySamples)); } @@ -207,15 +290,41 @@ void TwistYourGutsAudioProcessor::processBlock (juce::AudioBuffer& buffer return; // Parameters are read once per host block (not per chunk/sample): the - // dsp::Gain smoothers and the crossover's cutoff recompute cheaply - // enough at control rate, and re-reading the same atomic per chunk would - // buy nothing. + // dsp::Gain smoothers and the various stages' own control-rate updates + // recompute cheaply enough at control rate, and re-reading the same + // atomic per chunk would buy nothing. inputGainProcessor.setGainDecibels (inputGainDb->load (std::memory_order_relaxed)); outputGainProcessor.setGainDecibels (outputGainDb->load (std::memory_order_relaxed)); lowGainProcessor.setGainDecibels (lowLevelDb->load (std::memory_order_relaxed)); highGainProcessor.setGainDecibels (highLevelDb->load (std::memory_order_relaxed)); crossover.setCutoffFrequency (crossoverFreqHz->load (std::memory_order_relaxed)); + gate.setEnabled (gateEnabled->load (std::memory_order_relaxed) >= 0.5f); + gate.setThresholdDb (gateThresholdDb->load (std::memory_order_relaxed)); + gate.setRatio (gateRatio->load (std::memory_order_relaxed)); + gate.setAttackMs (gateAttackMs->load (std::memory_order_relaxed)); + gate.setReleaseMs (gateReleaseMs->load (std::memory_order_relaxed)); + + lowCompressor.setThresholdDb (lowCompThresholdDb->load (std::memory_order_relaxed)); + lowCompressor.setRatio (lowCompRatio->load (std::memory_order_relaxed)); + lowCompressor.setAttackMs (lowCompAttackMs->load (std::memory_order_relaxed)); + lowCompressor.setReleaseMs (lowCompReleaseMs->load (std::memory_order_relaxed)); + lowCompressor.setMakeupGainDb (lowCompMakeupDb->load (std::memory_order_relaxed)); + lowCompressor.setWetMixProportion (lowCompMixPercent->load (std::memory_order_relaxed) / 100.0f); + + const auto voicingIndex = static_cast (highVoicingChoice->load (std::memory_order_relaxed)); + highVoicing.setVoicing (static_cast (juce::jlimit (0, 2, voicingIndex))); + highVoicing.setDrive (highDrivePercent->load (std::memory_order_relaxed) / 100.0f); + highVoicing.setTone (highTonePercent->load (std::memory_order_relaxed) / 100.0f); + highVoicing.setWetMixProportion (highBlendPercent->load (std::memory_order_relaxed) / 100.0f); + + eq.setLowShelf (eqLowShelfFreqHz->load (std::memory_order_relaxed), eqLowShelfGainDb->load (std::memory_order_relaxed)); + eq.setPeak1 (eqPeak1FreqHz->load (std::memory_order_relaxed), eqPeak1GainDb->load (std::memory_order_relaxed), eqPeak1Q->load (std::memory_order_relaxed)); + eq.setPeak2 (eqPeak2FreqHz->load (std::memory_order_relaxed), eqPeak2GainDb->load (std::memory_order_relaxed), eqPeak2Q->load (std::memory_order_relaxed)); + eq.setHighShelf (eqHighShelfFreqHz->load (std::memory_order_relaxed), eqHighShelfGainDb->load (std::memory_order_relaxed)); + + irLoader.setWetMixProportion (irMixPercent->load (std::memory_order_relaxed) / 100.0f); + juce::dsp::AudioBlock fullBlock (buffer); // Defensive chunking: hosts are expected to never exceed the block size @@ -239,18 +348,27 @@ void TwistYourGutsAudioProcessor::processChunk (juce::dsp::AudioBlock& ch { inputGainProcessor.process (juce::dsp::ProcessContextReplacing (chunk)); + // Full-band noise gate, ahead of the crossover split. + gate.process (chunk); + const auto numChannels = chunk.getNumChannels(); const auto numSamples = chunk.getNumSamples(); auto lowBlock = juce::dsp::AudioBlock (lowBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); auto highBlock = juce::dsp::AudioBlock (highBandBuffer).getSubBlock (0, numSamples).getSubsetChannelBlock (0, numChannels); - // Issue #8: split the input-trimmed signal into low/high bands. + // Issue #8: split the input-trimmed, gated signal into low/high bands. crossover.process (chunk, lowBlock, highBlock); - // Issue #9: time-align the low band with the (currently zero) latency - // the high band will pick up once M3 adds oversampling ahead of the - // nonlinear voicing stage. + // Low band: parallel compressor, then level trim. + lowCompressor.process (lowBlock); + + // High band: oversampled distortion voicing (Gnaw/Wool/Razor), then + // level trim. This is the only source of latency in the chain. + highVoicing.process (highBlock); + + // Issue #9: time-align the low band with the latency the high band's + // oversampling stage introduces. lowBandLatencyDelay.process (juce::dsp::ProcessContextReplacing (lowBlock)); // Issue #10: independent per-band level trims, then sum the bands back @@ -260,6 +378,17 @@ void TwistYourGutsAudioProcessor::processChunk (juce::dsp::AudioBlock& ch chunk.replaceWithSumOf (lowBlock, highBlock); + // Post-sum 4-band EQ. Skipped entirely when disabled for a guaranteed + // bit-exact bypass rather than relying on all-zero band gains. + if (eqEnabled->load (std::memory_order_relaxed) >= 0.5f) + eq.process (chunk); + + // Cab-sim IR loader. Skipped entirely when disabled for the same reason + // (and safe-by-default even when enabled with no IR loaded yet - see + // tyg::IRLoader). + if (irEnabled->load (std::memory_order_relaxed) >= 0.5f) + irLoader.process (chunk); + // Optional safety clip (issue #10): a soft (tanh) limiter that only // engages when the user explicitly enables it, protecting against // accidental hard-clipped overs without colouring the signal at typical @@ -311,6 +440,12 @@ void TwistYourGutsAudioProcessor::setStateInformation (const void* data, int siz apvts.replaceState (juce::ValueTree::fromXml (*xmlState)); } +//============================================================================== +void TwistYourGutsAudioProcessor::loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate) +{ + irLoader.loadImpulseResponse (std::move (irBuffer), irSampleRate); +} + //============================================================================== // This creates new instances of the plugin. juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index 1359d9f..bbe2fdb 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -3,15 +3,20 @@ #include #include +#include "dsp/BandEQ.h" #include "dsp/Crossover.h" - -// As of M1 (issues #7-#10), the APVTS layout declares the complete, ID-frozen -// v1.0 parameter set (see src/params/ParameterIds.h and ParameterLayout.h), -// and processBlock() implements the gain-staged LR4 crossover core: input -// trim -> crossover split -> per-band level -> sum -> optional safety clip -> -// output trim. Compression, high-band voicing/drive, EQ and the IR loader -// remain declared-but-inert until their own milestone (M2 dynamics, M3 -// distortion, M4 EQ/IR) wires them into the signal chain. +#include "dsp/IRLoader.h" +#include "dsp/NoiseGateStage.h" +#include "dsp/ParallelCompressor.h" +#include "dsp/Voicing.h" + +// As of the M1 "DSP completion" milestone (issue #42), the full v1.0 signal +// path from the README is wired and live: input trim -> noise gate -> LR4 +// crossover split -> [low: parallel compressor -> level] + [high: voicing +// (Gnaw/Wool/Razor, oversampled) -> level] -> delay-compensated sum -> +// 4-band EQ -> IR loader (cab sim) -> optional safety clip -> output trim. +// See docs/architecture.md for the full breakdown and docs/manual.md for the +// user-facing parameter reference. class TwistYourGutsAudioProcessor final : public juce::AudioProcessor { public: @@ -56,49 +61,67 @@ class TwistYourGutsAudioProcessor final : public juce::AudioProcessor juce::AudioProcessorValueTreeState apvts; + // Loads a new cab-sim impulse response into the IR loader stage. Not + // real-time safe by contract (see tyg::IRLoader) - call from the message + // thread only (e.g. in response to a future GUI file picker or preset + // load), never from processBlock() or any audio-thread callback. + void loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate); + private: //============================================================================== // Latency-compensation seam (issue #9): computes the plugin's total - // reported latency and re-arms the low-band compensation delay to match - // it. Currently always 0 (no oversampling exists yet), but this is a - // real seam, not a mislabeled no-op - M3's oversampled high-band voicing - // stage will report a non-zero latency here, and the low-band delay - // line below will pick it up automatically so both bands stay time- - // aligned when summed. Called once from prepareToPlay(). + // reported latency (now the high-band voicing's oversampling latency, + // issue #42) and re-arms the low-band compensation delay to match it. + // Called once from prepareToPlay(). int computeTotalLatencySamples() const noexcept; void updateLatencyCompensation(); - // Processes at most `preparedBlockSize` samples: crossover split, per- - // band level, sum, optional safety clip. Called once per chunk from - // processBlock() so oversized host blocks (larger than prepareToPlay - // promised) are handled defensively without ever resizing a buffer on - // the audio thread. + // Processes at most `preparedBlockSize` samples: gate, crossover split, + // per-band dynamics/voicing, per-band level, sum, EQ, IR loader, + // optional safety clip. Called once per chunk from processBlock() so + // oversized host blocks (larger than prepareToPlay promised) are handled + // defensively without ever resizing a buffer on the audio thread. void processChunk (juce::dsp::AudioBlock& chunk) noexcept; //============================================================================== juce::dsp::Gain inputGainProcessor; juce::dsp::Gain outputGainProcessor; - // Issue #8: LR4 crossover splitting the (input-trimmed) signal into low - // and high bands ahead of independent per-band processing. + // Full-band input noise gate, ahead of the crossover split. + tyg::NoiseGateStage gate; + + // Issue #8: LR4 crossover splitting the (input-trimmed, gated) signal + // into low and high bands ahead of independent per-band processing. tyg::Crossover crossover; - // Issue #10: independent per-band level trims applied after the split - // and before the bands are summed back together. + // Low band: parallel compressor, then level trim. + tyg::ParallelCompressor lowCompressor; + + // High band: selectable oversampled distortion voicing (Gnaw/Wool/ + // Razor), then level trim. + tyg::Voicing highVoicing; + + // Issue #10: independent per-band level trims applied after each band's + // dynamics/voicing processing and before the bands are summed back + // together. juce::dsp::Gain lowGainProcessor; juce::dsp::Gain highGainProcessor; + // Post-sum 4-band EQ and cab-sim IR loader. + tyg::BandEQ eq; + tyg::IRLoader irLoader; + // Issue #9: upper bound on the latency this plugin will ever need to - // compensate for, i.e. the largest oversampling latency M3's high-band - // voicing stage is expected to introduce. Generous headroom (a few - // hundred samples at typical sample rates covers even high-order/high- - // ratio oversampling FIR latencies) at negligible memory cost. - static constexpr int maxLatencyCompensationSamples = 1024; - - // Delays the low band so it stays time-aligned with the high band once - // M3 gives the high band a non-zero oversampling latency. None- - // interpolation is correct here: latency compensation is always an - // integer number of samples, never a fractionally modulated delay. + // compensate for, i.e. the largest oversampling latency the high-band + // voicing stage is expected to introduce. Generous headroom well above + // the actual 4x-oversampling FIR latency at any supported sample rate, + // at negligible memory cost. + static constexpr int maxLatencyCompensationSamples = 4096; + + // Delays the low band so it stays time-aligned with the oversampled high + // band. None-interpolation is correct here: latency compensation is + // always an integer number of samples, never a fractionally modulated + // delay. juce::dsp::DelayLine lowBandLatencyDelay { maxLatencyCompensationSamples }; // Pre-allocated band buffers, sized to `preparedBlockSize` in @@ -118,6 +141,39 @@ class TwistYourGutsAudioProcessor final : public juce::AudioProcessor std::atomic* lowLevelDb = nullptr; std::atomic* highLevelDb = nullptr; + std::atomic* gateEnabled = nullptr; + std::atomic* gateThresholdDb = nullptr; + std::atomic* gateRatio = nullptr; + std::atomic* gateAttackMs = nullptr; + std::atomic* gateReleaseMs = nullptr; + + std::atomic* lowCompThresholdDb = nullptr; + std::atomic* lowCompRatio = nullptr; + std::atomic* lowCompAttackMs = nullptr; + std::atomic* lowCompReleaseMs = nullptr; + std::atomic* lowCompMakeupDb = nullptr; + std::atomic* lowCompMixPercent = nullptr; + + std::atomic* highVoicingChoice = nullptr; + std::atomic* highDrivePercent = nullptr; + std::atomic* highTonePercent = nullptr; + std::atomic* highBlendPercent = nullptr; + + std::atomic* eqEnabled = nullptr; + std::atomic* eqLowShelfFreqHz = nullptr; + std::atomic* eqLowShelfGainDb = nullptr; + std::atomic* eqPeak1FreqHz = nullptr; + std::atomic* eqPeak1GainDb = nullptr; + std::atomic* eqPeak1Q = nullptr; + std::atomic* eqPeak2FreqHz = nullptr; + std::atomic* eqPeak2GainDb = nullptr; + std::atomic* eqPeak2Q = nullptr; + std::atomic* eqHighShelfFreqHz = nullptr; + std::atomic* eqHighShelfGainDb = nullptr; + + std::atomic* irEnabled = nullptr; + std::atomic* irMixPercent = nullptr; + // The actual parameter object handed back from getBypassParameter() so // hosts can offer their own bypass UI/automation for this parameter. juce::RangedAudioParameter* bypassParameter = nullptr; diff --git a/src/dsp/BandEQ.cpp b/src/dsp/BandEQ.cpp new file mode 100644 index 0000000..8fa23e8 --- /dev/null +++ b/src/dsp/BandEQ.cpp @@ -0,0 +1,58 @@ +#include "BandEQ.h" + +namespace +{ + // Standard "flat"/Butterworth shelf slope (Q = 1/sqrt(2)), matching the + // implicit default juce::dsp::IIR::ArrayCoefficients::makeLowShelf/ + // makeHighShelf use when no explicit Q is given. + constexpr float shelfQ = 0.70710678f; +} + +namespace tyg +{ + void BandEQ::prepare (const juce::dsp::ProcessSpec& spec) + { + sampleRate = spec.sampleRate; + + lowShelf.prepare (spec); + peak1.prepare (spec); + peak2.prepare (spec); + highShelf.prepare (spec); + } + + void BandEQ::reset() + { + lowShelf.reset(); + peak1.reset(); + peak2.reset(); + highShelf.reset(); + } + + void BandEQ::setLowShelf (float freqHz, float gainDb) noexcept + { + const auto gainFactor = juce::Decibels::decibelsToGain (gainDb); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makeLowShelf (sampleRate, freqHz, shelfQ, gainFactor); + applyBiquadCoefficients (*lowShelf.state, raw); + } + + void BandEQ::setPeak1 (float freqHz, float gainDb, float q) noexcept + { + const auto gainFactor = juce::Decibels::decibelsToGain (gainDb); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makePeakFilter (sampleRate, freqHz, q, gainFactor); + applyBiquadCoefficients (*peak1.state, raw); + } + + void BandEQ::setPeak2 (float freqHz, float gainDb, float q) noexcept + { + const auto gainFactor = juce::Decibels::decibelsToGain (gainDb); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makePeakFilter (sampleRate, freqHz, q, gainFactor); + applyBiquadCoefficients (*peak2.state, raw); + } + + void BandEQ::setHighShelf (float freqHz, float gainDb) noexcept + { + const auto gainFactor = juce::Decibels::decibelsToGain (gainDb); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makeHighShelf (sampleRate, freqHz, shelfQ, gainFactor); + applyBiquadCoefficients (*highShelf.state, raw); + } +} diff --git a/src/dsp/BandEQ.h b/src/dsp/BandEQ.h new file mode 100644 index 0000000..1cc97a7 --- /dev/null +++ b/src/dsp/BandEQ.h @@ -0,0 +1,61 @@ +#pragma once + +#include "RealtimeCoefficients.h" + +#include + +// Post-sum 4-band EQ (issue #42): LowShelf -> Peak1 -> Peak2 -> HighShelf, +// run in series after the low/high bands are summed back together. +// +// Each band is a juce::dsp::ProcessorDuplicator, +// IIR::Coefficients> (JUCE 8.0.14, juce_dsp/processors/ +// juce_ProcessorDuplicator.h / juce_IIRFilter.h) so a single instance covers +// mono or stereo. Coefficients are *never* replaced wholesale from +// processBlock() (juce::dsp::IIR::Coefficients::makeLowShelf/ +// makePeakFilter/makeHighShelf heap-allocate a new Coefficients object every +// call, which is not real-time safe for parameters that can be automated +// continuously). Instead, prepare() allocates each band's Coefficients +// object once (2nd-order form, via the raw 6-argument constructor with +// placeholder identity values), and every subsequent parameter change +// overwrites that same object's raw coefficient storage in place via +// tyg::applyBiquadCoefficients(), using +// juce::dsp::IIR::ArrayCoefficients::makeXxx() (stack-only, zero +// allocation) as the source of the new values. See RealtimeCoefficients.h. +namespace tyg +{ + class BandEQ + { + public: + BandEQ() = default; + + void prepare (const juce::dsp::ProcessSpec& spec); + void reset(); + + // freqHz/gainDb/q are all real-time safe to call every block: they + // recompute a stack-only coefficient set and copy it in place into + // already-allocated storage (see class comment above). + void setLowShelf (float freqHz, float gainDb) noexcept; + void setPeak1 (float freqHz, float gainDb, float q) noexcept; + void setPeak2 (float freqHz, float gainDb, float q) noexcept; + void setHighShelf (float freqHz, float gainDb) noexcept; + + void process (juce::dsp::AudioBlock& block) noexcept + { + juce::dsp::ProcessContextReplacing context (block); + lowShelf.process (context); + peak1.process (context); + peak2.process (context); + highShelf.process (context); + } + + private: + using Duplicator = juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients>; + + double sampleRate = 44100.0; + + Duplicator lowShelf { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + Duplicator peak1 { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + Duplicator peak2 { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + Duplicator highShelf { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + }; +} diff --git a/src/dsp/IRLoader.cpp b/src/dsp/IRLoader.cpp new file mode 100644 index 0000000..9b497fe --- /dev/null +++ b/src/dsp/IRLoader.cpp @@ -0,0 +1,67 @@ +#include "IRLoader.h" + +namespace +{ + // A single-sample, unity-gain impulse response tagged with the *actual* + // session sample rate, so juce::dsp::Convolution's internal resampling + // step (juce_Convolution.cpp's resampleImpulseResponse()) is a no-op + // (exact rate match) rather than smearing/attenuating the impulse - see + // the class-level comment in IRLoader.h for why this matters. + juce::AudioBuffer makeIdentityImpulseResponse() + { + juce::AudioBuffer identity (2, 1); + identity.setSample (0, 0, 1.0f); + identity.setSample (1, 0, 1.0f); + return identity; + } +} + +namespace tyg +{ + void IRLoader::prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01) + { + // Explicitly (re)install a correctly-rate-tagged identity IR *before* + // convolution.prepare(), per JUCE's own recommendation ("it is + // recommended to call loadImpulseResponse() before prepare() if a + // specific IR must be active during the first process() call") - so + // "no IR loaded yet" is a guaranteed bit-exact passthrough at this + // session's sample rate from the very first block (see class-level + // comment in IRLoader.h). loadImpulseResponse() itself is wait-free/ + // real-time safe per JUCE's docs, but this call happens here in + // prepare() (message thread), never from processBlock(). + convolution.loadImpulseResponse (makeIdentityImpulseResponse(), + spec.sampleRate, + juce::dsp::Convolution::Stereo::yes, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); + + convolution.prepare (spec); + + // Prime the mix *before* prepare() so the mixer's internal reset() + // (called at the end of DryWetMixer::prepare()) snaps its smoothed + // dry/wet volumes to the correct starting point instead of a stale + // default (JUCE 8.0.14 DryWetMixer gotcha). + mixer.setMixingRule (juce::dsp::DryWetMixingRule::linear); + mixer.setWetMixProportion (initialWetMixProportion01); + mixer.prepare (spec); + + // Convolution() defaults to Latency{0} (zero added latency by + // construction), so the dry path never needs compensating delay. + mixer.setWetLatency (0.0f); + } + + void IRLoader::reset() + { + convolution.reset(); + mixer.reset(); + } + + void IRLoader::loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate) + { + convolution.loadImpulseResponse (std::move (irBuffer), + irSampleRate, + juce::dsp::Convolution::Stereo::yes, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::yes); + } +} diff --git a/src/dsp/IRLoader.h b/src/dsp/IRLoader.h new file mode 100644 index 0000000..25a4072 --- /dev/null +++ b/src/dsp/IRLoader.h @@ -0,0 +1,77 @@ +#pragma once + +#include + +// Cab-sim IR loader (issue #42), the final stage before output. Wraps +// juce::dsp::Convolution (JUCE 8.0.14, juce_dsp/frequency/juce_Convolution.h) +// configured for zero added latency (the default `Convolution()` / +// `Convolution(Latency{0})` constructor), with an irMix DryWetMixer wrapped +// around it so the loaded IR can be blended rather than fully replacing the +// signal. +// +// Safe-by-default without any bundled factory IR: juce::dsp::Convolution +// with no loadImpulseResponse() call yet made falls back to an internal +// single-sample identity impulse response (JUCE 8.0.14 +// ConvolutionEngineFactory::makeImpulseBuffer(), juce_Convolution.cpp). +// *However*, that internal fallback's assumed source sample rate is +// hardcoded to the ProcessSpec default (44100 Hz) and is never updated by +// prepare() - only by an explicit loadImpulseResponse() call - so at any +// *other* session sample rate, Convolution would silently resample that +// single-sample impulse against a mismatched rate, smearing/attenuating it +// (juce_Convolution.cpp's resampleImpulseResponse() only skips resampling +// when the rates match exactly) and quietly colouring the signal even with +// no IR ever loaded. prepare() below closes that gap by explicitly loading +// a correctly-rate-tagged identity impulse response itself, so "no IR +// loaded" is a guaranteed bit-exact passthrough at every session sample +// rate, not only at 44100 Hz. +// +// Bundling license-clean factory cab IRs is its own ear-tuning-gated +// milestone (see docs/manual.md); loadImpulseResponse() here is the DSP-side +// seam a future GUI file browser (or preset system) will call. +// +// Threading: per JUCE's docs, Convolution::loadImpulseResponse() is +// wait-free and safe to call from the audio thread, but this class's +// loadImpulseResponse() is intended to be called from the message thread +// (e.g. in response to a GUI file-picker or preset load) - it takes +// ownership of (moves) the supplied buffer, so the caller must not also +// touch it on the audio thread afterwards. +namespace tyg +{ + class IRLoader + { + public: + IRLoader() = default; + + // `initialWetMixProportion01` must be the current irMix value + // (0..1) *before* prepare() runs the internal DryWetMixer's + // reset(): see the same gotcha documented on tyg::Voicing::prepare(). + void prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01); + void reset(); + + void setWetMixProportion (float wetMixProportion01) noexcept { mixer.setWetMixProportion (wetMixProportion01); } + + // Loads a new impulse response. Not real-time safe by contract of + // this wrapper (even though the underlying JUCE call is wait-free) - + // call from the message thread only. `irSampleRate` is the sample + // rate the buffer's samples were captured/generated at; Convolution + // resamples internally to match the session's sample rate. + void loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate); + + // In-place: convolution + irMix dry/wet blend. Callers should skip + // calling this entirely when irEnabled is off, for a guaranteed + // bit-exact bypass rather than relying on mix==0. + void process (juce::dsp::AudioBlock& block) noexcept + { + mixer.pushDrySamples (juce::dsp::AudioBlock (block)); + + juce::dsp::ProcessContextReplacing context (block); + convolution.process (context); + + mixer.mixWetSamples (block); + } + + private: + juce::dsp::Convolution convolution; + juce::dsp::DryWetMixer mixer; + }; +} diff --git a/src/dsp/NoiseGateStage.cpp b/src/dsp/NoiseGateStage.cpp new file mode 100644 index 0000000..c500f27 --- /dev/null +++ b/src/dsp/NoiseGateStage.cpp @@ -0,0 +1,14 @@ +#include "NoiseGateStage.h" + +namespace tyg +{ + void NoiseGateStage::prepare (const juce::dsp::ProcessSpec& spec) + { + gate.prepare (spec); + } + + void NoiseGateStage::reset() + { + gate.reset(); + } +} diff --git a/src/dsp/NoiseGateStage.h b/src/dsp/NoiseGateStage.h new file mode 100644 index 0000000..759f377 --- /dev/null +++ b/src/dsp/NoiseGateStage.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +// Full-band input noise gate (issue #42), sitting between input trim and the +// LR4 crossover split in the signal chain. Thin wrapper around +// juce::dsp::NoiseGate (JUCE 8.0.14, +// juce_dsp/widgets/juce_NoiseGate.h) so the processor and the test suite +// share one real-time-safe seam, matching the pattern already established by +// tyg::Crossover. +// +// juce::dsp::NoiseGate's ballistics filters carry their own per-channel +// state internally (via BallisticsFilter, indexed by the `channel` argument +// passed to processSample()), so a single instance handles mono/stereo +// without any extra per-channel bookkeeping here. +namespace tyg +{ + class NoiseGateStage + { + public: + NoiseGateStage() = default; + + void prepare (const juce::dsp::ProcessSpec& spec); + void reset(); + + void setEnabled (bool shouldBeEnabled) noexcept { enabled = shouldBeEnabled; } + bool isEnabled() const noexcept { return enabled; } + + // Real-time safe: NoiseGate::setThreshold/setRatio/setAttack/ + // setRelease just recompute ballistics coefficients, no allocation. + void setThresholdDb (float newThresholdDb) noexcept { gate.setThreshold (newThresholdDb); } + void setRatio (float newRatio) noexcept { gate.setRatio (newRatio); } + void setAttackMs (float newAttackMs) noexcept { gate.setAttack (newAttackMs); } + void setReleaseMs (float newReleaseMs) noexcept { gate.setRelease (newReleaseMs); } + + // In-place gate. When disabled, this is a deliberate no-op (not just + // a unity-gain pass through the gate's own math) so the disabled + // state is bit-exact transparent and costs nothing on the audio + // thread. + void process (juce::dsp::AudioBlock& block) noexcept + { + if (! enabled) + return; + + gate.process (juce::dsp::ProcessContextReplacing (block)); + } + + private: + juce::dsp::NoiseGate gate; + bool enabled = false; + }; +} diff --git a/src/dsp/ParallelCompressor.cpp b/src/dsp/ParallelCompressor.cpp new file mode 100644 index 0000000..1869d14 --- /dev/null +++ b/src/dsp/ParallelCompressor.cpp @@ -0,0 +1,37 @@ +#include "ParallelCompressor.h" + +namespace +{ + // Matches the ~20ms ramp used for the plugin's other gain stages + // (PluginProcessor.cpp's gainRampDurationSeconds). + constexpr double makeupGainRampDurationSeconds = 0.02; +} + +namespace tyg +{ + void ParallelCompressor::prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01) + { + compressor.prepare (spec); + makeupGain.setRampDurationSeconds (makeupGainRampDurationSeconds); + makeupGain.prepare (spec); + + // Prime the mix *before* prepare() so the mixer's internal reset() + // (called at the end of DryWetMixer::prepare()) snaps its smoothed + // dry/wet volumes to the correct starting point instead of a stale + // default. + mixer.setMixingRule (juce::dsp::DryWetMixingRule::linear); + mixer.setWetMixProportion (initialWetMixProportion01); + mixer.prepare (spec); + + // The compressor and makeup gain add no sample latency, so the + // dry path never needs compensating delay. + mixer.setWetLatency (0.0f); + } + + void ParallelCompressor::reset() + { + compressor.reset(); + makeupGain.reset(); + mixer.reset(); + } +} diff --git a/src/dsp/ParallelCompressor.h b/src/dsp/ParallelCompressor.h new file mode 100644 index 0000000..3741734 --- /dev/null +++ b/src/dsp/ParallelCompressor.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +// Low-band parallel ("New York style") compressor (issue #42): the low band +// is compressed, made up, and blended back with its own uncompressed self +// via lowCompMix, rather than inserted serially - which is what makes it a +// *parallel* compressor rather than a plain dynamics insert. Sits after the +// LR4 split and before lowLevel in the low-band chain. +// +// juce::dsp::Compressor (JUCE 8.0.14, +// juce_dsp/widgets/juce_Compressor.h) is a simple feed-forward VCA-style +// compressor with no lookahead, so it introduces zero sample latency - the +// juce::dsp::DryWetMixer used for the parallel blend is configured +// accordingly (wetLatency stays 0), so this stage never needs to feed into +// the plugin's latency-compensation seam. +namespace tyg +{ + class ParallelCompressor + { + public: + ParallelCompressor() = default; + + // `initialWetMixProportion01` must be the current lowCompMix value + // (0..1) *before* prepare() runs the mixer's internal reset(): the + // DryWetMixer primes its smoothed dry/wet volumes from whatever + // `mix` was set to at the time reset() executes (JUCE 8.0.14 + // gotcha), so passing the real value here - rather than setting it + // only after prepare() - avoids an audible fade-in glitch on the + // very first block. + void prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01); + void reset(); + + // All real-time safe: Compressor's setters just recompute ballistics + // coefficients (no allocation), Gain's setter only retargets its + // SmoothedValue, and DryWetMixer::setWetMixProportion only updates a + // scalar + recomputes the (already-allocated) dry/wet volume + // targets. + void setThresholdDb (float newThresholdDb) noexcept { compressor.setThreshold (newThresholdDb); } + void setRatio (float newRatio) noexcept { compressor.setRatio (newRatio); } + void setAttackMs (float newAttackMs) noexcept { compressor.setAttack (newAttackMs); } + void setReleaseMs (float newReleaseMs) noexcept { compressor.setRelease (newReleaseMs); } + void setMakeupGainDb (float newMakeupDb) noexcept { makeupGain.setGainDecibels (newMakeupDb); } + void setWetMixProportion (float newWetMixProportion01) noexcept { mixer.setWetMixProportion (newWetMixProportion01); } + + // In-place parallel compression: mixer.pushDrySamples() captures the + // pre-compression signal, the compressor + makeup gain run in place, + // then mixer.mixWetSamples() blends the compressed ("wet") result + // back with the captured dry signal per lowCompMix. + void process (juce::dsp::AudioBlock& block) noexcept + { + mixer.pushDrySamples (juce::dsp::AudioBlock (block)); + + juce::dsp::ProcessContextReplacing context (block); + compressor.process (context); + makeupGain.process (context); + + mixer.mixWetSamples (block); + } + + private: + juce::dsp::Compressor compressor; + juce::dsp::Gain makeupGain; + juce::dsp::DryWetMixer mixer; + }; +} diff --git a/src/dsp/RealtimeCoefficients.h b/src/dsp/RealtimeCoefficients.h new file mode 100644 index 0000000..79ce13f --- /dev/null +++ b/src/dsp/RealtimeCoefficients.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include + +// Real-time-safe biquad/first-order coefficient updates for juce::dsp::IIR::Filter. +// +// juce::dsp::IIR::Coefficients::makeLowShelf/makePeakFilter/... (the +// usual way to build filter coefficients) heap-allocate a brand new +// Coefficients object on every call - fine in prepareToPlay(), not fine on +// the audio thread if a parameter is being automated every block. +// +// juce::dsp::IIR::ArrayCoefficients::makeXxx returns the same +// coefficients as a std::array (stack storage, zero allocation). This header +// writes that array's values directly into an *already-allocated* +// Coefficients object's raw coefficient storage (normalising by a0 +// exactly the way Coefficients' own constructor does), so repeated calls +// during processBlock() never touch the heap. +// +// JUCE 8.0.14, juce_dsp/processors/juce_IIRFilter.h / +// juce_dsp/processors/juce_IIRFilter_Impl.h (Coefficients::assignImpl shows +// the {b0,b1,b2,a1,a2} normalised-by-a0 storage layout this mirrors). +namespace tyg +{ + // Writes a normalised 2nd-order {b0,b1,b2,a1,a2} set (5 raw coefficients) + // computed from a raw {b0,b1,b2,a0,a1,a2} array (as returned by + // juce::dsp::IIR::ArrayCoefficients::makeLowShelf/makeHighShelf/ + // makePeakFilter/...) into `target`, which must already hold a 2nd-order + // filter's coefficient storage (i.e. have been constructed via the 6- + // argument Coefficients constructor, or a prior makeXxx() call, at least + // once - typically during prepareToPlay()). + inline void applyBiquadCoefficients (juce::dsp::IIR::Coefficients& target, + const std::array& raw) noexcept + { + jassert (target.getFilterOrder() == 2); + + auto* dest = target.getRawCoefficients(); + const auto invA0 = 1.0f / raw[3]; + + dest[0] = raw[0] * invA0; // b0 + dest[1] = raw[1] * invA0; // b1 + dest[2] = raw[2] * invA0; // b2 + dest[3] = raw[4] * invA0; // a1 + dest[4] = raw[5] * invA0; // a2 + } + + // Same idea for a 1st-order {b0,b1,a1} set (3 raw coefficients) computed + // from a raw {b0,b1,a0,a1} array (as returned by + // ArrayCoefficients::makeFirstOrderLowPass/HighPass/AllPass). + inline void applyFirstOrderCoefficients (juce::dsp::IIR::Coefficients& target, + const std::array& raw) noexcept + { + jassert (target.getFilterOrder() == 1); + + auto* dest = target.getRawCoefficients(); + const auto invA0 = 1.0f / raw[2]; + + dest[0] = raw[0] * invA0; // b0 + dest[1] = raw[1] * invA0; // b1 + dest[2] = raw[3] * invA0; // a1 + } +} diff --git a/src/dsp/Voicing.cpp b/src/dsp/Voicing.cpp new file mode 100644 index 0000000..b79d438 --- /dev/null +++ b/src/dsp/Voicing.cpp @@ -0,0 +1,217 @@ +#include "Voicing.h" + +#include + +namespace +{ + // Voicing-specific drive-gain ceilings (issue #42's per-voicing + // character): Gnaw pushes hard into a symmetric hard clip ("op-amp hard + // clip"), Wool cascades two moderate-drive asymmetric soft-clip stages + // ("cascaded soft-clip fuzz"), Razor stays comparatively mild ("tight + // overdrive"). Starting points only - final voicing character is an + // ear-tuning decision (see docs/manual.md and the PR description), not + // something math alone should finalise. + constexpr float gnawMaxDriveGain = 40.0f; + constexpr float woolMaxDriveGain1 = 12.0f; + constexpr float woolMaxDriveGain2 = 6.0f; + constexpr float woolAsymmetryBias = 0.15f; + constexpr float razorMaxDriveGain = 8.0f; + + // Voicing-specific mid-band character filter. + constexpr float gnawMidFreqHz = 1000.0f, gnawMidGainDb = 0.0f, gnawMidQ = 0.7f; + constexpr float woolMidFreqHz = 500.0f, woolMidGainDb = -6.0f, woolMidQ = 0.9f; // mid scoop + constexpr float razorMidFreqHz = 900.0f, razorMidGainDb = 5.0f, razorMidQ = 1.0f; // mid hump + + // Razor's pre-clip highpass: keeps the fundamental out of the shaper so + // the low end stays tight instead of flabby. + constexpr float razorPreHighPassHz = 200.0f; + constexpr float razorPreHighPassQ = 0.7071f; + + // highTone sweep range. + constexpr float minToneHz = 700.0f; + constexpr float maxToneHz = 15000.0f; + + // 4x oversampling (factor exponent 2 -> 2^2): enough headroom to push + // the aliasing products from a hard-clip/tanh nonlinearity well above + // the audible band for a bass-range source, at a latency/CPU cost that + // stays modest. FIR (not IIR) filtering trades latency for linear phase + // and cleaner stopband rejection, appropriate for a distortion stage + // that's also blended with a clean dry signal (highBlend) where phase + // mismatches between wet and dry would be audible as comb filtering. + constexpr size_t oversamplingFactorExponent = 2; +} + +namespace tyg +{ + void Voicing::prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01) + { + sampleRate = spec.sampleRate; + + oversampling = std::make_unique> ( + static_cast (spec.numChannels), + oversamplingFactorExponent, + juce::dsp::Oversampling::filterHalfBandFIREquiripple, + true, + true); + oversampling->initProcessing (static_cast (spec.maximumBlockSize)); + oversampling->reset(); + latencySamples = static_cast (std::lround (oversampling->getLatencyInSamples())); + + preHighPass.prepare (spec); + midFilter.prepare (spec); + toneFilter.prepare (spec); + + // Prime the mix *before* prepare() so the mixer's internal reset() + // (called at the end of DryWetMixer::prepare()) snaps its smoothed + // dry/wet volumes to the correct starting point instead of a stale + // default (JUCE 8.0.14 DryWetMixer gotcha). + blendMixer.setMixingRule (juce::dsp::DryWetMixingRule::linear); + blendMixer.setWetMixProportion (initialWetMixProportion01); + blendMixer.prepare (spec); + blendMixer.setWetLatency (static_cast (latencySamples)); + + updatePreHighPassCoefficients(); + updateMidFilterCoefficients(); + updateToneFilterCoefficients(); + + reset(); + } + + void Voicing::reset() + { + if (oversampling != nullptr) + oversampling->reset(); + + preHighPass.reset(); + midFilter.reset(); + toneFilter.reset(); + blendMixer.reset(); + } + + void Voicing::setVoicing (VoicingType newVoicing) noexcept + { + if (newVoicing == voicing) + return; + + voicing = newVoicing; + + updateMidFilterCoefficients(); + + if (voicing == VoicingType::razor) + { + updatePreHighPassCoefficients(); + // Avoid carrying over stale filter state from a previous Razor + // session (or silence) into the newly (re)activated pre-HPF. + preHighPass.reset(); + } + } + + void Voicing::process (juce::dsp::AudioBlock& block) noexcept + { + jassert (oversampling != nullptr); + + // Control-rate refresh: highTone is read fresh every host block by + // the processor and forwarded via setTone(), so recomputing the + // tone filter's (zero-allocation, see RealtimeCoefficients.h) + // coefficients once per block keeps it tracking automation without + // per-sample coefficient-recompute cost. + updateToneFilterCoefficients(); + + blendMixer.pushDrySamples (juce::dsp::AudioBlock (block)); + + if (voicing == VoicingType::razor) + preHighPass.process (juce::dsp::ProcessContextReplacing (block)); + + auto upBlock = oversampling->processSamplesUp (juce::dsp::AudioBlock (block)); + + const auto numChannels = upBlock.getNumChannels(); + const auto numSamples = upBlock.getNumSamples(); + + for (size_t channel = 0; channel < numChannels; ++channel) + { + auto* data = upBlock.getChannelPointer (channel); + + for (size_t sample = 0; sample < numSamples; ++sample) + data[sample] = shapeSample (data[sample]); + } + + oversampling->processSamplesDown (block); + + juce::dsp::ProcessContextReplacing context (block); + midFilter.process (context); + toneFilter.process (context); + + blendMixer.mixWetSamples (block); + } + + float Voicing::shapeSample (float x) const noexcept + { + switch (voicing) + { + case VoicingType::gnaw: + { + // Op-amp-style hard clip: symmetric, unforgiving. + const auto driveGain = 1.0f + driveAmount01 * (gnawMaxDriveGain - 1.0f); + return juce::jlimit (-1.0f, 1.0f, driveGain * x); + } + + case VoicingType::wool: + { + // Two cascaded tanh soft-clip stages; a small DC bias ahead + // of the second stage (removed again afterwards, so no + // steady-state DC offset reaches the output) breaks the + // symmetry for a grittier, more fuzz-like harmonic series. + const auto g1 = 1.0f + driveAmount01 * (woolMaxDriveGain1 - 1.0f); + const auto g2 = 1.0f + driveAmount01 * (woolMaxDriveGain2 - 1.0f); + const auto stage1 = std::tanh (g1 * x); + return std::tanh (g2 * (stage1 + woolAsymmetryBias)) - std::tanh (g2 * woolAsymmetryBias); + } + + case VoicingType::razor: + default: + { + // Comparatively mild tanh soft-clip; the "tight overdrive" + // character comes mostly from the pre-clip highpass and the + // mid-hump filter, not from the shaper itself. + const auto driveGain = 1.0f + driveAmount01 * (razorMaxDriveGain - 1.0f); + return std::tanh (driveGain * x); + } + } + } + + void Voicing::updateMidFilterCoefficients() noexcept + { + float freqHz = gnawMidFreqHz, gainDb = gnawMidGainDb, q = gnawMidQ; + + if (voicing == VoicingType::wool) + { + freqHz = woolMidFreqHz; + gainDb = woolMidGainDb; + q = woolMidQ; + } + else if (voicing == VoicingType::razor) + { + freqHz = razorMidFreqHz; + gainDb = razorMidGainDb; + q = razorMidQ; + } + + const auto gainFactor = juce::Decibels::decibelsToGain (gainDb); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makePeakFilter (sampleRate, freqHz, q, gainFactor); + applyBiquadCoefficients (*midFilter.state, raw); + } + + void Voicing::updateToneFilterCoefficients() noexcept + { + const auto cutoffHz = juce::mapToLog10 (toneAmount01, minToneHz, maxToneHz); + const auto clampedHz = juce::jlimit (20.0f, static_cast (sampleRate * 0.49), cutoffHz); + const auto raw = juce::dsp::IIR::ArrayCoefficients::makeFirstOrderLowPass (sampleRate, clampedHz); + applyFirstOrderCoefficients (*toneFilter.state, raw); + } + + void Voicing::updatePreHighPassCoefficients() noexcept + { + const auto raw = juce::dsp::IIR::ArrayCoefficients::makeHighPass (sampleRate, razorPreHighPassHz, razorPreHighPassQ); + applyBiquadCoefficients (*preHighPass.state, raw); + } +} diff --git a/src/dsp/Voicing.h b/src/dsp/Voicing.h new file mode 100644 index 0000000..8208626 --- /dev/null +++ b/src/dsp/Voicing.h @@ -0,0 +1,113 @@ +#pragma once + +#include "RealtimeCoefficients.h" + +#include + +#include +#include + +// High-band distortion engine (issue #42): selectable voicing (Gnaw/Wool/ +// Razor) with drive, tone, and a clean/distorted blend, running the +// nonlinear shaping stage oversampled to keep aliasing under control - +// exactly the "oversampled nonlinearities to control aliasing" pattern this +// suite uses for every distortion stage. +// +// Topology per block, all real-time safe (no allocation once prepare() has +// run): +// dry tap (pre-voicing high band) --------------------------+ +// | +// [Razor only] pre-highpass (base rate, tight low end) --+ | +// oversample up (juce::dsp::Oversampling, JUCE | | +// 8.0.14 juce_dsp/processors/juce_Oversampling.h) --------+ | +// per-sample waveshape (voicing-specific, drive-scaled) | +// oversample down | +// mid filter (peak, voicing-specific hump/scoop) | +// tone filter (1st-order lowpass, highTone-controlled) | +// ------------------------------------------------------ wet -> DryWetMixer (highBlend) +// +// Latency: only the oversampling stage adds sample latency (all the IIR +// filters here are zero-latency). getLatencySamples() reports it so +// PluginProcessor can feed it into the plugin's overall latency-compensation +// seam (issue #9) and delay the low band to match. +namespace tyg +{ + enum class VoicingType + { + gnaw = 0, + wool = 1, + razor = 2 + }; + + class Voicing + { + public: + Voicing() = default; + + // `initialWetMixProportion01` must be the current highBlend value + // (0..1) *before* prepare() runs the internal DryWetMixer's + // reset(): DryWetMixer primes its smoothed dry/wet volumes from + // whatever `mix` was set to at the moment reset() executes (JUCE + // 8.0.14 gotcha - see docs/architecture.md), so passing the real + // value here avoids an audible fade-in glitch on the very first + // block. + void prepare (const juce::dsp::ProcessSpec& spec, float initialWetMixProportion01); + void reset(); + + void setVoicing (VoicingType newVoicing) noexcept; + void setDrive (float drive01) noexcept { driveAmount01 = juce::jlimit (0.0f, 1.0f, drive01); } + void setTone (float tone01) noexcept { toneAmount01 = juce::jlimit (0.0f, 1.0f, tone01); } + void setWetMixProportion (float wetMixProportion01) noexcept { blendMixer.setWetMixProportion (wetMixProportion01); } + + // Integer sample latency contributed by the oversampling stage. + // Zero until prepare() has run. + int getLatencySamples() const noexcept { return latencySamples; } + + // In-place: processes the high band (already split off by the LR4 + // crossover) through the selected voicing and blends clean/distorted + // per the current wet mix proportion. + void process (juce::dsp::AudioBlock& block) noexcept; + + private: + void updateMidFilterCoefficients() noexcept; + void updateToneFilterCoefficients() noexcept; + void updatePreHighPassCoefficients() noexcept; + + // Per-sample nonlinearity for the current voicing, scaled by the + // current drive amount. Always returns a finite value in [-1, 1] + // regardless of drive/input magnitude (hard-clip and tanh-based + // shapers are both inherently bounded), so extreme drive settings + // can never produce NaN/Inf. + float shapeSample (float x) const noexcept; + + double sampleRate = 44100.0; + VoicingType voicing = VoicingType::gnaw; + float driveAmount01 = 0.5f; + float toneAmount01 = 0.5f; + + // Constructed in prepare() once the real channel count is known; + // Oversampling's constructor itself allocates, so it must never be + // (re)constructed from processBlock(). + std::unique_ptr> oversampling; + int latencySamples = 0; + + using Duplicator = juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients>; + + // Razor-only pre-emphasis highpass, run at the base sample rate + // before oversampling (linear filter, no aliasing concern, so no + // need to pay the oversampling cost for it). + Duplicator preHighPass { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + + // Voicing-specific character filter (mid hump for Razor, mid scoop + // for Wool, neutral for Gnaw), applied post-downsampling. + Duplicator midFilter { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f) }; + + // highTone-controlled 1st-order lowpass, applied post-downsampling. + Duplicator toneFilter { new juce::dsp::IIR::Coefficients (1.0f, 0.0f, 1.0f, 0.0f) }; + + // Generous headroom above any realistic oversampling FIR latency at + // any factor/sample-rate combination this plugin supports. + static constexpr int maxWetLatencySamples = 2048; + juce::dsp::DryWetMixer blendMixer { maxWetLatencySamples }; + }; +} diff --git a/tests/BandEQTests.cpp b/tests/BandEQTests.cpp new file mode 100644 index 0000000..cdc533f --- /dev/null +++ b/tests/BandEQTests.cpp @@ -0,0 +1,142 @@ +#include "dsp/BandEQ.h" +#include "TestHelpers.h" + +#include +#include + +#include +#include + +// Issue #42: post-sum 4-band EQ acceptance gates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 8192; + constexpr int settleSamples = 2048; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 1) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } + + double measureLevelRatioDb (tyg::BandEQ& eq, double probeFrequencyHz) + { + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, probeFrequencyHz, 0.5f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + eq.process (block); + + double sumOfSquaresIn = 0.0, sumOfSquaresOut = 0.0; + const auto* refData = reference.getReadPointer (0); + const auto* outData = buffer.getReadPointer (0); + + for (int i = settleSamples; i < numSamples; ++i) + { + sumOfSquaresIn += static_cast (refData[i]) * static_cast (refData[i]); + sumOfSquaresOut += static_cast (outData[i]) * static_cast (outData[i]); + } + + const auto inRms = std::sqrt (sumOfSquaresIn); + const auto outRms = std::sqrt (sumOfSquaresOut); + + return juce::Decibels::gainToDecibels (outRms / inRms); + } +} + +TEST_CASE ("BandEQ: all bands at 0dB gain is transparent across the band", "[eq][dsp]") +{ + tyg::BandEQ eq; + eq.prepare (makeSpec()); + eq.setLowShelf (100.0f, 0.0f); + eq.setPeak1 (500.0f, 0.0f, 0.7f); + eq.setPeak2 (2500.0f, 0.0f, 0.7f); + eq.setHighShelf (8000.0f, 0.0f); + + const double probeFrequenciesHz[] = { 40.0, 100.0, 500.0, 1000.0, 2500.0, 8000.0, 15000.0 }; + + for (const auto probeFrequencyHz : probeFrequenciesHz) + { + INFO ("probe frequency = " << probeFrequencyHz << " Hz"); + CHECK (measureLevelRatioDb (eq, probeFrequencyHz) == Catch::Approx (0.0).margin (0.2)); + } +} + +TEST_CASE ("BandEQ: low shelf boost raises level at low frequencies but not at high ones", "[eq][dsp]") +{ + tyg::BandEQ eq; + eq.prepare (makeSpec()); + eq.setLowShelf (200.0f, 12.0f); + eq.setPeak1 (500.0f, 0.0f, 0.7f); + eq.setPeak2 (2500.0f, 0.0f, 0.7f); + eq.setHighShelf (8000.0f, 0.0f); + + CHECK (measureLevelRatioDb (eq, 60.0) == Catch::Approx (12.0).margin (1.0)); + CHECK (measureLevelRatioDb (eq, 12000.0) == Catch::Approx (0.0).margin (0.5)); +} + +TEST_CASE ("BandEQ: high shelf cut lowers level at high frequencies but not at low ones", "[eq][dsp]") +{ + tyg::BandEQ eq; + eq.prepare (makeSpec()); + eq.setLowShelf (100.0f, 0.0f); + eq.setPeak1 (500.0f, 0.0f, 0.7f); + eq.setPeak2 (2500.0f, 0.0f, 0.7f); + eq.setHighShelf (6000.0f, -12.0f); + + CHECK (measureLevelRatioDb (eq, 15000.0) == Catch::Approx (-12.0).margin (1.0)); + CHECK (measureLevelRatioDb (eq, 60.0) == Catch::Approx (0.0).margin (0.5)); +} + +TEST_CASE ("BandEQ: a peak boost raises level at its centre frequency", "[eq][dsp]") +{ + tyg::BandEQ eq; + eq.prepare (makeSpec()); + eq.setLowShelf (100.0f, 0.0f); + eq.setPeak1 (1000.0f, 9.0f, 1.0f); + eq.setPeak2 (2500.0f, 0.0f, 0.7f); + eq.setHighShelf (8000.0f, 0.0f); + + CHECK (measureLevelRatioDb (eq, 1000.0) == Catch::Approx (9.0).margin (1.0)); + CHECK (measureLevelRatioDb (eq, 60.0) == Catch::Approx (0.0).margin (0.5)); +} + +TEST_CASE ("BandEQ: coefficient updates across a wide parameter sweep never produce NaN/Inf", "[eq][dsp][robustness]") +{ + tyg::BandEQ eq; + eq.prepare (makeSpec (2)); + + juce::AudioBuffer buffer (2, 512); + juce::Random random (1234); + + for (int iteration = 0; iteration < 40; ++iteration) + { + const auto lowFreq = random.nextFloat() * 360.0f + 40.0f; + const auto lowGain = random.nextFloat() * 36.0f - 18.0f; + const auto peak1Freq = random.nextFloat() * 1900.0f + 100.0f; + const auto peak1Gain = random.nextFloat() * 36.0f - 18.0f; + const auto peak1Q = random.nextFloat() * 4.8f + 0.2f; + const auto peak2Freq = random.nextFloat() * 7500.0f + 500.0f; + const auto peak2Gain = random.nextFloat() * 36.0f - 18.0f; + const auto peak2Q = random.nextFloat() * 4.8f + 0.2f; + const auto highFreq = random.nextFloat() * 14000.0f + 2000.0f; + const auto highGain = random.nextFloat() * 36.0f - 18.0f; + + eq.setLowShelf (lowFreq, lowGain); + eq.setPeak1 (peak1Freq, peak1Gain, peak1Q); + eq.setPeak2 (peak2Freq, peak2Gain, peak2Q); + eq.setHighShelf (highFreq, highGain); + + TestHelpers::fillWithSine (buffer, testSampleRate, 300.0 + iteration * 17.0, 0.5f); + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (eq.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} diff --git a/tests/GainProcessingTests.cpp b/tests/GainProcessingTests.cpp index 06b427b..582bdfc 100644 --- a/tests/GainProcessingTests.cpp +++ b/tests/GainProcessingTests.cpp @@ -23,6 +23,27 @@ namespace processor.processBlock (buffer, midi); } } + + // Since issue #42 wired the low-band parallel compressor and high-band + // voicing permanently into the signal path, the plugin is no longer + // level-transparent at its *default* parameters (lowCompMix defaults to + // 100% wet with a -18dB threshold, and highBlend defaults to 100% wet + // Gnaw hard-clip distortion) - that's by design, not a regression. These + // pure gain-staging tests are about input/output trim math, not + // compressor/voicing character (which get their own dedicated tests in + // ParallelCompressorTests.cpp/VoicingTests.cpp), so they pull both + // stages' blend controls to 0% (fully dry) to isolate the gain-staging + // path being tested. + void neutralizeDynamicsAndVoicing (TwistYourGutsAudioProcessor& processor) + { + auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); + REQUIRE (lowCompMixParam != nullptr); + REQUIRE (highBlendParam != nullptr); + + lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); + } } TEST_CASE ("Gain math: +6dB input gain doubles the RMS level", "[gain][dsp]") @@ -30,6 +51,8 @@ TEST_CASE ("Gain math: +6dB input gain doubles the RMS level", "[gain][dsp]") TwistYourGutsAudioProcessor processor; processor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (processor); + auto* inputGainParam = processor.apvts.getParameter (ParamIDs::inputGain); REQUIRE (inputGainParam != nullptr); inputGainParam->setValueNotifyingHost (inputGainParam->convertTo0to1 (6.0f)); @@ -69,6 +92,7 @@ TEST_CASE ("Passthrough level test: default parameters leave the signal level un // rigorously, across many probe frequencies, in CrossoverTests.cpp. TwistYourGutsAudioProcessor processor; processor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (processor); settleSmoothing (processor); diff --git a/tests/GainStagingTests.cpp b/tests/GainStagingTests.cpp index a830a41..64c8e6f 100644 --- a/tests/GainStagingTests.cpp +++ b/tests/GainStagingTests.cpp @@ -29,6 +29,27 @@ namespace // is a stateful IIR filter, so restarting the sine at phase zero every // block would inject a small broadband transient at each boundary and // pollute the level measurement. + // Since issue #42 wired the low-band parallel compressor and high-band + // voicing permanently into the signal path, both bands are no longer + // level-transparent at their *default* parameters (lowCompMix defaults + // to 100% wet with a -18dB threshold that a 0.5-amplitude probe tone + // sits well above; highBlend defaults to 100% wet Gnaw hard-clip + // distortion) - that's by design, not a regression. These tests are + // about the lowLevel/highLevel *trim* controls, not compressor/voicing + // character (which get their own dedicated tests), so both stages' + // blend controls are pulled to 0% (fully dry) up front to isolate the + // level-trim behaviour being tested. + void neutralizeDynamicsAndVoicing (TwistYourGutsAudioProcessor& processor) + { + auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); + REQUIRE (lowCompMixParam != nullptr); + REQUIRE (highBlendParam != nullptr); + + lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); + } + double measureSettledLevelDb (TwistYourGutsAudioProcessor& processor, double probeFrequencyHz) { juce::AudioBuffer buffer (2, testBlockSize); @@ -49,6 +70,7 @@ TEST_CASE ("Gain staging: lowLevel attenuates the low band only", "[gain-staging { TwistYourGutsAudioProcessor processor; processor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (processor); auto* lowLevelParam = processor.apvts.getParameter (ParamIDs::lowLevel); REQUIRE (lowLevelParam != nullptr); @@ -57,6 +79,7 @@ TEST_CASE ("Gain staging: lowLevel attenuates the low band only", "[gain-staging TwistYourGutsAudioProcessor attenuatedProcessor; attenuatedProcessor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (attenuatedProcessor); auto* attenuatedLowLevelParam = attenuatedProcessor.apvts.getParameter (ParamIDs::lowLevel); REQUIRE (attenuatedLowLevelParam != nullptr); attenuatedLowLevelParam->setValueNotifyingHost (attenuatedLowLevelParam->convertTo0to1 (-12.0f)); @@ -66,6 +89,7 @@ TEST_CASE ("Gain staging: lowLevel attenuates the low band only", "[gain-staging TwistYourGutsAudioProcessor referenceProcessor; referenceProcessor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (referenceProcessor); const auto referenceHighLevelDb = measureSettledLevelDb (referenceProcessor, highProbeFrequencyHz); // A low-frequency probe should drop by ~12 dB when lowLevel is -12 dB... @@ -79,14 +103,17 @@ TEST_CASE ("Gain staging: highLevel attenuates the high band only", "[gain-stagi { TwistYourGutsAudioProcessor referenceProcessor; referenceProcessor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (referenceProcessor); const auto referenceLowLevelDb = measureSettledLevelDb (referenceProcessor, lowProbeFrequencyHz); TwistYourGutsAudioProcessor referenceProcessor2; referenceProcessor2.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (referenceProcessor2); const auto referenceHighLevelDb = measureSettledLevelDb (referenceProcessor2, highProbeFrequencyHz); TwistYourGutsAudioProcessor attenuatedProcessor; attenuatedProcessor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (attenuatedProcessor); auto* attenuatedHighLevelParam = attenuatedProcessor.apvts.getParameter (ParamIDs::highLevel); REQUIRE (attenuatedHighLevelParam != nullptr); attenuatedHighLevelParam->setValueNotifyingHost (attenuatedHighLevelParam->convertTo0to1 (-12.0f)); @@ -151,6 +178,7 @@ TEST_CASE ("Gain staging: 0 dB band level defaults are transparent", "[gain-stag { TwistYourGutsAudioProcessor processor; processor.prepareToPlay (testSampleRate, testBlockSize); + neutralizeDynamicsAndVoicing (processor); // Defaults: lowLevel/highLevel/inputGain/outputGain all 0 dB, // outputClip off. A full-band-ish probe (sum of a low and a high tone) diff --git a/tests/IRLoaderTests.cpp b/tests/IRLoaderTests.cpp new file mode 100644 index 0000000..941c3d4 --- /dev/null +++ b/tests/IRLoaderTests.cpp @@ -0,0 +1,187 @@ +#include "dsp/IRLoader.h" +#include "TestHelpers.h" + +#include +#include + +#include +#include + +// Issue #42: cab-sim IR loader acceptance gates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 4096; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 1) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } + + // A short, sharply decaying synthetic "IR": clearly not silence and + // clearly not an identity impulse, so tests can tell whether it was + // actually applied. + juce::AudioBuffer makeSyntheticImpulseResponse (int numIrSamples = 64) + { + juce::AudioBuffer ir (2, numIrSamples); + + for (int channel = 0; channel < ir.getNumChannels(); ++channel) + { + auto* data = ir.getWritePointer (channel); + + for (int sample = 0; sample < numIrSamples; ++sample) + data[sample] = std::exp (-static_cast (sample) / 8.0f); + } + + return ir; + } +} + +TEST_CASE ("IRLoader: with no IR loaded, output is a bit-exact identity passthrough even at 100% wet", "[ir][dsp]") +{ + // juce::dsp::Convolution defaults to a single-sample identity impulse + // response until loadImpulseResponse() is called (JUCE 8.0.14), so this + // is the plugin's safe-by-default state before any factory/user IR is + // loaded. + tyg::IRLoader irLoader; + irLoader.prepare (makeSpec(), 1.0f); + + // juce::dsp::Convolution installs its (here: identity) engine via the + // same crossfade machinery used for a live IR swap, which runs once on + // the very first process() call after prepare() (JUCE 8.0.14, + // Convolution::Impl::processSamples()'s installPendingEngine()) - so a + // throwaway warm-up block is processed first, the same way this + // codebase's other tests settle smoothers/filter transients before + // measuring (e.g. GainProcessingTests.cpp's settleSmoothing()), and the + // identity-passthrough assertion is made against a block processed + // afterwards, once that one-time transition has completed. + juce::AudioBuffer warmup (1, numSamples); + TestHelpers::fillWithSine (warmup, testSampleRate, 400.0, 0.5f); + juce::dsp::AudioBlock warmupBlock (warmup); + juce::Thread::sleep (15); + irLoader.process (warmupBlock); + + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 400.0, 0.5f, numSamples); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + irLoader.process (block); + + const auto* refData = reference.getReadPointer (0); + const auto* outData = buffer.getReadPointer (0); + + for (int sample = 0; sample < numSamples; ++sample) + CHECK (outData[sample] == Catch::Approx (refData[sample]).margin (1e-4f)); +} + +TEST_CASE ("IRLoader: 0% mix is a passthrough regardless of the loaded IR", "[ir][dsp]") +{ + tyg::IRLoader irLoader; + irLoader.prepare (makeSpec (2), 0.0f); + irLoader.loadImpulseResponse (makeSyntheticImpulseResponse(), testSampleRate); + + // Convolution's asynchronous IR loading needs a handful of prepare/ + // process cycles to install the newly loaded engine (JUCE 8.0.14, + // juce_Convolution.cpp's ConvolutionEngineQueue) - process a few blocks + // so the loaded IR (if it were audible) would definitely be active + // before the assertion below. + juce::AudioBuffer warmup (2, numSamples); + juce::dsp::AudioBlock warmupBlock (warmup); + + for (int i = 0; i < 4; ++i) + { + juce::Thread::sleep (15); + irLoader.process (warmupBlock); + } + + juce::AudioBuffer buffer (2, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 400.0, 0.5f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + irLoader.process (block); + + const auto outRms = TestHelpers::rms (buffer); + const auto refRms = TestHelpers::rms (reference); + + CHECK (juce::Decibels::gainToDecibels (outRms / refRms) == Catch::Approx (0.0).margin (0.2)); +} + +TEST_CASE ("IRLoader: loading a real IR at 100% wet audibly changes the signal", "[ir][dsp]") +{ + tyg::IRLoader irLoader; + irLoader.prepare (makeSpec (1), 1.0f); + irLoader.loadImpulseResponse (makeSyntheticImpulseResponse(), testSampleRate); + + juce::AudioBuffer buffer (1, numSamples); + juce::dsp::AudioBlock block (buffer); + + // juce::dsp::Convolution loads impulse responses on a genuine background + // thread (JUCE 8.0.14's BackgroundMessageQueue, polling every ~10ms) and + // only installs the newly-built engine on a *subsequent* process() call + // once it's ready - so, unlike the rest of this real-time-safe DSP, this + // is one spot where a test legitimately has to wait on wall-clock time + // rather than just calling process() in a tight loop. + bool changed = false; + + for (int i = 0; i < 30 && ! changed; ++i) + { + juce::Thread::sleep (15); + + TestHelpers::fillWithSine (buffer, testSampleRate, 400.0, 0.5f, static_cast (i) * numSamples); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + irLoader.process (block); + + juce::AudioBuffer difference; + difference.makeCopyOf (buffer); + difference.addFrom (0, 0, reference, 0, 0, numSamples, -1.0f); + + if (TestHelpers::rms (difference) > 0.01) + changed = true; + } + + CHECK (changed); +} + +TEST_CASE ("IRLoader: no NaN/Inf across a denormal-range sweep, with and without a loaded IR", "[ir][dsp][robustness]") +{ + for (const bool loadIr : { false, true }) + { + tyg::IRLoader irLoader; + irLoader.prepare (makeSpec (2), 1.0f); + + if (loadIr) + irLoader.loadImpulseResponse (makeSyntheticImpulseResponse(), testSampleRate); + + juce::AudioBuffer buffer (2, numSamples); + const auto denormalValue = std::numeric_limits::denorm_min() * 4.0f; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + data[sample] = (sample % 2 == 0) ? denormalValue : -denormalValue; + } + + juce::dsp::AudioBlock block (buffer); + + for (int i = 0; i < 4; ++i) + { + CHECK_NOTHROW (irLoader.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + } +} diff --git a/tests/LatencyTests.cpp b/tests/LatencyTests.cpp index c21410e..728c3b6 100644 --- a/tests/LatencyTests.cpp +++ b/tests/LatencyTests.cpp @@ -5,53 +5,88 @@ #include #include -// Issue #9: latency-compensation framework acceptance gates. As of M1 no -// oversampling exists yet, so the seam (TwistYourGutsAudioProcessor:: -// computeTotalLatencySamples()/updateLatencyCompensation(), called from -// prepareToPlay()) always computes/reports zero - these tests pin that down -// and also confirm the seam doesn't itself break the band-split-then-sum -// magnitude flatness that issue #8 establishes at the Crossover-class level. +// Issue #9 (latency-compensation framework) + issue #42 (the high-band +// voicing's 4x oversampling is now the framework's one real latency +// source): these tests pin down that the reported latency is positive, +// sample-rate-consistent, and independent of host block size, and confirm +// the low-band compensation delay + the high-band DryWetMixer's own +// internal dry-path delay keep the band-split-then-sum magnitude-flat +// property (issue #8) intact end-to-end through the full processor. namespace { constexpr int testBlockSize = 512; + + // Generous upper bound: comfortably above the actual ~60-sample 4x + // maxQuality-FIR oversampling latency this plugin currently reports, but + // well inside maxLatencyCompensationSamples, so this stays a meaningful + // regression guard rather than a tautology. + constexpr int maxSaneLatencySamples = 1000; } -TEST_CASE ("Latency: plugin reports zero latency at multiple sample rates/block sizes", "[latency][dsp]") +TEST_CASE ("Latency: high-band oversampling reports positive latency, independent of host block size", "[latency][dsp]") { const double sampleRates[] = { 44100.0, 48000.0, 96000.0 }; const int blockSizes[] = { 64, 256, 512, 1024 }; for (const auto sampleRate : sampleRates) { + int latencyAtFirstBlockSize = -1; + for (const auto blockSize : blockSizes) { TwistYourGutsAudioProcessor processor; processor.prepareToPlay (sampleRate, blockSize); + const auto latency = processor.getLatencySamples(); + INFO ("sampleRate = " << sampleRate << ", blockSize = " << blockSize); - CHECK (processor.getLatencySamples() == 0); + CHECK (latency > 0); + CHECK (latency < maxSaneLatencySamples); + + if (latencyAtFirstBlockSize < 0) + latencyAtFirstBlockSize = latency; + else + CHECK (latency == latencyAtFirstBlockSize); } } } -TEST_CASE ("Latency: re-preparing the processor keeps latency at zero", "[latency][dsp]") +TEST_CASE ("Latency: re-preparing the processor recomputes latency deterministically", "[latency][dsp]") { TwistYourGutsAudioProcessor processor; processor.prepareToPlay (48000.0, 512); - CHECK (processor.getLatencySamples() == 0); + const auto latency48k = processor.getLatencySamples(); + CHECK (latency48k > 0); // Simulates a host changing sample rate/block size mid-session (e.g. a // DAW sample-rate switch), which re-runs the latency-compensation seam. processor.prepareToPlay (96000.0, 256); - CHECK (processor.getLatencySamples() == 0); + const auto latency96k = processor.getLatencySamples(); + CHECK (latency96k > 0); + + // Switching back to the original spec reproduces the original latency + // exactly - the seam is a pure function of (sampleRate, blockSize, + // numChannels), not order-dependent hidden state. + processor.prepareToPlay (48000.0, 512); + CHECK (processor.getLatencySamples() == latency48k); } TEST_CASE ("Latency: band-split-then-sum preserves magnitude flatness through the full processor", "[latency][dsp][crossover]") { // Exercises the same flat-sum property as CrossoverTests.cpp, but end- // to-end through TwistYourGutsAudioProcessor::processBlock() - i.e. - // including the zero-latency compensation delay line on the low band - - // to confirm the #9 seam doesn't perturb the #8 flat-sum guarantee. + // including the low-band compensation delay line and the high-band + // voicing's own internal (DryWetMixer) dry-path delay - to confirm the + // #9/#42 latency-compensation seam doesn't perturb the #8 flat-sum + // guarantee. highBlend and lowCompMix are pulled to 0% (fully dry) so + // neither the high band's *voicing* character (deliberately non- + // transparent at its Gnaw/50% drive defaults) nor the low band's + // parallel compressor (deliberately non-transparent at its -18dB/4:1 + // defaults, which a 0.5-amplitude probe sits well above) pollute a test + // that is specifically about the delay-compensation plumbing, not + // either stage's character - both dry paths still run through their + // respective latency-compensated DryWetMixers, so the plumbing is still + // fully exercised. constexpr double testSampleRate = 48000.0; const double probeFrequenciesHz[] = { 60.0, 150.0, 250.0, 600.0, 2000.0, 8000.0 }; @@ -61,6 +96,13 @@ TEST_CASE ("Latency: band-split-then-sum preserves magnitude flatness through th TwistYourGutsAudioProcessor processor; processor.prepareToPlay (testSampleRate, testBlockSize); + auto* highBlendParam = processor.apvts.getParameter (ParamIDs::highBlend); + auto* lowCompMixParam = processor.apvts.getParameter (ParamIDs::lowCompMix); + REQUIRE (highBlendParam != nullptr); + REQUIRE (lowCompMixParam != nullptr); + highBlendParam->setValueNotifyingHost (highBlendParam->convertTo0to1 (0.0f)); + lowCompMixParam->setValueNotifyingHost (lowCompMixParam->convertTo0to1 (0.0f)); + juce::AudioBuffer buffer (2, testBlockSize); juce::MidiBuffer midi; @@ -80,6 +122,7 @@ TEST_CASE ("Latency: band-split-then-sum preserves magnitude flatness through th REQUIRE (inputRms > 0.0); INFO ("probe frequency = " << probeFrequencyHz << " Hz"); - CHECK (juce::Decibels::gainToDecibels (outputRms / inputRms) == Catch::Approx (0.0).margin (0.1)); + CHECK (juce::Decibels::gainToDecibels (outputRms / inputRms) == Catch::Approx (0.0).margin (0.2)); + CHECK (TestHelpers::allSamplesFinite (buffer)); } } diff --git a/tests/NoiseGateTests.cpp b/tests/NoiseGateTests.cpp new file mode 100644 index 0000000..0d6b5a7 --- /dev/null +++ b/tests/NoiseGateTests.cpp @@ -0,0 +1,141 @@ +#include "dsp/NoiseGateStage.h" +#include "TestHelpers.h" + +#include +#include + +#include +#include + +// Issue #42: full-band input noise gate acceptance gates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 4096; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 2) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } +} + +TEST_CASE ("NoiseGateStage: disabled is a bit-exact passthrough", "[gate][dsp]") +{ + tyg::NoiseGateStage gate; + gate.prepare (makeSpec()); + gate.setEnabled (false); + gate.setThresholdDb (-10.0f); // aggressive settings, should still be ignored + gate.setRatio (20.0f); + + juce::AudioBuffer buffer (2, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 100.0, 0.05f); // quiet tone, well under threshold + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + gate.process (block); + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + const auto* refData = reference.getReadPointer (channel); + const auto* outData = buffer.getReadPointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + CHECK (outData[sample] == Catch::Approx (refData[sample]).margin (1e-9)); + } +} + +TEST_CASE ("NoiseGateStage: enabled attenuates a signal below threshold", "[gate][dsp]") +{ + tyg::NoiseGateStage gate; + gate.prepare (makeSpec (1)); + gate.setEnabled (true); + gate.setThresholdDb (-20.0f); + gate.setRatio (10.0f); + gate.setAttackMs (1.0f); + gate.setReleaseMs (50.0f); + + // -40 dBFS tone: well below the -20 dB threshold, so the gate should + // ramp down to a heavily attenuated (~10:1 ratio) level once settled. + constexpr float quietAmplitude = 0.01f; // ~ -40 dBFS + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 200.0, quietAmplitude); + + juce::dsp::AudioBlock block (buffer); + gate.process (block); + + // Measure the settled tail (skip the release ramp's transient). + constexpr int settleSamples = numSamples / 2; + double sumOfSquares = 0.0; + + const auto* data = buffer.getReadPointer (0); + + for (int i = settleSamples; i < numSamples; ++i) + sumOfSquares += static_cast (data[i]) * static_cast (data[i]); + + const auto settledRms = std::sqrt (sumOfSquares / static_cast (numSamples - settleSamples)); + const auto settledDb = juce::Decibels::gainToDecibels (settledRms); + const auto inputDb = juce::Decibels::gainToDecibels (static_cast (quietAmplitude) * 0.70710678); + + // The gated signal should sit noticeably below the input level. + CHECK (settledDb < inputDb - 10.0); +} + +TEST_CASE ("NoiseGateStage: enabled passes a signal above threshold through essentially unchanged", "[gate][dsp]") +{ + tyg::NoiseGateStage gate; + gate.prepare (makeSpec (1)); + gate.setEnabled (true); + gate.setThresholdDb (-40.0f); + gate.setRatio (10.0f); + gate.setAttackMs (1.0f); + gate.setReleaseMs (50.0f); + + constexpr float loudAmplitude = 0.5f; // well above the -40dB threshold + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 200.0, loudAmplitude); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + gate.process (block); + + constexpr int settleSamples = numSamples / 2; + const auto outRms = TestHelpers::rms (buffer); + const auto refRms = TestHelpers::rms (reference); + juce::ignoreUnused (settleSamples); + + CHECK (juce::Decibels::gainToDecibels (outRms / refRms) == Catch::Approx (0.0).margin (0.5)); +} + +TEST_CASE ("NoiseGateStage: no NaN/Inf across a denormal-range and extreme-parameter sweep", "[gate][dsp][robustness]") +{ + tyg::NoiseGateStage gate; + gate.prepare (makeSpec()); + gate.setEnabled (true); + gate.setThresholdDb (-80.0f); + gate.setRatio (20.0f); + gate.setAttackMs (0.1f); + gate.setReleaseMs (500.0f); + + juce::AudioBuffer buffer (2, numSamples); + const auto denormalValue = std::numeric_limits::denorm_min() * 4.0f; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + data[sample] = (sample % 2 == 0) ? denormalValue : -denormalValue; + } + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (gate.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); +} diff --git a/tests/ParallelCompressorTests.cpp b/tests/ParallelCompressorTests.cpp new file mode 100644 index 0000000..c0130e7 --- /dev/null +++ b/tests/ParallelCompressorTests.cpp @@ -0,0 +1,169 @@ +#include "dsp/ParallelCompressor.h" +#include "TestHelpers.h" + +#include +#include + +#include +#include + +// Issue #42: low-band parallel compressor acceptance gates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 4096; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 1) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } +} + +TEST_CASE ("ParallelCompressor: 0% mix is a bit-exact dry passthrough", "[compressor][dsp]") +{ + tyg::ParallelCompressor compressor; + compressor.prepare (makeSpec(), 0.0f); + compressor.setThresholdDb (-30.0f); + compressor.setRatio (10.0f); + compressor.setAttackMs (1.0f); + compressor.setReleaseMs (50.0f); + compressor.setMakeupGainDb (12.0f); // aggressive settings that would be very audible if leaking through + + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 100.0, 0.5f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + compressor.process (block); + + const auto* refData = reference.getReadPointer (0); + const auto* outData = buffer.getReadPointer (0); + + for (int sample = 0; sample < numSamples; ++sample) + CHECK (outData[sample] == Catch::Approx (refData[sample]).margin (1e-6)); +} + +TEST_CASE ("ParallelCompressor: signal well above threshold is gain-reduced at 100% mix", "[compressor][dsp]") +{ + tyg::ParallelCompressor compressor; + compressor.prepare (makeSpec(), 1.0f); + compressor.setThresholdDb (-24.0f); + compressor.setRatio (8.0f); + compressor.setAttackMs (1.0f); + compressor.setReleaseMs (50.0f); + compressor.setMakeupGainDb (0.0f); + + // 0.5 amplitude ~= -6dBFS, well above the -24dB threshold: an 8:1 ratio + // should visibly reduce gain once the ballistics filter has settled. + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 100.0, 0.5f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + compressor.process (block); + + constexpr int settleSamples = numSamples / 2; + double sumOfSquaresOut = 0.0, sumOfSquaresRef = 0.0; + + const auto* outData = buffer.getReadPointer (0); + const auto* refData = reference.getReadPointer (0); + + for (int i = settleSamples; i < numSamples; ++i) + { + sumOfSquaresOut += static_cast (outData[i]) * static_cast (outData[i]); + sumOfSquaresRef += static_cast (refData[i]) * static_cast (refData[i]); + } + + const auto outRms = std::sqrt (sumOfSquaresOut); + const auto refRms = std::sqrt (sumOfSquaresRef); + + // Gain reduction should be clearly audible (well beyond smoothing noise) + // but the compressor should never invert or silence the signal. + const auto reductionDb = juce::Decibels::gainToDecibels (outRms / refRms); + CHECK (reductionDb < -1.0); + CHECK (reductionDb > -30.0); +} + +TEST_CASE ("ParallelCompressor: makeup gain raises the wet level as expected at 100% mix", "[compressor][dsp]") +{ + // With the threshold pinned far below the signal (so the compressor + // itself is fully engaged and roughly constant-gain-reduction for a + // steady tone) two otherwise-identical runs that only differ by a fixed + // makeup gain should differ by ~ that same amount once settled. + tyg::ParallelCompressor unityCompressor; + unityCompressor.prepare (makeSpec(), 1.0f); + unityCompressor.setThresholdDb (-60.0f); + unityCompressor.setRatio (4.0f); + unityCompressor.setAttackMs (1.0f); + unityCompressor.setReleaseMs (50.0f); + unityCompressor.setMakeupGainDb (0.0f); + + tyg::ParallelCompressor makeupCompressor; + makeupCompressor.prepare (makeSpec(), 1.0f); + makeupCompressor.setThresholdDb (-60.0f); + makeupCompressor.setRatio (4.0f); + makeupCompressor.setAttackMs (1.0f); + makeupCompressor.setReleaseMs (50.0f); + makeupCompressor.setMakeupGainDb (6.0f); + + juce::AudioBuffer unityBuffer (1, numSamples); + TestHelpers::fillWithSine (unityBuffer, testSampleRate, 100.0, 0.3f); + juce::dsp::AudioBlock unityBlock (unityBuffer); + unityCompressor.process (unityBlock); + + juce::AudioBuffer makeupBuffer (1, numSamples); + TestHelpers::fillWithSine (makeupBuffer, testSampleRate, 100.0, 0.3f); + juce::dsp::AudioBlock makeupBlock (makeupBuffer); + makeupCompressor.process (makeupBlock); + + constexpr int settleSamples = numSamples / 2; + double sumOfSquaresUnity = 0.0, sumOfSquaresMakeup = 0.0; + + const auto* unityData = unityBuffer.getReadPointer (0); + const auto* makeupData = makeupBuffer.getReadPointer (0); + + for (int i = settleSamples; i < numSamples; ++i) + { + sumOfSquaresUnity += static_cast (unityData[i]) * static_cast (unityData[i]); + sumOfSquaresMakeup += static_cast (makeupData[i]) * static_cast (makeupData[i]); + } + + const auto unityRms = std::sqrt (sumOfSquaresUnity); + const auto makeupRms = std::sqrt (sumOfSquaresMakeup); + + CHECK (juce::Decibels::gainToDecibels (makeupRms / unityRms) == Catch::Approx (6.0).margin (0.5)); +} + +TEST_CASE ("ParallelCompressor: no NaN/Inf across an extreme-parameter and denormal sweep", "[compressor][dsp][robustness]") +{ + tyg::ParallelCompressor compressor; + compressor.prepare (makeSpec (2), 1.0f); + compressor.setThresholdDb (-60.0f); + compressor.setRatio (20.0f); + compressor.setAttackMs (0.1f); + compressor.setReleaseMs (1000.0f); + compressor.setMakeupGainDb (24.0f); + + juce::AudioBuffer buffer (2, numSamples); + const auto denormalValue = std::numeric_limits::denorm_min() * 4.0f; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + data[sample] = (sample % 2 == 0) ? denormalValue : -denormalValue; + } + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (compressor.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); +} diff --git a/tests/ParameterTests.cpp b/tests/ParameterTests.cpp index 31229ac..f1b9b90 100644 --- a/tests/ParameterTests.cpp +++ b/tests/ParameterTests.cpp @@ -218,9 +218,11 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p CHECK (processor.getBypassParameter() == apvts.getParameter (ParamIDs::bypass)); } - SECTION ("reports zero latency") + SECTION ("reports positive latency once prepared (issue #42's oversampled high-band voicing)") { - processor.prepareToPlay (48000.0, 512); CHECK (processor.getLatencySamples() == 0); + + processor.prepareToPlay (48000.0, 512); + CHECK (processor.getLatencySamples() > 0); } } diff --git a/tests/SampleRateAndRobustnessTests.cpp b/tests/SampleRateAndRobustnessTests.cpp new file mode 100644 index 0000000..542f4ad --- /dev/null +++ b/tests/SampleRateAndRobustnessTests.cpp @@ -0,0 +1,161 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "TestHelpers.h" + +#include + +#include + +// Issue #43: broadened test coverage - sample-rate sweeps (44.1-192kHz), +// mono/stereo bus configurations, extreme parameter automation, and a +// long-run NaN/Inf stability soak, exercised through the full processor so +// every M1 DSP stage (gate, crossover, parallel compressor, voicing, EQ, IR +// loader) is covered together, the way a host would actually drive it. +namespace +{ + constexpr int testBlockSize = 512; + + // Every ParamIDs float/bool/choice parameter, driven to a pseudo-random + // value each iteration. Keeping this list in one place means new + // parameters are easy to fold into both the extreme-automation and + // long-run soak tests below. + void randomiseAllParameters (juce::AudioProcessorValueTreeState& apvts, juce::Random& random) + { + for (auto* parameter : apvts.processor.getParameters()) + parameter->setValueNotifyingHost (random.nextFloat()); + } +} + +TEST_CASE ("Sample-rate sweep: processBlock stays finite and reports plausible latency from 44.1kHz to 192kHz", "[robustness][sample-rate]") +{ + const double sampleRates[] = { 44100.0, 48000.0, 88200.0, 96000.0, 176400.0, 192000.0 }; + + for (const auto sampleRate : sampleRates) + { + TwistYourGutsAudioProcessor processor; + processor.prepareToPlay (sampleRate, testBlockSize); + + INFO ("sampleRate = " << sampleRate); + CHECK (processor.getLatencySamples() > 0); + CHECK (processor.getLatencySamples() < 4096); + + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + for (int i = 0; i < 8; ++i) + { + TestHelpers::fillWithSine (buffer, sampleRate, 220.0, + 0.6f, static_cast (i) * testBlockSize); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + } +} + +TEST_CASE ("Bus configuration: mono in/out processes without crashing or producing NaN/Inf", "[robustness][bus-layout]") +{ + TwistYourGutsAudioProcessor processor; + + juce::AudioProcessor::BusesLayout monoLayout; + monoLayout.inputBuses.add (juce::AudioChannelSet::mono()); + monoLayout.outputBuses.add (juce::AudioChannelSet::mono()); + + REQUIRE (processor.checkBusesLayoutSupported (monoLayout)); + REQUIRE (processor.setBusesLayout (monoLayout)); + + processor.prepareToPlay (48000.0, testBlockSize); + CHECK (processor.getTotalNumInputChannels() == 1); + CHECK (processor.getTotalNumOutputChannels() == 1); + + juce::AudioBuffer buffer (1, testBlockSize); + juce::MidiBuffer midi; + + for (int i = 0; i < 8; ++i) + { + TestHelpers::fillWithSine (buffer, 48000.0, 220.0, 0.6f, static_cast (i) * testBlockSize); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} + +TEST_CASE ("Bus configuration: stereo in/out (default) processes without crashing or producing NaN/Inf", "[robustness][bus-layout]") +{ + TwistYourGutsAudioProcessor processor; + processor.prepareToPlay (48000.0, testBlockSize); + CHECK (processor.getTotalNumInputChannels() == 2); + CHECK (processor.getTotalNumOutputChannels() == 2); + + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + for (int i = 0; i < 8; ++i) + { + TestHelpers::fillWithSine (buffer, 48000.0, 220.0, 0.6f, static_cast (i) * testBlockSize); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} + +TEST_CASE ("Extreme parameter automation: randomising every parameter every block never produces NaN/Inf", "[robustness][automation]") +{ + TwistYourGutsAudioProcessor processor; + processor.prepareToPlay (48000.0, testBlockSize); + + juce::Random random (2026); + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + for (int i = 0; i < 60; ++i) + { + randomiseAllParameters (processor.apvts, random); + TestHelpers::fillWithSine (buffer, 48000.0, 100.0 + random.nextFloat() * 8000.0, + 0.8f, static_cast (i) * testBlockSize); + + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} + +TEST_CASE ("Long-run stability: continuous processing over an extended run stays finite (no slow-building blowup)", "[robustness][long-run]") +{ + TwistYourGutsAudioProcessor processor; + processor.prepareToPlay (48000.0, testBlockSize); + + // Enable every stage at once (gate, EQ, IR loader all default-off) so + // the soak actually exercises the full chain, at settings well inside + // normal musical use rather than another extreme sweep. + auto setParam = [&] (const char* id, float value01) + { + auto* parameter = processor.apvts.getParameter (id); + REQUIRE (parameter != nullptr); + parameter->setValueNotifyingHost (value01); + }; + + setParam (ParamIDs::gateEnabled, 1.0f); + setParam (ParamIDs::eqEnabled, 1.0f); + setParam (ParamIDs::irEnabled, 1.0f); + + juce::AudioBuffer buffer (2, testBlockSize); + juce::MidiBuffer midi; + + // ~500 blocks at 512 samples/48kHz is ~5.3 seconds of continuous audio - + // long enough to catch a slowly diverging filter state, short enough to + // stay well under a minute even on Debug CI. + constexpr int numBlocks = 500; + + for (int i = 0; i < numBlocks; ++i) + { + const auto frequencyHz = 80.0 + 40.0 * std::sin (static_cast (i) * 0.05); + TestHelpers::fillWithSine (buffer, 48000.0, frequencyHz, 0.7f, static_cast (i) * testBlockSize); + + processor.processBlock (buffer, midi); + + if (i % 50 == 0) + { + INFO ("block " << i); + REQUIRE (TestHelpers::allSamplesFinite (buffer)); + } + } + + CHECK (TestHelpers::allSamplesFinite (buffer)); +} diff --git a/tests/VoicingTests.cpp b/tests/VoicingTests.cpp new file mode 100644 index 0000000..4840c07 --- /dev/null +++ b/tests/VoicingTests.cpp @@ -0,0 +1,190 @@ +#include "dsp/Voicing.h" +#include "TestHelpers.h" + +#include +#include + +#include +#include +#include + +// Issue #42: high-band distortion voicing (Gnaw/Wool/Razor) acceptance +// gates. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int numSamples = 2048; + + juce::dsp::ProcessSpec makeSpec (int numChannels = 1) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = testSampleRate; + spec.maximumBlockSize = static_cast (numSamples); + spec.numChannels = static_cast (numChannels); + return spec; + } + + constexpr std::array allVoicings { + tyg::VoicingType::gnaw, tyg::VoicingType::wool, tyg::VoicingType::razor + }; +} + +TEST_CASE ("Voicing: 0% blend is a passthrough within a small margin (dry-path DryWetMixer delay compensation)", "[voicing][dsp]") +{ + // Not bit-exact: the dry path runs through a Thiran (fractional- + // interpolation-capable) delay line inside DryWetMixer even when the + // requested delay is an exact integer sample count, so a tiny amount of + // allpass ripple is expected - this checks level transparency, the way + // GainProcessingTests.cpp's passthrough test does for the full chain. + for (const auto voicing : allVoicings) + { + tyg::Voicing voicingUnderTest; + voicingUnderTest.prepare (makeSpec(), 0.0f); + voicingUnderTest.setVoicing (voicing); + voicingUnderTest.setDrive (1.0f); + voicingUnderTest.setTone (0.5f); + + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 1000.0, 0.5f); + + juce::AudioBuffer reference; + reference.makeCopyOf (buffer); + + juce::dsp::AudioBlock block (buffer); + voicingUnderTest.process (block); + + const auto outRms = TestHelpers::rms (buffer); + const auto refRms = TestHelpers::rms (reference); + + INFO ("voicing index = " << static_cast (voicing)); + CHECK (juce::Decibels::gainToDecibels (outRms / refRms) == Catch::Approx (0.0).margin (0.2)); + } +} + +TEST_CASE ("Voicing: reports positive, plausible oversampling latency after prepare()", "[voicing][dsp]") +{ + tyg::Voicing voicing; + CHECK (voicing.getLatencySamples() == 0); + + voicing.prepare (makeSpec(), 1.0f); + CHECK (voicing.getLatencySamples() > 0); + CHECK (voicing.getLatencySamples() < 1000); +} + +TEST_CASE ("Voicing: no NaN/Inf and no runaway output at extreme drive for every voicing", "[voicing][dsp][robustness]") +{ + // The waveshapers themselves (tanh/hard-clip) are always bounded to + // [-1, 1], but the post-shaper mid-hump/scoop peak filter can still + // legitimately push a transient above unity when it's boosting (e.g. + // Razor's +5dB hump) - that's ordinary EQ-after-clipper behaviour, not a + // bug, so this checks for finiteness and the absence of a runaway + // feedback-loop-style blow-up rather than a strict unity ceiling. + for (const auto voicingType : allVoicings) + { + tyg::Voicing voicing; + voicing.prepare (makeSpec(), 1.0f); + voicing.setVoicing (voicingType); + voicing.setDrive (1.0f); // maximum drive + voicing.setTone (0.5f); + + // A hot input (well above 0dBFS) is exactly when an unbounded + // waveshaper would misbehave. + juce::AudioBuffer buffer (1, numSamples); + TestHelpers::fillWithSine (buffer, testSampleRate, 300.0, 4.0f); + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (voicing.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + + const auto* data = buffer.getReadPointer (0); + + for (int sample = 0; sample < numSamples; ++sample) + CHECK (std::abs (data[sample]) <= 10.0f); + } +} + +TEST_CASE ("Voicing: higher drive increases harmonic energy for every voicing", "[voicing][dsp]") +{ + // A pure sine driven harder through a nonlinearity gains energy outside + // its fundamental (harmonics), so a simple, topology-agnostic proxy for + // "more drive audibly changes the signal" is that the *difference* + // between the low-drive and high-drive outputs is clearly non-zero. + for (const auto voicingType : allVoicings) + { + tyg::Voicing lowDriveVoicing; + lowDriveVoicing.prepare (makeSpec(), 1.0f); + lowDriveVoicing.setVoicing (voicingType); + lowDriveVoicing.setDrive (0.0f); + lowDriveVoicing.setTone (0.5f); + + tyg::Voicing highDriveVoicing; + highDriveVoicing.prepare (makeSpec(), 1.0f); + highDriveVoicing.setVoicing (voicingType); + highDriveVoicing.setDrive (1.0f); + highDriveVoicing.setTone (0.5f); + + juce::AudioBuffer lowDriveBuffer (1, numSamples); + TestHelpers::fillWithSine (lowDriveBuffer, testSampleRate, 300.0, 0.7f); + juce::dsp::AudioBlock lowDriveBlock (lowDriveBuffer); + lowDriveVoicing.process (lowDriveBlock); + + juce::AudioBuffer highDriveBuffer (1, numSamples); + TestHelpers::fillWithSine (highDriveBuffer, testSampleRate, 300.0, 0.7f); + juce::dsp::AudioBlock highDriveBlock (highDriveBuffer); + highDriveVoicing.process (highDriveBlock); + + juce::AudioBuffer difference; + difference.makeCopyOf (highDriveBuffer); + difference.addFrom (0, 0, lowDriveBuffer, 0, 0, numSamples, -1.0f); + + INFO ("voicing index = " << static_cast (voicingType)); + CHECK (TestHelpers::rms (difference) > 0.01); + } +} + +TEST_CASE ("Voicing: no NaN/Inf across a denormal-range sweep for every voicing", "[voicing][dsp][robustness]") +{ + for (const auto voicingType : allVoicings) + { + tyg::Voicing voicing; + voicing.prepare (makeSpec (2), 1.0f); + voicing.setVoicing (voicingType); + voicing.setDrive (1.0f); + voicing.setTone (1.0f); + + juce::AudioBuffer buffer (2, numSamples); + const auto denormalValue = std::numeric_limits::denorm_min() * 4.0f; + + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + auto* data = buffer.getWritePointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + data[sample] = (sample % 2 == 0) ? denormalValue : -denormalValue; + } + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (voicing.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} + +TEST_CASE ("Voicing: switching voicing mid-stream never produces NaN/Inf", "[voicing][dsp][robustness]") +{ + tyg::Voicing voicing; + voicing.prepare (makeSpec (2), 1.0f); + voicing.setDrive (0.8f); + voicing.setTone (0.3f); + + juce::AudioBuffer buffer (2, numSamples); + + for (int iteration = 0; iteration < 12; ++iteration) + { + voicing.setVoicing (allVoicings[static_cast (iteration) % allVoicings.size()]); + TestHelpers::fillWithSine (buffer, testSampleRate, 250.0, 0.6f, static_cast (iteration) * numSamples); + + juce::dsp::AudioBlock block (buffer); + CHECK_NOTHROW (voicing.process (block)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +}