Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

subpair

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.

Install

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

For 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 5

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

Commands

subpair fetch

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.

subpair search

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.

Two rankings are calculated, both strictly lexicographic:

  1. Raw: an excess-GD-weighted maximum dip of the raw magnitude below a one-octave broad trend or a wide two-sided check, energy-weighted mean absolute excess group delay, the shape-neutral excess-GD tail integral, then worst raw time-to-minus-20-dB across one-third-octave bands.
  2. EQ'd: the same three measurements after applying the fitted PEQs.

The one-octave trend is itself a smoothing of the curve it's compared against, so a dip much wider than that window (a broadband suck-out from a large path-length difference between the two subs, say) is largely absorbed into the trend and under-reported. A second check catches this: at each frequency it looks for the best level well to the left and well to the right (past a one-octave margin so it isn't just re-detecting a narrow notch off its own shoulders); if both sides recover to a normal level, the gap between that and the current level counts as a dip, however wide. A plain monotonic rolloff — expected at a subwoofer's low end — never recovers on at least one side, so it is never flagged by this check regardless of its total range; only a single global reference (a percentile, a single very wide trend) would get that wrong.

A magnitude dip that coincides with real excess group delay is a genuine destructive-interference null — acoustically irreparable and audible as smearing, not just a shallow, EQ-fixable amplitude ripple. The null-score metric scales dip severity up (by up to +150%) where it overlaps excess GD, using the same risk gate that reduces EQ authority there (see below); a dip with no excess GD nearby scores the same as before. The plain, unweighted dip depth is still reported separately as magnitude_only_null_score_db / post_eq_magnitude_only_null_score_db.

A magnitude peak above trend still scores nothing on its own — reinforcement adds output rather than destructively cancelling it, and isn't the kind of summing-position problem a null is. But a peak that only exists with real excess group delay is a resonance/ringing signature (comb reinforcement with genuine energy storage that its own magnitude doesn't explain), not a benign constructive bump, and is scored the same way a dip's severity is inflated: proportional to the excess-GD risk alone. A minimum-phase peak (negligible excess GD) still scores exactly zero; only a non-minimum-phase one counts.

This GD weighting (both the dip-severity boost and the peak penalty) is applied once per finalist, not inside the fast exhaustive delay/gain/polarity search itself: true excess GD needs a minimum-phase extraction per candidate, which is too expensive to run over that whole grid.

The excess-GD scalar used by both rankings is normalized and integrated only over --eq-range (or the complete analysis band when --eq-range is not specified). Minimum-phase extraction and phase differentiation still use the full cached bandwidth and analysis grid so correction-range boundaries do not create Hilbert or numerical derivative artifacts.

That scalar is an energy-weighted mean, so a badly smeared region that happens to sit in a magnitude dip or near a band edge (where SPL is naturally low) barely moves it — two sums can look equally clean on that metric while one is audibly ringing somewhere the ear doesn't need much level to notice it. excess_gd_tail_ms/post_eq_excess_gd_tail_ms — the third tie-break level in each ranking — integrates |excess GD| over log-frequency across the same range, with every frequency weighted equally regardless of level, using the same np.trapezoid-style integration as the energy-weighted scalar rather than a plain index-based average. It is deliberately shape-neutral: a narrow, severe spike and a wider, shallower bump of the same area (peak height times width) score the same, rather than a peak detector (which only sees the narrow one) or a percentile (which is blind to anything narrower than its own width cutoff, however severe, the opposite failure). It exists to catch a sum that looks flat and clean on magnitude but is smeary in phase somewhere — a case the energy-weighted mean and the null-score metric (which only look where magnitude itself is unusual) can both miss.

excess_gd_peak_ms/post_eq_excess_gd_peak_ms — a fourth, final tie-break — is the deliberately width-invariant counterpart to the area-based tail metric above: the single worst denoised |excess GD| sample in the range, lightly pre-denoised the same way _excess_gd_authority's own gate is (so one noisy sample cannot set it by itself), then a plain maximum. A maximum is unaffected by a feature's own width — a narrow spike and a wide plateau of the same height already score identically — so this only ever separates two placements whose smeared area ties but where one still has a single sharper, more severe excursion the area-based tail metric alone cannot see. It does not replace excess_gd_tail_ms; like every metric in this ranking, it only breaks ties the earlier ones leave exact.

All four excess-GD-derived metrics above — the null-score GD weighting, excess_gd_ms, excess_gd_tail_ms, and excess_gd_peak_ms — measure "excess" relative to a single constant baseline (this curve's weighted median), so any frequency-dependent group delay at all counts as excess. An earlier, opt-in --gd-baseline monotonic mode instead fit a per-point baseline via weighted isotonic regression, constrained to be non-increasing in magnitude as frequency rose, modelling a genuine low-end group-delay rise as normal rather than as excess. It was removed after proving unreliable on real measurements: a non-increasing (pool-adjacent-violators) fit pools any later violation backward across everything before it, so a single local bump anywhere in the curve — ordinary measurement ripple, not necessarily a genuine feature — could inflate the fitted baseline into an implausible, near-flat plateau spanning nearly the whole band, several times taller than the curve's own genuine excess-GD peak. The resolution-aware smoothing described below already addresses the measurement-reliability concern that motivated it, so a single constant baseline is the only mode now.

There is no weighted blend. Each later metric only breaks an exact tie in the earlier metrics. --tie-tolerance-db (0–3 dB, default 0) widens "tie" to any null-score difference within that many dB, so the fast-search's finalist tie-break and the raw/EQ'd pair rankings fall through to excess-GD and tail time between practically-indistinguishable null scores instead of a below-audibility difference deciding the winner outright. The default of 0 preserves strict lexicographic behaviour, which means a null-score gap of a few tenths of a dB — well within the resolution/noise of the underlying measurements — can otherwise decide a pair outright over another candidate that is dramatically better on every other metric (excess GD, tail, SPL). A nonzero tolerance in roughly the 1–2 dB range is a reasonable starting point for real measurements; generate-reports.sh uses 1.5. Magnitude scoring and PEQ fitting use the raw log-grid samples without fractional-octave or variable smoothing. The one-octave trend is only the broad reference from which raw dips and the conservative target are measured. Both rankings use the same per-pair polarity, delay, and gain tuple selected by the exhaustive raw search; the EQ'd ranking answers which of those selected sums responds best to the requested correction.

Each pair also reports delay_plateau_ms/gain_plateau_db: how far delay or gain can drift from the chosen value while the raw magnitude null score stays within 0.5 dB of its optimum. A wide plateau is a forgiving setting; a narrow one is a razor's-edge optimum easily upset by real-world delay drift, temperature, or DSP quantization.

Each pair also reports low_end_extension_f3_hz/low_end_extension_f6_hz (and their post_eq_ counterparts): in-band F3/F6-style diagnostics giving the lowest frequency each pair's broad trend's envelope holds up before permanently falling 3 dB (F3) or 6 dB (F6) below its own peak and not recovering — i.e. where does this specific placement's own response start rolling off, relative to its own best-supported plateau, matching a classic F3 spec.

This is deliberately self-referential: the reference level is each pair's own envelope peak, not a level or curve shared across pairs. Two cross-pair-referenced designs were tried and reverted, because every real placement in a search rolls off toward the bottom of the band to some degree, so any reference built by aggregating across pairs — an elementwise average curve, or even the elementwise best (maximum) curve — inevitably has its own baked-in rolloff too. Comparing a pair's departure against such a reference hides exactly the amount of rolloff the reference itself has, and for whichever pair dominates the reference (typically the loudest candidate) it hides essentially all of it: confirmed on a real search, where the loudest pair visibly rolled off by a real, several-dB amount well above the band edge on its own magnitude plot, yet reported a trivial "fully extended" (25 Hz) answer under both designs, because it also defined (or nearly defined) whatever it was being compared against. Absolute cross-pair SPL is a different, legitimate question, already answered directly by the relative_spl_db/post_eq_relative_spl_db columns — check those alongside F3/F6 if you want to know how loud two placements are relative to each other, not just how each one's own low end holds up.

The scan starts at the envelope's own peak, not the value at the top of the band: a two-subwoofer sum is routinely bandpass-shaped (it rises out of the bottom of the band, peaks somewhere in the middle, and rolls off again toward crossover), so a pair whose passband merely peaks away from the top of the band isn't penalised for that alone. The envelope — the higher of the best level attained scanning up from the bottom of the band and the best level still attainable scanning down from the top — is used to find both the peak and the corner, deliberately not the raw trend: an isolated, recoverable notch is a placement defect the null score already measures on its own terms, so a response that is flat down to 25 Hz with one unrelated -5 dB notch at 100 Hz still reports ~25 Hz extension, not ~100 Hz. A genuine, sustained rolloff is not masked this way. Lower is more extended. This is purely informational — it is shown in subpair report's tables and printed by subpair search, but it is not a raw or EQ'd ranking key, so it never changes which placement wins; a placement's own null/excess-GD/tail severity always decides.

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 PEQ simulator uses constrained greedy target matching with RBJ constant-Q bells: it evaluates the largest raw-magnitude target errors and retains the 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 changes ranking, not just the suggested PEQ: null_score_db/post_eq_null_score_db barely count a minimum-phase dip at low excess GD, since any minimum-phase EQ (which describes essentially all DSP/PEQ hardware) can restore both its magnitude and phase exactly, as long as the correction fits inside your own --max-boost/--max-cut. A non-minimum-phase dip — real excess group delay, a genuine destructive- interference null — still scores up to the same maximum severity as the other targets, since no amount of magnitude-only correction fixes that. Practically, dsp mode ends up preferring flat excess group delay over flat raw magnitude: with ordinary magnitude problems assumed fixable later, what's left to differentiate placements is largely what a DSP can't fix. Minimum-phase peaks already score zero in every target, dsp included.

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

--low-shelf-freq HZ --low-shelf-gain DB [--low-shelf-slope S] adds a fixed, broad RBJ low-shelf boost or cut, for people who want a general tonality control (more or less sub-bass) alongside — or instead of relying entirely on — corrective EQ. --low-shelf-gain is required to be nonzero for the shelf to take effect (-15..15 dB); --low-shelf-freq alone is inert. --low-shelf-slope is the RBJ "S" shelf-slope parameter (0.1..1, default 1, the steepest transition without gain overshoot).

This is a search-time EQ setting, exactly like --max-boost/--eq-bands: it is folded into every post-EQ score (post_eq_null_score_db, post_eq_spl_db, ranking, everything), so a deliberate tonal choice can change which placement wins — changing it means re-running search, not a report-time toggle. report/verify no longer take their own --low-shelf-* flags; they display and predict whatever shelf the loaded search-results.json was generated with.

The shelf is still deliberately independent of fit_eq_filters's bounded bell fitter in one specific sense: the greedy PK-bell loop is fitted completely unaware of it, targeting the raw, unshelved response exactly as if the shelf were inactive, so a deliberate tonal tilt is never fought or cancelled by the corrective fitter the way a genuine response defect at the same frequencies would be. It is applied once, multiplicatively, after the bell bank is chosen — not counted against --eq-bands/--max-boost/ --max-cut/--eq-range — closer to how a fixed hardware shelf ahead of an adaptive room-correction DSP behaves than to another corrective filter competing for the same budget. In the report it appears in the fitted-PEQ text block as a separate LS Fc ... Gain ... Slope ... line, clearly marked apart from the PK bell lines above it.

Relative SPL is reported, not ranked. It is the energy-mean in-band SPL at the searched gain settings (gains are referenced to an equal 1 kHz electrical drive). Raw and EQ'd modes each reference their own rank 1.

subpair report

Produces one HTML file containing Plotly itself, the sortable EQ'd ranking, top-pair response diagnostics, post-EQ CSD heatmaps, and copyable PEQ text. Pass --raw to show the raw ranking and raw diagnostics instead, omitting the EQ-specific plots and PEQ 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.

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 ranking table also shows colour-rated, informational F3/F6 columns (see above); they use lower-is-better colouring, render as an empty gray cell when no meaningful crossing exists, and do not affect sorting or which pairs are recommended.

subpair verify

After physically measuring one selected sum, leave that new measurement in REW and run:

subpair verify --rank 1 --output verification.html

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

If the loaded search-results.json was generated with a low shelf active (see subpair search's Low shelf section above), verify automatically applies it to the predicted curve before computing the deviation, since verification is about checking one fully-specified, already-scored configuration — no separate --low-shelf-* flags are needed or accepted here.

Important assumptions

  • 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.
  • verify is 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.

About

Tool to find an optimal placement of two subwoofers using REW measurements.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages