subpair is a deterministic, offline-first Python CLI for choosing two
subwoofer positions from solo Room EQ Wizard (REW) measurements. REW is used
read-only: fetch retrieves impulse responses, and every expensive operation
runs from a local NumPy cache.
Python 3.11 or newer is required.
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .Start REW's API server (default http://127.0.0.1:4735) and load at least
three solo measurements made with the same loopback timing reference. Then:
subpair fetch --count 12
subpair search --band 25 150 \
--delay-range -10 10 0.1 --gain-range -3 3 0.5 --eq-bands 7 --top 10
subpair report --top 5 --limit 15 --output subpair-report.htmlFor an aggressive, range-limited flat target with bounded boost:
subpair search --aggressive-correction \
--eq-range 30 90 --eq-range-slope 48 --max-boost 6
subpair report --top 5Omit --count to use every loaded measurement, or use --indices 1,3,5,7
to select explicit entries. fetch prints the complete REW measurement list
before selecting anything. It rejects mixed sample rates or response lengths;
responses are never resampled or extended to make them match.
The defaults write measurement-*.npz, manifest.json, and
search-results.json below .subpair-cache/.
The REW API is beta. subpair first loads the self-documenting root, locates
the OpenAPI document referenced by it, and only then resolves the measurement
list and impulse-response GET operations from the advertised paths. A
validated /doc.json compatibility probe is used only when old Swagger UI
HTML does not expose its specification URL. Arrays are decoded as big-endian
32-bit floats. The unwindowed, non-normalised impulse response is requested
when those query parameters are advertised.
All pair, polarity, relative-delay, and relative-gain settings are enumerated. The first sub is the 0 dB reference; the second receives the reported gain, polarity, and delay. The grid includes both range endpoints when they lie on the requested step.
Raw and EQ'd results are both rated with one scalar usable-output score. Higher is better, and the displayed score is relative to the best pair in that mode (best = 0 dB):
sound power = (1 - low-end weight) × equal-drive full-band SPL
+ low-end weight × excursion-weighted low-end power
score = sound power - dip weight × residual dip
The defaults are a 0.5 low-end weight and a 1.0 dip weight. In other words,
ordinary in-band pressure and low-frequency extension contribute equally in
dB, and each dB of the worst remaining local dip costs one score dB.
--score-low-end-weight 0..1 moves the output term from full-band SPL to
low-end power; --score-dip-weight 0..4 controls how strongly response
smoothness matters. A dip weight of zero rates output alone. The absolute
score_db values retain the cache's arbitrary acoustic level reference, but
subtracting the best value gives the stable, directly comparable
relative_score_db shown as Score in the CLI and report.
The residual-dip component compares each raw or corrected response with a one-third-octave-FWHM Gaussian smoothing of that same response and takes only the largest negative deviation. Real measured response outside the analysis band is included as smoothing margin before the result is cropped back to the scored band. This makes the number visually auditable: a broad roll-off is followed by the smoothed reference, while a narrow notch remains visible. There is no two-sided null-recovery heuristic and no excess-group-delay multiplier, so a 3 dB feature cannot be reported as a 19 dB magnitude null.
Excess group delay, its shape-neutral tail integral, its width-invariant peak, and CSD decay time remain in the result and report as diagnostics. They still gate unsafe automatic EQ boost, but they do not silently inflate the residual dip or act as score tie-breakers.
The exhaustive polarity/delay/gain grid uses the same equal-drive raw formula as a fast first pass. Full EQ fitting for every grid point would be prohibitively expensive, so each pair retains up to eight highest-raw-score configurations and eight lowest-residual-dip configurations. Fitted post-EQ score then selects the pair's reported polarity, delay, and gain tuple. This two-objective shortlist matters because a slightly quieter, smoother raw sum can require less attenuation and win after EQ. Raw and EQ'd tables report the same selected physical configuration so their before/after values remain comparable.
Each pair also reports delay_plateau_ms/gain_plateau_db: how far delay
or gain can drift while the raw score remains within 0.5 dB of its value at
the selected configuration. A wide plateau is forgiving; a narrow one is
more sensitive to real-world delay drift, temperature, or DSP quantization.
Low-end power replaces the old F3/F6 thresholds. It energy-averages the
one-octave broad response over the analyzed range through 100 Hz and weights
each frequency by the approximate amplifier and excursion cost of producing
pressure there. In the pistonic region, pressure is proportional to frequency
squared times cone displacement. Holding pressure constant one octave lower
therefore takes four times the displacement and, with the simple
voltage-proportional-to-displacement model available without driver data,
about sixteen times the amplifier power. The resulting f^-4 weight is
+12.04 dB per octave downward.
The searched gain is included through headroom_db/post_eq_headroom_db,
a negative gain applied to the actual compared response. The first sub is at
0 dB and the second receives the reported relative gain, so raw headroom is
the negative of any positive pair gain. Post-EQ headroom additionally removes
the fitted EQ response's largest in-band boost. The hottest driver is
therefore at 0 dB for every pair, and EQ boost cannot manufacture equal-drive
output capability.
Headroom is applied once to the complete raw or post-EQ sum before calculating
every scoring component, magnitude comparison, and final combined EQ
response. The report's Headroom column and copyable Preamp ... dB setting
use that same value. Exact electrical watts or cone excursion would require
driver impedance, motor, enclosure, limiter, and built-in DSP transfer data
which an acoustic REW impulse response does not contain, so low-end power is
an excursion-cost proxy rather than a claim of absolute output capability.
For minimum phase, subpair uses the real-cepstrum form of the Hilbert transform on the full available 0-to-Nyquist magnitude, not a brick-wall copy of the scoring band. The impulse is zero-padded by 4x for this transform, and log magnitude is floored 160 dB below its maximum. This limits circular Hilbert/cepstral wrap while making the bandwidth convention explicit. The energy-weighted constant component of excess delay is removed: it represents the arbitrary common timing offset, while relative arrival time remains in the complex sum.
The cached impulse's own length sets a native frequency resolution
(sample_rate / length) that zero-padding cannot improve — it can only
interpolate smoothly between what that capture actually resolved. Near DC
that native resolution covers a large fraction of an octave, so a short
sweep leaves few genuinely independent samples per octave in the sub-bass;
differentiating an interpolated phase there amplifies ordinary measurement
noise into large, sign-flipping excess-group-delay swings that have nothing
to do with the placement itself. excess_gd_ms/post_eq_excess_gd_ms,
excess_gd_tail_ms/post_eq_excess_gd_tail_ms, the excess-GD authority
gate, and the report's excess-GD plot are therefore progressively smoothed
below roughly six times the cache's native resolution per octave (capped at
4 octaves of smoothing so an unusually short capture can't smooth away the
entire analysis band) — negligible for a long sweep or well above the
sub-bass, strongest right where a short sweep's own resolution runs out.
sample_rate / length is a useful resolution heuristic, not a hard
measurement-theory cutoff, and "six native bins" is a chosen, tunable
estimator width (MIN_RELIABLE_NATIVE_BINS), not a threshold derived from
first principles. Consequently this smoothing preferentially preserves
genuine, resolution-supported low-frequency excess-GD features (a real
reflection or port resonance many bins wide relative to the cache's native
resolution) over noise concentrated near or below that resolution — it does
not leave every genuine feature bit-for-bit unaffected. A real feature whose
own bandwidth is comparable to or narrower than the smoothing width applied
at its frequency will still be attenuated somewhat, the same tradeoff any
smoothing-based denoiser makes; only pathologically short captures (well
outside normal REW usage) push the smoothing width large enough for this to
matter for an ordinarily-wide feature. The cache's native resolution and
this threshold are recorded in search-results.json's
settings.native_resolution block.
The automatic EQ simulator uses constrained greedy target matching with RBJ
constant-Q PK filters and the optional automatic low shelf. It evaluates the
largest raw-magnitude target errors and retains the PK or LS candidate that
most reduces the weighted global error. --eq-bands COUNT
allows 0–16 bands and defaults to 7; zero cleanly disables EQ. The default
--eq-target trend follows the broad response and --max-boost 0 preserves
cuts-only behaviour. --eq-target flat or --aggressive-correction uses a
flat in-range target; --max-boost permits 0–12 dB of combined boost.
--max-cut bounds any single filter's cut depth, 0–30 dB (default 18).
--eq-target dsp fits the same flat curve as flat, for placements you
intend to correct with a full-featured external DSP rather than subpair's
own conservative fitter. It is retained as a descriptive alias; unlike the
retired null/GD ranking, usable-output scoring has no target-specific dsp
exception, so dsp and flat produce the same numerical result for otherwise
identical options.
Boost filters are capped at Q 1 so the fitter cannot use a sharp resonant bell
to fill a narrow cancellation. Cuts may use Q up to 10 for modal peaks. The
combined response is checked against --max-boost after every candidate, and
areas with large excess group delay receive less fitting authority.
These safeguards follow the REW automatic-EQ controls
and miniDSP room-EQ guidance.
--eq-range LOW HIGH constrains filter centres. The target correction is
attenuated outside that range by --eq-range-slope (0–48 dB/oct); zero means
a hard target curtain. The fitted EQ bands may still have their natural skirts
outside the range.
EQ authority is reduced where absolute excess group delay is large relative to the local period. Subpair lightly denoises delay on the log-frequency grid (well under one bin, just enough that a single noisy sample can't set a gate by itself), then takes a maximum — not a moving average — over at least a one-third-octave window before smoothing only the resulting gate's edges. A moving average would dilute a peak in proportion to how much narrower it is than the averaging window, so a genuinely severe but narrow excess-GD spike could end up almost entirely ignored while a wider, shallower bump of the very same peak height was heavily gated; the maximum filter instead gates a narrow spike and a wide bump of equal height alike. It therefore cannot follow narrow point-to-point GD wiggles or jump abruptly from low to full authority over a couple of hertz, but does still respond fully to a genuinely narrow, severe spike. Authority falls rapidly as gated excess GD approaches 0.35 cycles, so the aggressive target does not blindly boost phase-storage nulls which are unlikely to respond to EQ. The effective, range- and excess-GD-aware target is available as a hidden trace on each magnitude plot.
The post-EQ tail score is a deterministic, one-third-octave CSD-style analytic-envelope estimate and should be treated as a comparative metric, not a room RT60 measurement.
--low-shelf on|off controls whether the automatic EQ fitter may use one RBJ
low-shelf band; it defaults to on. When enabled, the fitter chooses both the
corner frequency and the boost or attenuation independently for every pair.
The shelf competes directly with PK candidates against the same correction
target, obeys --max-boost, --max-cut, --eq-range, and the excess-GD-aware
objective, and consumes one of the slots allowed by --eq-bands when chosen.
on enables the candidate but does not force a shelf when it cannot improve
the fit. off leaves the entire band budget available to PK filters. The RBJ
slope is fixed internally at 1, the steepest transition without gain
overshoot; there are no manual shelf-frequency, gain, or slope flags. A fitted
shelf appears alongside the PK filters in the report as an LS Fc ... Gain ... Slope ... line and is included in every post-EQ plot and score.
Relative SPL is the energy-mean in-band SPL after applying the corresponding
raw or post-EQ headroom, so it compares equal maximum driver drive rather than
nominal searched levels. It contributes to sound power according to
--score-low-end-weight; the standalone table value is relative to the
highest-scoring pair in that mode. Gains are referenced to an equal 1 kHz
electrical drive.
Produces one HTML file containing Plotly itself, the sortable EQ'd ranking,
top-pair response diagnostics, post-EQ CSD heatmaps, and copyable EQ text.
Pass --raw to show the raw ranking and raw diagnostics instead, omitting the
EQ-specific plots and filter controls entirely. The table has selection checkboxes
(its top five are checked by default); the selected pairs feed the combined
overview and appear as pair tabs below the table for fast one-at-a-time
diagnostics. The overview switches between magnitude and excess group delay.
Number keys 1–9 open the corresponding selected pair tab. Metric cells use a
best-to-worst colour scale. report --top N changes the initial selection
count. report --limit N limits the table and its selectable diagnostics to
the top N results (default 15). No network connection or CDN is used when the
report is viewed.
The selected overview and pair diagnostics share one magnitude Y-axis range and one excess-group-delay Y-axis range, recalculated whenever the selection changes. The combined EQ response uses that same magnitude axis; when it is shown, small markers identify every fitted PK/LS band's configured frequency and gain. EQ response and band gains are included in the shared range so those markers remain visible. Excess-GD plots include zero but clamp their lower scale limit to -20 ms so large negative measurement-noise excursions do not flatten the useful part of the curves.
Each CSD heatmap includes the corresponding zero-referenced excess-group-delay curve and a solid 0 ms reference. A vertical overlay indicates constant delay; frequency-dependent bends expose excess storage without relying on visual estimation of the heatmap ridge. CSD figures are static to avoid accidental zooming or panning and appear before the separate excess-group-delay graph.
The table is initially sorted by the computed Score rather than an ordinal Rank column. It also shows the score's residual-dip, low-end-power, and Relative-SPL components plus the applied Headroom gain. Low-end power and Score use higher-is-better colouring; residual dip uses lower-is-better.
After physically measuring one selected sum, leave that new measurement in REW and run:
subpair verify --rank 1 --output verification.htmlThe command chooses the sole REW measurement not present in the cache. Use
--measurement INDEX_OR_UUID if more than one new measurement is loaded. It
checks sample rate and length, overlays measured versus predicted magnitude,
and prints the maximum in-band deviation. The comparison removes only one
constant level offset by default (--keep-level disables that); it does not
hide frequency-dependent mutual-coupling error.
Verification compares the physical sum with the unequalized prediction. It
applies the raw headroom_db global gain, but not PK filters, post-EQ
headroom, or the automatically fitted low shelf; the shelf follows exactly
the same verification semantics as every other EQ band.
- Solo measurements must share a loopback-derived absolute time base.
- The cache records each IR start time. Spectra are shifted onto that absolute time axis, then one common reference delay is removed. Only relative delay can affect the search.
verifyis the mutual-coupling sanity check. Linear superposition can fail when nearby drivers/enclosures interact acoustically or electrically.- Search output is deterministic for a fixed cache and options. Fetch metadata naturally reflects whatever REW returns.