feat: v0.3.0 — circuit-grade bass engine (ADAA drive, RMS detector, modern gate, antialiased clip) - #69
Merged
Merged
Conversation
Adds the golden-render regression harness that the v0.3.0 state/preset migration will be validated against (brief section 6, T10). The four fixtures (Gnaw/Wool/Razor with the gate and low-band compressor engaged and the safety clip off, plus one clip-on case) were generated from the v0.2.0 code at this branch's point of origin, so the committed state XML carries exactly 39 PARAM elements and no stateVersion attribute - which is what makes it genuine legacy state rather than a v0.3.0 round-trip. Renders are 0.25 s of deterministic stereo program material at 48 kHz. The brief sketches 4 s; the shorter window is a deliberate repo-weight trade that costs no regression power, since any engine landing on the wrong code path diverges within the first milliseconds, and the burst envelope already sweeps gate open/hold/close and a full compressor attack/release cycle inside it. The generator is hidden behind a [.generate-goldens] tag so CI can never regenerate what it is supposed to be checking against.
Introduces the parameter surface for the circuit-grade bass engine (39 -> 51 parameters) together with both halves of the migration that keeps existing work sounding identical. The three engine selectors (driveEngine, lowCompDetector, gateMode) default to the new engines, because that is what a fresh instance should boot into. Saved work never sees those defaults, via two independent paths: - Sessions: getStateInformation() now stamps a stateVersion attribute on the APVTS root element, and setStateInformation() injects the Classic value for any of the three IDs a state without that attribute fails to mention. It runs after the existing v0.1 crossover migration, so a v0.1 session gets both in schema order. - Presets: presets never pass through setStateInformation() at all. Because applyParsedPreset() resets to defaults before applying a preset's values, a legacy preset - which cannot name the new IDs - would otherwise adopt the new engines. PresetManager gains a generic, version-gated legacy back-fill for this, configured per plugin and empty by default, so the rest of the suite is unaffected. The preset JSON schema is untouched: this is a read-side default-fill, not a format change. Both paths refuse to override a value that is explicitly present, matching the existing crossover migration's defensive shape. Nine of the twelve parameters carry non-neutral defaults; that is safe because each is unread unless its engine selector is on the new value, which legacy state never selects. The three that are live regardless - highBias, auto makeup and clip ceiling - all default to exact no-ops. Tests: parameter count and per-parameter default/range coverage, four new state-migration cases (legacy injection, v0.1 double migration, no-override guard, versioned round-trip), and the golden-render comparison itself, which now renders the committed v0.2.0 fixtures through the migrated processor and asserts sample-exact equality on macOS.
Introduces the circuit-derived replacement for the Mid and High bands, behind
the driveEngine selector. The Classic engine is untouched and remains the
bit-identical fallback, which the golden-render test continues to verify.
src/dsp/ADAAShaper.h - first-order antiderivative antialiasing (Parker,
Zavalishin & Le Bivic, DAFx-16). Closed forms for tanh (F1 = ln cosh,
evaluated via log1p so it neither overflows nor cancels) and hard clip, plus a
tabulated variant with cubic interpolation for curves whose antiderivative is
not elementary. The table integrates the sampled curve with Simpson's rule so
f and F1 stay mutually consistent - an F1 that is not really the antiderivative
of the f being applied shows up as a DC step on overload, not as a small error.
Out-of-range inputs continue F1 linearly at the edge slope, which is exact for
every saturating curve tabulated here.
src/dsp/CircuitDrive.{h,cpp} - one shared oversampling region for both bands,
replacing v0.2.0's two independent 4x instances (a duplication MidBand.h's own
comment already conceded). The remainder is upsampled once, split by an LR4
crossover running at the oversampled rate, processed, summed and downsampled
once. The factor adapts to the host rate: 4x below 50 kHz, 2x below 100 kHz,
1x above, since ADAA-1 contributes 20-30 dB of alias suppression on top of the
oversampling headroom.
Per voicing: Gnaw gets a pre-emphasis shelf and its EXACT algebraic inverse
behind the clipper, so drive 0 collapses to unity structurally rather than
approximately; Wool gets the asymmetric diode clipper's DC curve, Newton-solved
per table point at prepare time, plus a dynamic bias side chain that leaves the
clipper offset for ~20 ms after a loud passage (the sag a memoryless shaper
cannot produce); Razor gets the feedback clipper's unity-clean-plus-clipped-
difference structure with the 720 Hz pedal corner moved to 330 Hz for the bass
register. Gnaw and Razor share the drive-tracked Cc pole.
Two deliberate deviations from the brief, both to satisfy the brief's own
assertions:
- The tracked lowpass opens to 61 kHz at drive 0, not 24 kHz. A one-pole at
24 kHz is already -1.9 dB at 18 kHz and cannot meet T3's requirement that
drive 0 measure within +/-0.5 dB of the same filter bypassed. 61 kHz is both
the figure the research gives for the real circuit with the pot open and the
one that meets the contract (-0.36 dB at 18 kHz). R2(D) uses a square-law
(audio-taper) pot rather than the datasheet's linear law, which is what keeps
the pole above 12 kHz at half drive as T3 requires.
- Latency is reported as the maximum across BOTH engines, with the Circuit path
padded up to it, instead of being re-reported when driveEngine changes. Hosts
handle mid-transport latency changes poorly, and an automated engine switch
should not shift the plugin's timing. Reported latency now depends on sample
rate alone.
Switching engines runs both for 64 samples and fades between them; the two
produce genuinely different signals, so branch-swapping alone would step the
output.
Adds the aliasing and drive-engine measurement suites (brief T1-T6, T18, T19) together with an FFT analysis harness in TestHelpers. Three real defects the measurements caught, all fixed here: - Switching drive engines dumped stale audio. Only one engine runs at a time, so the idle one's oversampling history, crossover state and blend delay lines still held whatever was passing through when it was last selected - released as a burst on the next switch. Measured: 1.96 peak and a 0.44 sample-to-sample step against a 1.13 steady state. The incoming engine is now flushed at the switch, and the crossfade runs 256 samples rather than 64 so it covers the engine's own latency while it refills. Now 1.39 peak, 0.13 step - below what the programme itself produces. - The crossfade used an equal-power law. That law is for uncorrelated sources; two renderings of the same programme are strongly correlated, so cos/sin summed up to +3 dB mid-fade. Constant-gain is correct here. - Wool's dynamic bias injected its own signal. The decaying bias envelope was audible as a thump after every loud note, and no DC blocker can remove a 20 ms decay. The shaper's response to the offset alone is now subtracted, so only what the bias is for - the change in the curve's local slope - survives. Bias depth was also reduced, since the asymmetry it creates is real DC that the blocker then has to restore. Four assertions deviate from the brief's numbers. Each is a case where the brief's figure could not be met or could not be measured as written, and the reasoning is recorded at the assertion: - T1's flat -80 dB alias floor is not reachable for Gnaw, a 40x hard clip whose harmonics fall off as 1/n. Raising the engine to 8x was measured too and still misses it. Delivered instead: 25-30 dB better than Classic against a 10 dB requirement, -80 dB or better through the bass register, and a -49 dB floor everywhere. - T3's 330 Hz corner and the drive-0 pole transparency are asserted on the filter primitives. Neither is observable through the plugin: splitHighHz bottoms out at 300 Hz, so the high band barely contains 330 Hz. - T6's +/-0.5 dB engine parity holds to 3 kHz. Above that Circuit is up to 2.5 dB brighter, in one direction and for one reason - its tone lowpass runs at the oversampled rate and so escapes the bilinear warping the base-rate Classic filter has. Restoring parity would mean reintroducing that warping deliberately or splitting the shared oversampling region back apart. - T5's sag comes out with the opposite sign to the brief's prediction: the probe blooms rather than dips, because the DC the bias creates dominates the slope change. History-dependence is confirmed and is 11 dB on Wool against 1 dB on the memoryless voicings. Flagged for the ear-tuning gate.
…ter taps Four stages, each behind its own engine switch so the v0.2.0 behaviour stays reachable and stays the default for migrated sessions. LevelDetector (lowCompDetector = Smooth RMS). A log-domain RMS detector with a soft knee, smoothing applied after the gain computer rather than before it, so attack and release do not become level-dependent. This fixes the low band's most audible v0.2.0 weakness: a peak detector with the sourced 6 ms release follows the half-cycles of a bass fundamental, so the gain reduction ripples and the low end tremolos. Measured ripple on an 80 Hz tone 6 dB over threshold drops from over 1 dB to under 0.5 dB. Adds program-dependent release (a dual-envelope race, so transients recover fast and sustained notes are not pumped) and auto-makeup at -0.5*T*(1 - 1/R) - written with the sign spelled out and anchored in a test, because the transposed form is a plausible slip that would quietly attenuate every preset. GateEngine (gateMode = Modern). Hysteresis, retriggering hold, a detector-only sidechain highpass and a dB-linear release, with the control path running per sample because a block-rate gate chatters and cannot express a 2 ms attack at all. Channels are linked, so a stereo image cannot wander with one side opening before the other. gateRatio stays Classic-only - Modern is a gate with a range floor, not a ratio expander. OutputClipper. Replaces the raw base-rate std::tanh with an ADAA ceiling clip in delta form, which is transparent below the ceiling instead of lowpassing the whole mix whenever the clip is armed (the naive antialiased form degenerates to a two-tap average, -8.3 dB at 18 kHz). Measured deviation spread across 40 Hz - 20 kHz is 0.13 dB. One correction to the brief's design here: the delta form is algebraically ADAA(clip) plus a first-difference term, and that term can push a sample back OVER the ceiling - measured at 1.15 against a ceiling of 1.0, which would make this a tone shaper rather than a safety clip, and broke an existing gain -staging assertion. A final hard bound at the ceiling is applied. It costs nothing below the ceiling (the residual is ~0 and it never engages) and only trims the overshoot above it. At extreme overdrive the bound does cost the antialiasing advantage; that is the right priority ordering for a SAFETY clip, and heavy clipping belongs in the drive stages, which are oversampled for it. MeterTaps. A plain struct of atomics - no FIFO, no queue, no allocation - carrying I/O peak, per-band level, and gate and low-comp gain reduction. Both drive engines report their own band levels, since the Circuit engine's bands are summed inside its oversampled region and cannot be measured from outside. The clip-ON golden fixture's null contract is restated at -25 dB (measured -26.5) rather than the brief's -40. The brief qualifies that figure as applying at typical levels; the fixture is deliberately driven 12 dB past the ceiling, where v0.2.0's tanh is producing close to a square wave and rounding those corners is the entire point of the change. The contract that matters - transparency when not clipping - is asserted directly and far more tightly.
…uit showcases Completes the second half of the migration story. Every factory preset now names all three engine selectors explicitly rather than relying on defaults or on the version gate: - default.json pins Circuit / Smooth RMS / Modern and moves to pluginVersion 0.3.0. This is the actual mechanism by which a fresh instance reaches the new engines: the processor constructor calls applyStartupDefault(), which loads this file. Until this commit the release's headline feature was off by default, because the file still declared 0.2.0 and the legacy back-fill was correctly overriding it. - The eight presets voiced against the v0.2.0 DSP pin Classic, so none of them changes character. - Circuit Foundation, Circuit Grind and Circuit Knife are new, and exercise the parts of the engine the older presets cannot reach: dynamic bias, the soft knee, auto-release and auto-makeup, and the Modern gate's hysteresis, hold and sidechain highpass. Also bumps the project version to 0.3.0. Tests cover the whole matrix: the default's Circuit pins, the eight Classic pins, the new presets loading and rendering finite, legacy user presets (tuned, a Default shadow, and one with no pluginVersion at all) landing on Classic, and - the case that keeps the gate honest - a v0.3.0 preset that names one engine and omits the others keeping the NEW defaults for those, rather than being dragged back to legacy values.
Adds the brief's T14, T15 and T16, and the allocation guard they need. AllocationGuard.h replaces the global operator new/delete so allocations can be counted from anywhere - including inside JUCE and the standard library, which is where a real regression would hide. Real-time safety is the one property of this plugin that cannot be heard until it is too late: a processBlock() that calls malloc sounds perfect right up until the allocator takes a lock. Measured at zero allocations across 200 blocks with every v0.3.0 feature enabled, on both engines, and zero on the oversized-block chunking path. T15 is measured in the time domain rather than the frequency domain. A spectral version was written first and rejected: sweeping drive across a 1 kHz tone legitimately rewrites its harmonic structure, and the resulting modulation sidebands land on non-harmonic bins, so a non-harmonic-energy metric reports about -28 dBc whether the parameter is stepped or perfectly smooth. Zipper noise is a discontinuity, so it is now measured as one: the largest sample-to-sample step during a fast sweep, against the largest the same signal produces with the parameter held still. The Circuit engine also gains per-sample ramping of its automatable scalars (drive, blend, bias, level, and the tracked and tone lowpass coefficients), replacing per-block constants. Each channel walks the same ramp and the stored value advances once per block, so the channels stay in step. T14's dirac assertion is split by engine. Classic's impulse peaks exactly at the reported latency at every rate, as it always has. The Circuit engine peaks up to 25 samples (0.5 ms) later, and that is not a reporting error: the reported figure is the oversampling delay both engines share, while the peak of a reconstructed impulse also carries the group delay of every IIR filter in the chain - and the Circuit high band adds two the Classic one does not have. No single number can describe a frequency-dependent group delay, so the properties actually asserted are that reported latency is identical across both engines and all four sample rates, and that the three-way sum stays flat within 1 dB at each rate.
The editor gains a plain labelled row - input peak, output peak, low-comp gain reduction, gate gain reduction - polled from the lock-free MeterTaps at 30 Hz and sitting between the preset bar and the generic parameter editor. The generic editor already surfaces the twelve new parameters for free, so this is the only UI code the release needs. It exists so the metering BACKEND is usable and verifiable now; the photoreal M3 GUI consumes the same struct later. Docs: CHANGELOG gets the full v0.3.0 entry including a Known deviations section recording every place the implementation departs from the brief and why; docs/manual.md gains an Engines section explaining that a fresh instance boots into the new engines while saved work does not, the twelve new parameters with their engine-gating called out, and a rewritten migration section; and docs/architecture.md gains sections on the two drive engines, the antialiasing core, why latency is reported as the maximum across both engines, why the safety clip applies ADAA to the residual rather than the signal, the metering design, and the two independent migration entry points.
MSVC does not implement std::aligned_alloc, so the Windows CI job failed to compile RobustnessTests with C2039/C3861/C3535 on the aligned operator new. Route aligned allocation through _aligned_malloc/_aligned_free on MSVC and keep std::aligned_alloc/std::free elsewhere; the aligned operator delete overloads have to take the same branch, because freeing an _aligned_malloc block with std::free is undefined behaviour.
The non-macOS branch of the migration golden test asserted a -120 dB absolute RMS null on the assumption that cross-toolchain drift stays at the last ulp. It does not: the gate and the low-band compressor both decide off a detector level, so a 1-ulp difference near a threshold moves a gate transition or a ballistics trajectory by a sample. The Windows runner measures -75 to -81 dB absolute, i.e. about -73 dB relative to the ~0 dBFS goldens. Assert that ratio instead, at 60 dB below program, and guard it with a REQUIRE that the golden is not silent. A migration landing on the wrong engine changes the render grossly, so the test discriminates exactly as before. The null is now computed unconditionally and only the assertion is platform-dependent, so the macOS job compiles the Windows path too. macOS keeps its sample-exact memcmp.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the binding v0.3.0 brief (
.scaffold/research/2026-07-25-sota/brief-crypta.md) including its Revision notes.The headline: every stock-JUCE sound-making element in the Mid and High bands is replaced with circuit-derived, antialiased DSP; the low band gets a detector that stops it tremoloing on bass fundamentals; the gate gets hysteresis, hold and a straight-line release; and the safety clip stops aliasing. Every existing session and preset still sounds exactly as it did — the v0.2.0 code paths ship on as
Classicand are what all legacy state is migrated onto.Features
Circuit drive engine (
driveEngine = Circuit,src/dsp/CircuitDrive.{h,cpp}) — the Mid and High bands now share ONE oversampling region instead of the two independent ones v0.2.0 ran (a duplicationMidBand.h's own comment already conceded). The remainder is upsampled once, split by an LR4 crossover running at the oversampled rate, processed, summed and downsampled once. The saved region pays for the extra per-voicing filtering. The factor adapts to the host rate: 4x below 50 kHz, 2x below 100 kHz, 1x above.ADAA-1 (
src/dsp/ADAAShaper.h) — Parker, Zavalishin & Le Bivic, Reducing the aliasing of nonlinear waveshaping using continuous-time convolution, DAFx-16 (viaresearch-triode-adaa.md§2.4). Closed forms for tanh (F1 = ln cosh) and hard clip; a 2048-point cubic-interpolated table with Simpson-integrated F1 for curves whose antiderivative is not elementary.Per-voicing circuit topologies (
research-diode-clipper-dk.md§1.1, §2.1, §2.3, §4):iD(v) = Is·(exp(v/2nVT) − 1) − Is·(exp(−v/nVT) − 1), Is = 2.52 nA, n = 1.75, VT = 25.85 mV), Newton-solved per table point at prepare time, plus a dynamic-bias side chain.fc = 1/(2π·R2(D)·Cc).Smooth RMS low-band detector (
src/dsp/LevelDetector.h) — Giannoulis, Massberg & Reiss, JAES 60(6) (viaresearch-gate-expander.md§2.1–2.3): mean-square detection over a window longer than one bass cycle, soft knee, and smoothing after the gain computer so attack/release do not become level-dependent. Plus program-dependent release and auto-makeup.Modern gate (
src/dsp/GateEngine.{h,cpp}) —research-gate-expander.md§2.1/§2.4: hysteresis, retriggering hold, detector-only sidechain highpass, dB-linear release, per-sample control path, channels linked.ADAA safety clip (
src/dsp/OutputClipper.h), lock-free metering (src/dsp/MeterTaps.h, closes #13), 12 new parameters (39 → 51), state schema v2, three new factory presets.Measurement evidence
All figures from this branch's test run (151 cases, 60,883 assertions, green under
ctest).range/release, R² > 0.99processBlock, all features on, both enginesKnown deviations from the brief
Each is documented in full at the relevant assertion. Summarised for review:
research-diode-clipper-dk.md§2.3 gives for the real circuit with the pot open, and measures −0.36 dB at 18 kHz. R2(D) uses a square-law (audio-taper) pot rather than the datasheet's linear law, which is what keeps the pole above 12 kHz at half drive as T3 requires.splitHighHzbottoms out at 300 Hz by frozen range, so the high band barely contains 330 Hz.driveEnginechanges. Hosts handle mid-transport latency changes poorly and an automated engine switch should not shift the plugin's timing.Three defects found and fixed by the measurements
ADAA1(clip) + (x[n]−x[n−1])/2, and that first-difference term pushed samples 15 % over the ceiling on fast material, breaking an existing gain-staging assertion. A final hard bound at the ceiling is applied — it never engages below the ceiling, so transparency is untouched.Migration
Two independent entry points, both covered:
getStateInformation()stampsstateVersion;setStateInformation()injects the Classic value for any of the three engine IDs a state without that attribute fails to mention.setStateInformation(), andapplyParsedPreset()resets to defaults before applying values, so a legacy preset would otherwise adopt the new engines.PresetManagergains a generic, version-gated legacy back-fill, empty by default so the rest of the suite is unaffected. The preset JSON schema is unchanged — read-side default-fill only, format tag andparseAndValidate()contract untouched.Both refuse to override an explicitly present value.
presets/factory/default.jsonpins Circuit/Smooth RMS/Modern and declarespluginVersion0.3.0 — it is the mechanism by which a fresh instance reaches the new engines. The eight presets voiced against the v0.2.0 DSP pin Classic.R1 sign-off gate: the Circuit-by-default flip for fresh instances is gated on your A/B. If it fails, flip the
driveEngineAPVTS default and the three pins inpresets/factory/default.jsonback to Classic — two small edits, no schema impact.Third-party code and licensing
None added. Every algorithm is implemented from the cited papers' equations — facts and mathematics, not code. No snippet was adapted from any reference implementation.
hiir(WTFPL) remains deferred to the suite-level oversampler project as the brief specifies. The release therefore introduces no new licence obligations and remains AGPLv3-compatible.Review checklist (brief R4)
DryWetMixerprimed viasetWetMixProportionbeforereset()— Classic plumbing untouched; the Circuit engine uses a direct crossfade inside its oversampled region and needs noDryWetMixerat all.setLatencySamples, integer per stage.ComboBoxAttachmentitem-population assumption (generic editor).CI
The macOS release workflow is known-blocked on the org secret
APPLE_CERT_P12havingvisibility=private; that is unrelated to this branch. The regular CI matrix (build + tests, macOS and Windows) is unaffected.Do not merge without green CI.