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
..

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 .jsfx file 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 at tools/ysfx_render.cpp in this repo), built on the same ysfx.h API (ysfx_process_double). Loads, compiles, and actually runs a .jsfx file: 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), so tools/analyze.py / analyze_peak.py / analyze_boostcut.py can 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-9 works in a slider range declaration (slider1:x=100e-9<...>) but is a syntax error in @init/@slider/@sample/@gfx code. Use plain decimals (0.0000001).
  • Imported files need a real @init. A .jsfx-inc containing 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 before ysfx_tool pinned it down with a minimal reproduction - put every function ... ( ... ); after the @init line.
  • 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==0 one-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 an n==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 (which ysfx_render always injects at sample 0) instead of self-triggering - see offline_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 a Ch1LoFreq-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 via TrackFX_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. from sweep_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.sh runs deconvolve_sweep.py over every WAV in a directory against one dry sweep. The output IRs are directly usable with tools/analyze.py-style scripts, exactly like a tools/ysfx_render.cpp render.
  • analyze_reference_measurements.py - point this at sweep_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 numbers Rs/Rp/Rboost/Rcut calibration is fit against - see docs/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.