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>
Local test environment (no REAPER required)
REAPER has no scripted/headless mode and there's no official standalone JSFX compiler, so every syntax/import/runtime error in this project used to mean: edit blind, ask for a REAPER reload, read the error off a screenshot, repeat. ysfx is an independent, open-source JSFX compiler/runtime (it vendors the actual Cockos EEL2 source, so it should match REAPER's behavior closely) with a buildable command-line tool - that closes the loop locally.
One-time setup
mkdir -p ~/tools && cd ~/tools
git clone https://github.com/JoepVanlier/ysfx.git
cd ysfx
git submodule update --init thirdparty/dr_libs # clap-juce-extensions isn't needed, skip it
cp /path/to/this/repo/tools/ysfx_render.cpp tools/ysfx_render.cpp
Then add these lines to the end of cmake.tools.txt (registers the extra
tool as a build target - not upstream, so it has to be re-applied on a
fresh clone):
# Local addition (not upstream): offline WAV renderer for the Passive Four
# project's test harnesses, so they can be run without REAPER.
add_executable(ysfx_render
"tools/ysfx_render.cpp")
target_link_libraries(ysfx_render
PRIVATE
ysfx::ysfx)
Then build:
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DYSFX_PLUGIN=OFF -DYSFX_TOOLS=ON ..
cmake --build . -j$(nproc)
-DYSFX_PLUGIN=OFF skips the VST3/AU/CLAP plugin build (and its heavier
dependencies) - only the core library and the tools/ CLI targets are
built. This produces two binaries in ~/tools/ysfx/build/:
ysfx_tool- upstream. Loads and compiles a.jsfxfile and reports compile errors (undefined functions, syntax errors, bad imports, etc.) with the same file/line info REAPER shows. Doesn't process audio.ysfx_render- the file copied in above (source vendored attools/ysfx_render.cppin this repo), built on the sameysfx.hAPI (ysfx_process_double). Loads, compiles, and actually runs a.jsfxfile: feeds it a unit impulse (silence otherwise) and writes the output to a 32-bit IEEE float WAV file (not 16-bit PCM - see the gotcha below for why that distinction actually mattered), sotools/analyze.py/analyze_peak.py/analyze_boostcut.pycan validate DSP behavior - not just "does it compile" - with no REAPER involved.
Usage
tools/check.sh
Compile-checks every .jsfx file in the repo, then re-renders and
re-validates both offline test harnesses
(tools/offline_test.jsfx/offline_test_peak.jsfx) against their
analytical references. Run this after any change to a .jsfx/.jsfx-inc
file, before asking for a REAPER reload.
For a one-off check or render:
~/tools/ysfx/build/ysfx_tool "Effects/Passive Four/PassiveFour.jsfx"
~/tools/ysfx/build/ysfx_render "Effects/Passive Four/PassiveFour.jsfx" /tmp/out.wav 44100 8192
python3 tools/analyze_peak.py /tmp/out.wav 1000 2 # freq/Q matching the sliders you care about
Testing a live parameter change (not just the default state)
ysfx_render accepts --sliderN=VALUE (0-based slider index) to change a
slider's value after a one-sample warmup block, with notify=false -
the strictest simulation of "a control changed without the host being
guaranteed to re-run @slider before the next @sample" (exactly what
slider_automate() from a @gfx-drawn knob does). The warmup matters:
without it, the override would land before ysfx_init()'s own pending
@slider run ever fires, leaving every coefficient at its never-computed
default instead of testing anything meaningful.
tools/ysfx_render "Effects/Passive Four/PassiveFour.jsfx" /tmp/out.wav 44100 8192 --slider0=2000
python3 tools/analyze_peak.py /tmp/out.wav 2000 2
This is how the 2026-09 "knob moves the native fader but the sound doesn't
change" bug was reproduced and fixed: coefficients cached in @slider
stayed frozen under exactly this scenario (~10 dB error at the new
frequency); recomputing them in @sample instead fixed it (<0.06 dB). See
the "live parameter change regression" step in tools/check.sh.
Known JSFX/EEL2 gotchas this caught
Learned the hard way, each one previously required a REAPER round-trip to discover:
- No scientific notation in code.
100e-9works in a slider range declaration (slider1:x=100e-9<...>) but is a syntax error in@init/@slider/@sample/@gfxcode. Use plain decimals (0.0000001). - Imported files need a real
@init. A.jsfx-inccontaining only function definitions fails with "has no @init or other code" when imported. - Functions must be defined inside
@init, not just before it. A function textually placed before the first@-section marker compiles fine standalone but is invisible ("undefined") from any other section of the same file, or from any file that imports it. This was misdiagnosed once as an import-transitivity problem beforeysfx_toolpinned it down with a minimal reproduction - put everyfunction ... ( ... );after the@initline. - 16-bit PCM WAV silently clips - use float. A band with make-up gain
applied can genuinely produce a resonant network's initial-sample
overshoot above 1.0 for a full-scale impulse (verified: 1.0816, not a
bug), which
ysfx_render's original 16-bit PCM writer clamped to exactly 1.0 before saving. That looked exactly like a real ~0.7 dB DSP bug in a 4-band cascade (reproducible, stable, present with any band above 0 dB of gain) until traced sample-by-sample against a double-precision Python re-implementation of the same WDF network, which matched perfectly except at the clipped sample. Switched to 32-bit IEEE float WAV, which has no such ceiling - every check.sh case now matches its analytical reference to <0.001 dB, not just within a tolerance band. - A file's own
n==0one-shot impulse trigger doesn't survive--sliderN=testing.ysfx_render's warmup block (see above) runs a 1-sample block before applying the override, which consumes ann==0-style internal counter some early test harnesses used to self-generate their impulse - the real measurement afterward then sees no impulse at all. Fixed by having those harnesses read the impulse from the input channels (whichysfx_renderalways injects at sample 0) instead of self-triggering - seeoffline_test_boostcut.jsfx.
Reference-measurement tools (also on ReaPack)
Separate from the offline test environment above, these tools automate capturing reference measurements from a third-party plugin (e.g. a real Massive Passive VST) inside REAPER, since Plugin Doctor's export turned out to be screenshot/visual-comparison only, not numeric:
dump_fx_params.lua- read-only ReaScript: lists every parameter (index/name/value/range) of a plugin with aCh1LoFreq-style parameter on the selected track, to the console and a text file next to the project. Run this first to find out what you're actually working with.sweep_fx_params.lua- ReaScript: automates a full freq/gain/ bandwidth/shape sweep across all 4 bands, rendering the plugin's response to a sweep media item for each point (a labeled WAV, plus a CSV manifest of the plugin's own real-world parameter values viaTrackFX_GetFormattedParamValue, so nothing about the plugin's internal normalized-to-real-units mapping needs to be guessed). Needs a sweep media item and a render-settings priming step - see the comment block at the top of the script for the full one-time setup.deconvolve_sweep.py/deconvolve_batch.sh- recover an impulse response from a sweep response (a plugin's recorded output to a logarithmic sine sweep, e.g. fromsweep_fx_params.lua) by regularized spectral division against the dry sweep (H = Wet*conj(Dry)/(|Dry|^2 + eps), then IFFT) - a hand-rolled radix-2 FFT, stdlib only, validated against a naive O(n^2) DFT to <1e-13 error and, end to end, against a known WDF band's analytical response to <0.06 dB.deconvolve_batch.shrunsdeconvolve_sweep.pyover every WAV in a directory against one dry sweep. The output IRs are directly usable withtools/analyze.py-style scripts, exactly like atools/ysfx_render.cpprender.analyze_reference_measurements.py- point this atsweep_fx_params.lua's manifest CSV and a directory of IRs (e.g.deconvolve_batch.sh's output) to get real dB numbers per band: measured cut/boost per Gain setting (relative to that band's own OUT/baseline recording, so the sweep's item-volume attenuation cancels out automatically), measured peak frequency per freq-switch position (parabolic-refined, not just grid-snapped), measured -3 dB bandwidth/Q per bandwidth-pot position, and a bell-vs-shelf shape comparison. This is the tool that turns raw reference recordings into the target numbersRs/Rp/Rboost/Rcutcalibration is fit against - seedocs/passive-four/circuit-notes.md's "First real reference measurements" section for the first results and what they imply about the DSP model.
Why a sweep instead of a literal impulse: an earlier impulse_gen.jsfx
approach (inject a single full-scale sample) didn't render reliably
through this particular plugin. A sweep is the standard alternative for
exactly this situation - real signal energy at every frequency instead of
one instant, then recovered into an IR by deconvolution against the known
sweep, same result either way.
The two ReaScripts (not the Python/shell deconvolution tools, which aren't
REAPER-loadable formats) are also published as this repo's ReaPack
packages (Effects/Passive Four Dev Tools and Scripts/Passive Four Dev Tools categories -
see the main README.md), so they can be installed/updated the normal
ReaPack way instead of copying files by hand.
Limitations
ysfx_render always feeds a unit impulse on every input channel at sample
0 (silence otherwise) - fine for impulse-response-style validation
(everything in tools/), not a substitute for listening to real program
material, checking the @gfx UI renders/looks right, or testing mouse
interaction - those still need REAPER.