No description
  • Python 93.3%
  • Nix 6.7%
Find a file
kheq 96110cd4f1 Add CLAUDE.md
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.
2026-08-18 15:25:34 +00:00
kheq Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
.envrc Package as a Nix flake 2026-08-18 15:25:25 +00:00
.gitignore Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
CLAUDE.md Add CLAUDE.md 2026-08-18 15:25:34 +00:00
default.nix Package as a Nix flake 2026-08-18 15:25:25 +00:00
flake.nix Package as a Nix flake 2026-08-18 15:25:25 +00:00
kheq.example.yaml Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
LICENSE Document setup, workflow and the microphone limitation 2026-08-18 15:25:25 +00:00
package.nix Package as a Nix flake 2026-08-18 15:25:25 +00:00
pyproject.toml Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
README.md Add CLAUDE.md 2026-08-18 15:25:34 +00:00
requirements.txt Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
selftest.py Sweep measurement, filter fitting and SSC device control 2026-08-18 15:25:25 +00:00
shell.nix Package as a Nix flake 2026-08-18 15:25:25 +00:00

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:

  1. 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 raise correct_f_max to ~1000 Hz.
  2. 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_db defaults 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+
  • sounddevice needs PortAudio (bundled on macOS/Windows; apt install libportaudio2 on Linux)
  • zeroconf is optional and only used by kheq 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 in
  • output_channel per speaker — so each speaker can be swept on its own
  • host per speaker — the fe80::… address from discover
  • interface — en0, eth0, or on Windows the interface index from netsh 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Application — band count and legal ranges for frequency, Q and gain are read back from the speaker with /osc/limits rather 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.