Files
julienandClaude Sonnet 5 daead8a1d9 Replace 0.7.0's Rq bandwidth mechanism with an L/C-ratio approach (0.8.0)
A Plugin Doctor side-by-side of a real UADx Manley Massive Passive VST
against PassiveFour.jsfx (band 1/Lo, ~150 Hz, ~5.7 dB boost) showed 0.7.0's
Bandwidth knob wasn't remotely close: the real curve is a broad, low-
shelf-like plateau spanning ~100-300 Hz, decaying gracefully out past 5 Hz
and 10+ kHz; ours was a narrow spike.

Root cause: Rq (0.7.0's parallel Q-spoiling resistor) caps a branch's
impedance at Rq everywhere, not just near resonance. A real L-C branch's
impedance keeps rising toward infinity far from resonance; a parallel Rq
never lets it, so pushing Rq low enough to matter didn't broaden the peak
so much as flatten the entire spectrum toward a constant gain floor
(measured directly: +4.8 dB everywhere, five octaves from center, at a
middling "wide" setting).

wdf_boost_cut_band() drops Rq entirely. Bandwidth is now the caller's
responsibility (PassiveFour.jsfx's process_band()): scale L up and C down
by the same z0_ratio, keeping L*C (and the resonant frequency) fixed while
changing the characteristic impedance sqrt(L/C). A branch built this way
still has unbounded impedance at the frequency extremes regardless of
ratio, so it can't degenerate into a flat floor - and this is how real
analog bandwidth pots actually work.

Verified end to end against the same real captured Lo-band measurement
(docs/passive-four/reference-measurements-2026-09-13.txt): PassiveFour.jsfx's
own curve now matches within ~0.2-1.6 dB from 5 Hz to 20 kHz, down from
being off by multiple dB at most frequencies.

Caught a second bug while implementing the fix: an early draft applied an
extra sqrt() to z0_ratio before scaling L/C, silently halving the knob's
effect in log-scale terms versus what BW_RATIO_MIN implied. Found by
re-deriving the L/C-ratio math from scratch (Z0_new/Z0_old = ratio needs
L *= ratio, C /= ratio directly, no sqrt) and re-validating
tools/analyze_boostcut.py's analytical reference against the corrected
formula (0.000 dB match) instead of trusting the first plausible-looking
result.

tools/check.sh also gained a 48 kHz/96 kHz sample-rate check (WDF port
resistances depend on 1/srate; previously only exercised at 44100).

circuit-notes.md documents this fix and separately answers an external
architecture review's other points (per-sample recomputation is
intentional/bug-fix-verified not an oversight, wdf_peak_band() is a
validation-only harness not used by the plugin, frequency prewarping is
handled via WDF port resistances and actively verified by the analytical
comparisons, and the 4-band cascade is a deliberate buffered-stages
design) - most were based on parts of the codebase that don't apply to the
shipped plugin or were already validated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:32:59 +02:00

20 KiB

Circuit notes

Disclaimer

This project does not use, and its authors do not have access to, any manufacturer's proprietary schematic. Passive Four's LC network and tube stage are an independent design built to reproduce the documented, audible behavior of bridged-T passive EQs of this style:

  • bandwidth that widens as boost/cut is increased,
  • an audibly different response when boost and cut are dialed in simultaneously on the same band (vs. either alone),
  • soft, tube-driven harmonic coloration and gentle compression at higher drive.

Component values (inductances, capacitances, resistive ranges) are estimates tuned by measurement and by ear against the target behavior above, not values copied from a real unit's schematic or service manual.

DSP approach (planned)

  • Passive LC ladder network modeled as a Wave Digital Filter (WDF): R/L/C one-ports plus series/parallel adaptors forming an adapted binary tree per band.
  • Per band: switched-frequency L/C pair (stepped, like a rotary switch), continuously variable bandwidth resistance, separate boost/cut resistive branches feeding the same bridged-T (this is what produces the proportional-Q interaction), and a peak/shelf topology switch.
  • Four bands in series per channel (band 1 → 2 → 3 → 4), matching the classic layout.
  • A single tube output stage per channel (rather than one per band) provides the harmonic coloration: modeled as a nonlinear WDF one-port (simplified triode transfer curve) solved via Newton-Raphson at the root of the tree, running under 4x oversampling to control aliasing.

This file will be expanded with actual component-value tables and measured frequency-response plots as each band is implemented and validated (see the project plan phases).

Boost/cut band status (implemented)

Effects/Passive Four/lib/band.jsfx-inc's wdf_boost_cut_band() implements the shared bridged-T principle described above: a fixed resistive pad (series Rs, shunt-to-ground Rp) sets a baseline attenuation; a boost L-C branch bridges across Rs (lower resistance = more boost) and a separate cut L-C branch bridges across Rp (lower resistance = more cut). Both branches currently share the same L/C (hence the same tuned frequency and, implicitly, the same Q) - real designs sometimes use different L values per side to equalize boost vs. cut Q, a possible refinement once this is characterized against real hardware measurements.

There is deliberately no independent Bandwidth/Q knob yet in this topology - it's set implicitly by Rs/Rp and the L/C pair. Adding one (without breaking the boost/cut interaction) is a planned follow-up.

Validated with tools/offline_test_boostcut.jsfx + tools/analyze_boostcut.py against the analytical H(s) = Zp/(Zs+Zp) (Zs = Rs ‖ (Rboost+sL+1/sC), Zp = Rp ‖ (Rcut+sL+1/sC)) across boost-only, cut-only, both-together, and baseline cases - matches to <0.001 dB. Confirms the interaction is genuine circuit behavior, not simple dB addition: with the values exercised in that test, boost alone reaches ~0 dB and cut alone reaches ~-45 dB at the tuned frequency, but both together land around -6 dB - neither cancels nor adds directly.

All 4 bands, per-band make-up gain, and stepped frequencies (implemented)

PassiveFour.jsfx now runs all 4 bands in series per channel (process_band(), wrapping wdf_boost_cut_band()), each with its own stepped frequency table (11 positions per band, read off a UADx Manley Massive Passive panel photo - all 4 bands cover different ranges, e.g. band 1 is 22-1000 Hz, band 4 is 560-27000 Hz, following the standard E12 component-value series). Rs/Rp (the pad) are still shared across all 4 bands for now, pending real per-band reference measurements to calibrate against.

Each band also gets a make-up gain of (Rs+Rp)/Rp, applied after wdf_boost_cut_band(), that exactly cancels that band's own passive insertion loss at rest (boost=cut=0). Without it, a band's baseline attenuation (~-15.5 dB with the current Rs/Rp) would compound across 4 series bands to roughly -60 dB even with every control untouched; with it, each band is transparent (0 dB) at rest and boost/cut swing symmetrically above/below that 0 dB point - matching the real unit's amount knob being labeled 0-20 dB as a boost-or-cut deviation, not an always-negative passive divider reading.

A real debugging story worth keeping: validating this 4-band cascade initially showed a reproducible ~0.7 dB error that looked exactly like a DSP bug - traced, sample-by-sample against a from-scratch double-precision Python re-implementation of the same network, to a resonant band's genuine initial-sample overshoot above 1.0 (a full-scale impulse into a band with make-up gain can legitimately peak above 0 dBFS), which the offline test renderer's original 16-bit PCM WAV output was silently clipping. The actual DSP was correct the entire time. See tools/README.md's gotcha list for the fix (32-bit float WAV) - this is exactly the kind of failure the offline-render-and-compare discipline below is meant to catch, except this time it caught a bug in the test tool itself rather than the circuit.

First real reference measurements (2026-09-13)

tools/sweep_fx_params.lua v1.0.2 (with corrected Enable/Gain semantics - see its changelog in index.xml) captured all 4 bands of a real UADx Manley Massive Passive VST via sweep response + deconvolution (tools/deconvolve_batch.sh), analyzed by the new tools/analyze_reference_measurements.py. Full per-band tables: docs/passive-four/reference-measurements-2026-09-13.txt. The raw WAVs/manifest/IRs are kept local only (not committed - large binary data, easy to regenerate from the same script run).

Headline findings, all four bands behave consistently:

  • Gain knob is non-linear, and cut/boost aren't mirror images. At the nominal 0-20 dB Gain setting: boost measures roughly 0.8/5.4-5.9/7.4-8.1/10.3-11.3 dB at the 5/10/15/20 dB marks (i.e. more than the dial says in the low-mid range, converging back near the dial value at max); cut measures roughly -0.6/-4.6 to -5.0/-6.3 to -7.0/-8.6 to -10 dB at the same marks - consistently a couple dB short of the dial's magnitude, and clearly not just boost's numbers negated. A single linear Rboost/Rcut sweep from 0 won't reproduce this; matching it will need a nonlinear (or piecewise) mapping from the Gain slider to Rboost/Rcut, calibrated separately for each direction.
  • The resonant peak migrates upward under heavy boost. At 20 dB boost, the measured peak sits ~15-30% above the nominal switch frequency for most bands/positions (e.g. Lo band's 150 Hz position peaks around 181 Hz), not at the nominal frequency itself. This is a real, repeatable effect (present at every frequency-switch position tested, all 4 bands), consistent with a passive network's resonance shifting as the boost branch's loading changes - not a measurement artifact (see find_peak()'s parabolic refinement in the analysis script, added specifically to rule out grid quantization as the cause). PassiveFour.jsfx's frequency tables may need a small per-position correction once this is reproduced in the WDF model, or the model itself may reproduce it for free once Rboost is calibrated correctly - worth checking before assuming a table fix is needed.
  • Bandwidth pot → Q is roughly monotonic but strongly nonlinear, and consistent in shape across all 4 bands: pot positions (0.1/0.3/0.5/0.7/0.9 normalized, reported here as the plugin's own raw display value ~1.2-2.8) map to measured Q from ~0.1 (very broad, multi-octave, sometimes not even closing a clean -3 dB point within the search window at the narrowest boost band's widest-BW setting) up to ~2-2.6 (fairly narrow) at the far end. A single linear Rs/Rp-vs-BW mapping is unlikely to fit this well; expect to need a nonlinear pot curve, same as Gain.
  • Shelf mode is a real shelf, bell mode is a real bell - confirms the earlier probe_enable_param.lua semantics finding at the DSP level, not just the display string. At 20 dB boost 3 octaves below/above center: BELL falls back to near 0 dB away from center (as expected of a peaking filter); SHELF stays near the full boosted level on one side and falls off on the other (asymmetric, as expected of a shelf) - e.g. Lo band SHELF measures ~20 dB three octaves below center (still fully "shelved up") vs. ~0.7 dB three octaves above.

Not yet done: fitting actual Rs/Rp/Rboost/Rcut/BW-pot component-value curves to this data per band (still shared across all 4 for now), and reproducing the peak-frequency migration under boost inside the WDF model itself.

Mode switch + calibrated Amount curve (implemented, 0.6.0)

The independent Boost/Cut knobs from 0.4.0-0.5.0 didn't match the real control: the hardware (and the real VST, confirmed via tools/probe_enable_param.lua) has one center-detented control per band, so CUT and BOOST are mutually exclusive, never both engaged at once. PassiveFour.jsfx 0.6.0 replaces them with a 3-way Mode switch (CUT/OFF/BOOST) plus one continuous Amount knob per band.

Amount (0-1, "how far the dial is turned") is mapped through gain_curve(), a piecewise-linear fit through the real measured cut/boost dB values at the 0/5/10/15/20 dB dial marks (averaged across all 4 bands - see the measurements above), then analytically inverted to the exact Rboost/Rcut pot value that makes this band's own bridged-T network hit that same dB at resonance. The inversion is closed-form, not iterative: at the design frequency the L/C branch's reactance cancels (s = j*w0), leaving a purely real voltage divider (boost: Zs = Rs ‖ Rboost; cut: Zp = Rp ‖ Rcut), so solving H_peak = Zp/(Zs+Zp) = target for the one pot in play is plain algebra - see process_band()'s comment block for the derivation.

Verified with tools/check.sh's live-parameter-change regression (BOOST direction, matches to 0.003 dB) plus a manual CUT/OFF check (CUT: -9.22 dB vs. -9.26 dB target; OFF: exactly 0.00 dB, confirming the band stays transparent at rest regardless of which direction was last selected).

Independent Bandwidth control (implemented, 0.8.0 - superseding 0.7.0's Rq)

Prompted by a concrete symptom: without any bandwidth control, a band's resonance narrows sharply as Amount increases (measured, before this change: ~1.7 octaves at 50% amount down to ~0.5 octaves at 100% on band 3) - the opposite of the real unit's documented "wider when boosted more" behavior, and visually/audibly "exploded" (spiky) at high boost.

First attempt (0.7.0, reverted): a series bandwidth resistor doesn't work. A series resistor in the boost/cut branch is numerically indistinguishable from just using a different Rboost/Rcut (both are plain series resistances feeding the same node, so they only ever appear as a sum) - one degree of freedom, not two, confirmed by direct experiment before rejecting it.

Second attempt (0.7.0, also reverted): Rq in parallel with the bare L-C pair. This does decouple bandwidth from peak height in principle - at resonance the L-C pair's impedance is ~0, shorting Rq out regardless of its value, so it can't affect the peak dB, only the shape away from resonance (confirmed: sweeping Rq held the measured peak within 0.01 dB while widening it). The flaw showed up once compared against a real curve: the user captured a side-by-side Plugin Doctor overlay of a real UADx Manley Massive Passive VST against PassiveFour.jsfx (band 1/Lo, ~150 Hz, ~5.7 dB boost) and the two didn't remotely resemble each other - the real curve was a broad, low-shelf-like plateau spanning roughly 100-300 Hz and decaying gracefully out past 5 Hz and 10+ kHz, while this project's was a narrow spike. Root cause: Rq caps the branch's impedance at Rq everywhere, not just near resonance - a real L-C branch's impedance keeps rising toward infinity far from resonance, but a parallel Rq never lets it, so pushing Rq low enough to matter didn't broaden the peak so much as flatten the entire spectrum toward a constant gain floor (measured directly on PassiveFour.jsfx: a real +4.8 dB everywhere, five octaves from center, at a middling "wide" Bandwidth setting - not a rounding error, a qualitatively wrong shape).

Current approach: vary the L/C ratio, not a resistor. Rq is gone from wdf_boost_cut_band() entirely; bandwidth is now controlled by the caller (PassiveFour.jsfx's process_band()) scaling L up and C down by the same z0_ratio factor, keeping the L*C product - and so the resonant frequency - exactly fixed, while changing the characteristic impedance sqrt(L/C). A branch built this way still has genuinely unbounded impedance at the frequency extremes no matter the ratio (only the steepness near resonance changes), so it structurally cannot degenerate into a flat floor the way Rq did - and this is how real analog bandwidth pots work (a resonant network's Q is set by its L/C ratio, not by a lossy shunt). z0_ratio=1 reproduces the exact pre-Bandwidth-knob curve; z0_ratio=BW_RATIO_MIN (0.02) is "fully wide." Verified end to end against the same real captured Lo-band measurement: PassiveFour.jsfx's own curve now matches the real unit's to within ~0.2-1.6 dB from 5 Hz to 20 kHz (both showing the same broad ~5 dB plateau), down from being off by several dB at most frequencies with either of the earlier approaches.

A second bug briefly reintroduced the same class of problem during this fix: an early draft applied an extra sqrt() to z0_ratio before scaling L/C, which silently made the knob's actual effect the square root of what the constant BW_RATIO_MIN implied - halving the intended widening in log-scale terms. Caught before release by re-deriving the L/C-ratio math from scratch algebraically (Z0_new/Z0_old = ratio requires L *= ratio, C /= ratio directly, no square root) and re-validating tools/analyze_boostcut.py's analytical reference against the corrected formula (0.000 dB match) rather than trusting that the first "plausible-looking" result was correct.

Current range and values (BW_RATIO_MIN) are shared across all 4 bands and picked by manual comparison against band 1's real captured curve only (same caveat as Rs/Rp/the gain curve) - a rougher approximation than either, and a good target for future per-band calibration against the rest of the measured BW-pot-to-Q data in docs/passive-four/reference-measurements-2026-09-13.txt. "Bandwidth" here is defined purely as this L/C ratio, not a calibrated real-world unit (e.g. not literally a -3 dB-point octave count, nor the real unit's own BW pot scale) - worth keeping in mind when comparing knob positions 1:1 against the real hardware.

WDF core status

Effects/Passive Four/lib/wdf.jsfx-inc implements the one-port (resistor, capacitor, inductor, ideal/resistive voltage source) and series/parallel adaptor primitives, with formulas cross-checked against the chowdsp_wdf C++ library.

Validated with tools/offline_test.jsfx + tools/analyze.py: a one-pole RC lowpass (ideal source → polarity inverter → series resistor+capacitor) matches its analytical response within 0.01 dB across the audible range once frequency warping from the bilinear discretization is accounted for (see analyze.py's comments - warping is expected/correct behavior of any bilinear-transform filter, not an error).

This caught a real bug during development: driving the series(R,C) network directly from a resistive voltage source (no polarity inverter) compiles and runs, but silently produces the wrong cutoff/decay rate and inverted DC gain - it "looks like" a lowpass but isn't the right one. The fix was to mirror chowdsp_wdf's own verified topology (ideal source → polarity inverter → series network) rather than the more "obvious" direct wiring. This is the reason this offline validation step exists at all: WDF wiring bugs are easy to write, compile cleanly, and sound plausible while being numerically wrong - every new topology (each EQ band's bridged-T network, the tube stage) should get the same offline-render-and-compare treatment before being trusted.

Answering an external architecture review (2026-09-14)

A review of this codebase (not run against the actual current state - it predates the Rq revert above) raised several points worth a permanent, explicit answer rather than a one-off reply, since they're the kind of question anyone reading this code cold would reasonably ask:

  • "@sample recomputes coefficients every sample instead of caching them in @slider." Deliberate, not an oversight - see the 0.3.6 changelog entry and tools/README.md's gotcha list. Caching in @slider was the original design and caused a real, shipped bug ("knob moves, sound doesn't change") because slider_automate() (used by the @gfx knobs) doesn't guarantee @slider reruns before the next @sample. Reverting to per-sample recomputation is the fix, reproduced and verified via tools/ysfx_render.cpp's --sliderN= override and locked in by tools/check.sh's "live parameter change regression". The transcendental-function cost here (a few sin/cos/log/pow calls per band per sample) is negligible next to the WDF network itself.
  • "wdf_peak_band() is a resonant band-pass, not an EQ bell that returns to 0 dB on both sides." Correct, and already stated in that function's own comment ("this stage's gain is <= 0 dB everywhere... it will not sound 'boosted' yet") - it's a Phase 3 validation harness for the WDF primitives against the Audio EQ Cookbook's band-pass formula (tools/offline_test_peak.jsfx/analyze_peak.py), not used anywhere in PassiveFour.jsfx, which exclusively calls wdf_boost_cut_band() via process_band().
  • "Frequency prewarping isn't visible in the code that computes L/C." It doesn't need to be there: WDF one-ports handle the bilinear transform via their port resistance formulas (wdf_inductor_R = 2L/T, wdf_capacitor_R = T/(2C), both in wdf.jsfx-inc), which is the standard WDF way of getting a correctly-warped digital filter without a separate prewarping step on the component values themselves. This is actively verified, not assumed: analyze.py/analyze_peak.py/analyze_boostcut.py all explicitly pre-warp their analytical reference frequency (freq_warped = (sr/pi)*tan(pi*f/sr)) before comparing, and every offline test matches that warped reference to <0.01 dB - if prewarping were missing or wrong, that comparison would fail, not silently pass.
  • "Four bands processed as independent blocks, each with its own ideal source, doesn't model the electrical loading between bands." True, and intentional - see the 0.5.0 changelog ("Bands run in series (band1 -> 2 -> 3 -> 4 per channel), matching the real signal flow"). This models 4 buffered/cascaded stages (consistent with each band being independently switchable in/out without audibly changing the others' behavior, which is how these units are actually used), not one shared passive network with mutual loading across all 4 bands at once. Modeling genuine cross-band electrical loading would be a real, much larger architectural change (shared source impedance and loading terms threading through all 4 bands' networks at once) - not attempted here, and not obviously the right target even for fidelity, since it's not clear the real hardware works that way either.
  • "gain_curve()'s calibration table should account for Rq." Moot now that Rq is gone (see above) - the boost/cut inversion math never depended on the bandwidth mechanism in either version (Rq or L/C ratio), since both leave the exact-resonance peak untouched by construction.

Not disputed, and acted on: the review's suggestion to test at multiple sample rates, not just 44100 - tools/check.sh now also runs the boost/cut check at 48 kHz and 96 kHz.