- Python 93.3%
- Nix 6.7%
Records the invariants that look like conservatism a refactor would strip out: the 400 Hz ceiling, the 4 dB boost limit, power-domain averaging, log-grid resampling before smoothing, and the write ordering in write_eq. |
||
|---|---|---|
| kheq | ||
| .envrc | ||
| .gitignore | ||
| CLAUDE.md | ||
| default.nix | ||
| flake.nix | ||
| kheq.example.yaml | ||
| LICENSE | ||
| package.nix | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| selftest.py | ||
| shell.nix | ||
kheq — measurement-driven room EQ for Neumann KH DSP monitors
Plays a sweep through each speaker, records it with your microphone, works out what the room is doing, fits a bank of parametric filters, and writes them into the speaker's own DSP over the network. Then re-measures to check it actually helped.
Everything except the sound-card layer is offline-testable: nix flake check
(or python3 selftest.py) runs the full chain against a synthesised room and
asserts the result.
Read this before you use it
Your condenser microphone is the weak link, and it matters more than anything else in this project. A studio condenser is voiced, not calibrated: a large diaphragm cardioid typically has a presence lift of several dB somewhere between 2 and 12 kHz, a bass roll-off, and strong off-axis colouration. The software cannot tell the difference between "the room has a 4 dB peak at 6 kHz" and "the microphone has a 4 dB peak at 6 kHz". If you let it correct the top end, it will carve the microphone's own voicing into a pair of speakers that were flat to ±0.5 dB when they left Berlin.
So the default configuration only corrects 30–400 Hz. That is the range where
- the room genuinely dominates (below the Schroeder frequency of a typical small room, roughly 200–400 Hz),
- the errors are large (±10 dB is normal) and minimum-phase, so EQ actually fixes them rather than just moving them,
- and most decent condensers are reasonably well behaved.
This is also, not coincidentally, where the audible benefit is. Below 400 Hz you can expect a real improvement. Above it you mostly need acoustic treatment and speaker placement, not filters.
To go higher you need one of:
- A calibrated measurement mic (miniDSP UMIK-1, Earthworks M23, Sonarworks
XREF20 — all ship with an individual calibration file). Point
mic_calibration:at the file and raisecorrect_f_maxto ~1000 Hz. - A calibration file for your existing mic, from a lab like Cross Spectrum Labs. Note a 0°-incidence file is what you want here, and it must be your capsule's file, not a model-average.
If you have neither, leave the defaults alone. It will still be worth doing.
Two more honest limits:
- Deep nulls cannot be equalised. A cancellation dip from a boundary
reflection is not minimum-phase; adding 12 dB of boost burns headroom and
woofer excursion and the null stays exactly where it was.
max_boost_dbdefaults to 4 dB for this reason. Don't raise it much. - This corrects one listening position. Multi-position averaging (default 5 positions) makes the result robust over a head-sized area, not over the room.
Install
Nix (flake)
nix run github:you/kheq -- devices # or: nix run . -- devices
nix build # ./result/bin/kheq
nix develop # dev shell with everything in it
nix flake check # builds + runs the offline self-test
Outputs:
| output | what it is |
|---|---|
packages.default / packages.kheq |
the CLI, bin/kheq |
apps.default |
nix run entry point |
devShells.default |
Python env + PortAudio (+ alsa-utils on Linux) |
checks.package |
package build, whose checkPhase runs selftest.py |
checks.selftest |
the self-test on its own, so failures point at the DSP not the packaging |
overlays.default |
adds kheq to a nixpkgs instance |
formatter |
nixpkgs-fmt |
Systems: x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin.
There is no committed flake.lock; run nix flake lock once (or nix flake update) to pin nixpkgs.
To use it from your own configuration:
{
inputs.kheq.url = "github:you/kheq";
# …
nixpkgs.overlays = [ inputs.kheq.overlays.default ];
environment.systemPackages = [ pkgs.kheq ];
}
default.nix and shell.nix are there for non-flake use (nix-build,
nix-shell), and .envrc contains use flake for direnv.
PortAudio is dlopen'd by sounddevice; nixpkgs already patches the store path
into the module, so it works without LD_LIBRARY_PATH fiddling. If PortAudio
reports no devices, that is your audio server rather than the package — check
aplay -l / pw-cli from inside nix develop.
Plain Python
pip install -r requirements.txt
- Python 3.10+
sounddeviceneeds PortAudio (bundled on macOS/Windows;apt install libportaudio2on Linux)zeroconfis optional and only used bykheq discover
On the speaker side you need the rear-panel CONTROL switch set to NETWORK —
in the LOCAL position the speaker ignores everything sent to it — and IPv6
enabled on your machine. Neumann's SSC control runs over IPv6 only, usually on
link-local fe80:: addresses, which is why every command needs to know the
network interface.
Setup
cp kheq.example.yaml kheq.yaml
kheq devices # find your interface's device index
kheq discover # find the speakers, needs `interface:` set
Fill in kheq.yaml:
input_device/input_channel— where the microphone is plugged inoutput_channelper speaker — so each speaker can be swept on its ownhostper speaker — thefe80::…address fromdiscoverinterface—en0,eth0, or on Windows the interface index fromnetsh interface ipv6 show interface
Then check the connection and back up whatever is in the speakers now:
kheq info --speaker left
kheq backup before-kheq.json
That backup matters if you have ever run MA 1: kheq writes to the same EQ bank,
so applying it overwrites the MA 1 alignment. restore before-kheq.json puts it
back.
Microphone placement
Mic on a stand at the listening position, capsule at ear height, pointing at the speaker being measured (not at the ceiling — a cardioid condenser is only predictable on axis). Get it away from the desk surface. Leave the room, or at least sit still and keep quiet; the sweep needs a clean capture.
Running it
Guided, end to end:
kheq auto --speakers left right
That measures each speaker at 5 positions (prompting you to move the mic), fits
filters, matches the two speakers' levels, applies everything, and re-measures to
verify. Add --save to persist to the speakers' flash, and --no-apply if you
want to look at the plots first.
Step by step, which is what I'd recommend the first time:
kheq measure --speakers left right
kheq design --speakers left right # writes plots + eq-*.json
# look at kheq-out/design-left.png before going further
kheq apply --speakers left right --dry-run
kheq apply --speakers left right --trim-level
kheq verify --speakers left right
kheq reset clears the EQ bank; kheq restore puts an old backup back.
Reading the plot
Top panel: grey traces are the individual mic positions, blue is the smoothed average, dashed is the target, red is what the EQ should give you. Middle: the grey line is what the EQ wants to do, purple is what the filter bank can actually deliver — where they diverge, you've run out of bands or hit the boost limit. Bottom: error before and after, plus the spread across mic positions. That dotted spread line is the useful one: wherever it's large, the response is position-dependent interference and no EQ is going to fix it, which is why the fitter down-weights those regions.
How it works
- Excitation — 10–12 s exponential sine sweep, 15 Hz to ~22 kHz, with a silent tail for the room's decay. The long sweep buys signal-to-noise in the bass, which is where the measurement is hardest and matters most.
- Deconvolution — regularised spectral division (Kirkeby) of the recording by the reference sweep, with frequency-dependent regularisation so the inversion doesn't explode outside the excited band. Because harmonic distortion products land before the linear impulse in an ESS measurement, the code checks that region and warns you if the sweep is too loud.
- Windowing — the impulse is aligned on the direct sound and cut with a long (300 ms default) tapered window, deliberately keeping the modal decay. Gating shorter would show you the speaker's anechoic response, which is already fine and isn't the thing you're trying to fix.
- Spectrum — power spectrum resampled onto a log-frequency grid (48 points per octave) before smoothing, so the top end doesn't dominate the averages. Smoothing is frequency-dependent: 1/6 octave in the bass, widening to 1/3 octave above 1 kHz.
- Spatial averaging — energy (not dB) average across mic positions, so a deep null at one position can't drag the average down and provoke a large useless boost.
- Target — flat with a gentle downward tilt (−0.5 dB/octave by default), level-matched to the measurement over 200 Hz – 4 kHz so the correction can't quietly change your monitoring level.
- Fitting — the desired correction is clipped to
max_boost_db/max_cut_db, weighted with a half-octave taper at the band edges and down-weighted where positions disagree, then filters are placed greedily on the largest remaining error and finally refined jointly with a bounded least-squares solve. Filters are modelled as RBJ biquads at the device sample rate so what's fitted is what the DSP will actually produce. - Application — band count and legal ranges for frequency, Q and gain are
read back from the speaker with
/osc/limitsrather than hard-coded, the fitted bands are clamped to fit, and the boost array is zeroed before the frequencies and Qs are rewritten so a half-completed write can never leave a narrow high-gain filter sitting at the wrong frequency.
Protocol notes
The speakers speak Sennheiser Sound Control: line-delimited JSON over TCP port 45
over IPv6. The EQ bank lives at /audio/out/equalizer as parallel arrays:
{"audio":{"out":{"equalizer":{
"enabled":[true,...], "type":["PARAMETRIC",...],
"frequency":[...], "q":[...], "gain":[...], "boost":[...]}}}}
boost is the per-band gain in dB. {"device":{"save_settings":true}} persists
to flash. kheq.ssc also exposes schema() and limits() so you can explore
the address space of your particular firmware:
from kheq.ssc import SscDevice
with SscDevice("fe80::…", interface="en0") as d:
print(d.schema(["audio", "out"]))
If your speakers expose their EQ somewhere else, or you're driving something
else entirely, SscDevice.eq_path() is the single place to change.
Configuration reference
See kheq.example.yaml — every key is commented. The ones worth thinking about:
| key | default | notes |
|---|---|---|
correct_f_max |
400 | only raise this with a calibrated mic |
max_boost_db |
4 | resist the temptation |
n_bands |
8 | KH DSP models expose 10 slots |
target_tilt_db_per_octave |
−0.5 | 0 for flat, −0.75 for an untreated room |
positions |
5 | fewer than 3 and the average isn't worth much |
window_post_ms |
300 | longer resolves modes better, needs a quiet room |
mic_calibration |
null | point this at a cal file if you have one |
Files
kheq/sweep.py sweep generation, deconvolution, windowing, distortion check
kheq/spectrum.py log-grid spectra, variable-octave smoothing, mic calibration
kheq/measure.py measurement session, multi-position averaging, save/load
kheq/target.py target curve
kheq/biquad.py RBJ biquad magnitude responses
kheq/fit.py greedy placement + bounded joint refinement
kheq/design.py measurement + config -> device-ready bands
kheq/report.py plots and text summaries
kheq/ssc.py Sennheiser Sound Control client
kheq/cli.py command line interface
selftest.py offline validation against a synthesised room
CLAUDE.md repo conventions and the invariants not to break
flake.nix packages / apps / devShells / checks / overlay
package.nix the derivation itself, also usable via callPackage
default.nix nix-build shim for non-flake users
shell.nix nix-shell shim for non-flake users
MIT licensed. Use at your own risk — it writes to your speakers, and while it
backs up first and clamps everything to the device's own stated limits, you
should keep that backup file.