- Python 67.1%
- HTML 32.9%
- pyusb is now an optional import; only the usb backend needs it. usb.core.find sites go through _usb_find(), which maps a missing libusb (NoBackendError) to a plain try-next-backend DuskError. - device_present() checks backends in _open_transport() order (hid first) instead of requiring pyusb, unbreaking watch-device on macOS. - darwin backend order is hid-only: macOS holds HID interfaces exclusively, so the pyusb fallback can never claim iface 3 — it port-resets the device and misdiagnoses every failure as a CAF wedge (observed live; DUSKEQ_BACKEND=usb still forces it). - udev/hotplug-install are platform-aware: darwin prints no-setup- needed guidance and a launchd agent plist instead of systemd/udev. - hidapi promoted to a core dependency; docs updated (README, CLAUDE.md, DUSK_SP_PROGRESS.md macOS verification entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| docs | ||
| presets | ||
| tests | ||
| .gitignore | ||
| autofit.py | ||
| bass_boost_9band.txt | ||
| CLAUDE.md | ||
| DUSK_SP_PROGRESS.md | ||
| duskeq.py | ||
| duskeq.spec | ||
| duskeq_ui.html | ||
| duskeq_ui.py | ||
| pyproject.toml | ||
| README.md | ||
duskeq — MOONDROP DUSK-SP parametric EQ for Linux & macOS (CLI + Web UI)
Native control of the MOONDROP DUSK-SP's built-in DSP EQ over USB. No Android app, no browser WebHID, no cloud. Reverse-engineered from the official Moondrop Android app (the DUSK-SP is a Conexant CX Freeman3 DAC speaking the Conexant CAF protocol over HID control transfers).
Does more than the official app: 10 live EQ bands (app exposes 5), exact preamp, composite-curve auto-headroom, verified persistent flash presets, full read-back.
Files
| file | required | purpose |
|---|---|---|
duskeq.py |
yes | protocol + CLI tool. Everything else builds on this. |
duskeq_ui.py |
for UI | local web server wrapping duskeq.py (stdlib only) |
duskeq_ui.html |
for UI | frontend (canvas PEQ editor); must sit next to duskeq_ui.py |
DUSK_SP_PROGRESS.md |
no | protocol reference + reverse-engineering history (recommended to keep) |
*.txt presets |
no | REW/AutoEQ ParametricEQ files (e.g. bass_boost_9band.txt) |
No files are generated at runtime; state lives on the device.
Installation
duskeq CLI via pip (requires Python ≥ 3.8):
pip install . # from source checkout
duskeq --help # list commands
duskeq info # check device presence
duskeq-ui (web UI) requires a source checkout OR the PyInstaller binary (both keep duskeq_ui.html adjacent to the module):
# Source: install deps, run directly
pip install -e .
duskeq-ui # serves http://localhost:8765
# PyInstaller: build the standalone binary (requires pyinstaller)
pyinstaller duskeq.spec
dist/duskeq-ui
System dependencies
- Linux and macOS, both verified on hardware (macOS: Apple Silicon,
2026-07-29). Windows: code-identical
hidbackend exists but is untested there (see Roadmap). - Python ≥ 3.8
- macOS: just
pip install hidapi— no libusb, no sudo, no rules. pyusb is optional (usb fallback backend:brew install libusb; pip install pyusb). - Linux:
libusb-1.0(system package) —pyusb(the Python binding) is pulled in automatically bypip install .:
# Debian/Ubuntu:
sudo apt install libusb-1.0-0
Two transports exist: HidTransport (hidapi, preferred on every platform —
see the wedge-budget note in CLAUDE.md/DUSK_SP_PROGRESS.md) and UsbTransport
(pyusb + libusb fallback, DUSKEQ_BACKEND=usb to force). Same protocol,
different USB library underneath. duskeq.py udev prints the Linux udev rules
for both backends (raw-USB rule for usb, hidraw rule for hid); on macOS
it explains that no rule is needed.
Run without sudo (one-time, Linux only — macOS needs nothing)
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="35d8", ATTR{idProduct}=="1499", MODE="0666", TAG+="uaccess"' \
| sudo tee /etc/udev/rules.d/70-dusksp.rules
sudo udevadm control --reload && sudo udevadm trigger
# then replug the DUSK-SP
(duskeq.py udev prints the same instructions.)
Web UI
.venv/bin/python duskeq_ui.py # serves http://localhost:8765
- Graph: 10 draggable band dots — drag = freq/gain, mouse-wheel over dot = Q, double-click = enable/disable. Live composite response curve.
- Table: exact numeric entry, filter type per band (PK / LS / HS / LP / HP).
- Preamp: auto (computes true composite-curve peak, places it at −1 dBFS) or manual.
- Buttons:
- Apply to DAC (live) — runtime write, audible instantly, volatile (replug reverts to flash preset)
- Read from DAC — decode current DSP state (including preamp folds)
- Flatten DAC — bypass-flat runtime
- Save to flash (persistent) — survives replug, max 9 bands, confirmation dialog
- Load flash preset — read the saved preset into the editor
- REW/AutoEQ text import & export panel at the bottom.
The server holds the USB interface while running — stop it (Ctrl-C) before using the CLI. It auto-recovers the device's "wedged channel" state (see Quirks).
Presets, A/B, and sharing
- Preset library: Save preset stores the current editor state (server-side,
under
presets/*.jsonnext toduskeq_ui.py); the dropdown lists and loads them back into the editor (not applied to the DAC until you press Apply); Delete removes one. - A/B slots: Store A / Store B snapshot the current editor state into two in-browser slots; Apply A / Apply B push either straight to the DAC for quick comparison.
- Blind test: once both slots are stored, Blind test randomizes which slot is "X" and which is "Y" behind the scenes, lets you audition and vote, and tallies preference — the mapping is never rendered, so you can't peek.
- Share links: Share encodes the current bands/preamp into a
#p=…URL fragment (base64, no server round-trip) that recreates the editor state when opened elsewhere; loading is client-side, so nothing is applied until the recipient presses Apply. Copying usesnavigator.clipboard, which browsers restrict to secure contexts (https://orlocalhost) — over plain HTTP on a LAN it falls back to aprompt()dialog you copy manually.
Test signals
Pink noise, a sine tone at the currently-selected band's frequency, or band-limited noise around it — played in-browser via Web Audio, through whatever the browser's default audio output device is. Select the DUSK-SP as the system/browser output device to actually hear the EQ change; the buttons don't route audio through the device themselves.
LAN mode
duskeq-ui --host 0.0.0.0 [--port PORT] binds to all interfaces so another
device on the LAN (phone, tablet) can open the UI by browsing to the host
machine's hostname or IP. The server enforces a Host/Origin allowlist against
DNS-rebinding-style attacks: with --host 0.0.0.0 it accepts localhost,
127.0.0.1, the bind address itself, and the machine's own hostname/LAN IP
(resolved via socket.gethostname()); anything else is rejected with 403.
HTTP API (JSON)
| method + path | body | effect |
|---|---|---|
GET /api/status |
– | {connected, chip, sample_rate, eq_mode} |
GET /api/read |
– | decoded runtime slots 0–9 + bank-mismatch list |
GET /api/flash-read |
– | saved 9-band flash preset params |
GET /api/presets |
– | list of saved presets (server-side library) |
POST /api/apply |
{preamp: dB|null, bands:[{type,freq,q,gain,enabled},…]} |
live runtime write (≤10 bands; preamp:null = auto) |
POST /api/fit |
{measurement: raw text, target, bands} |
fitted {bands, preamp, residual_rms} (measurement-to-target PEQ, no device I/O) |
POST /api/reset |
– | flatten runtime |
POST /api/flash |
same as apply (≤9 bands) | persistent flash write + activate |
POST /api/preset-save |
{name, preamp, bands} |
save/overwrite a named preset |
POST /api/preset-delete |
{name} |
delete a named preset |
CLI
All commands: .venv/bin/python duskeq.py <command>
| command | syntax / notes |
|---|---|
read |
decode runtime EQ (preamp + 9 bands) |
read --rew |
same, printed as a REW ParametricEQ.txt (reconstructs preamp incl. folds) |
write FILE [--preamp dB] |
apply REW/AutoEQ file live. ≤9 filters → preamp gets dedicated slot 0; 10 filters → 10-band mode (preamp folded into filter 1). No Preamp: line → auto preamp (curve peak → −1 dBFS). Warns if explicit preamp can clip. |
reset |
flatten preamp + all 9 bands (replug restores flash preset) |
flash-read |
read saved 9-band preset (gains int-rounded in metadata — cosmetic) |
flash-write FILE [--preamp dB] --commit --yes WRITE_FLASH [--activate] |
persistent 9-band preset. Dry-run without --commit. --activate selects custom mode 0. Do not unplug during write (~10 s). |
mode |
read saved EQ mode (0 = custom preset) |
info |
chip code + sample rate |
dump-dsp [--start N] [--end M] [--bank 0|1] |
raw read-only dump of exact DSP indexes |
probe-read [--band 0-8] [--bank 0|1] |
minimal single-slot diagnostic read |
probe-step STEP [...] |
low-level diagnostics; write steps require explicit unsafe flags |
udev |
print the sudo-free udev rule(s) (raw-USB + hidraw) |
apply-last |
re-apply the last successfully applied runtime preset (from ~/.config/duskeq/last.json or $XDG_CONFIG_HOME) |
watch FILE [--interval SECS] |
re-apply FILE to the runtime DSP whenever it changes on disk (default poll: 0.5s) |
watch-device [--interval SECS] |
poll for the DUSK-SP and re-apply the last preset automatically on plug-in (default poll: 2s) |
hotplug-install |
print a systemd user unit that runs watch-device in the background |
fit MEASUREMENT [--target NAME] [--bands N] [--max-gain dB] [-o OUT] [--apply] |
fit a parametric EQ from a measured frequency response (see Fitting below) |
scan VID:PID |
read-only probe of an arbitrary vendor:product id (e.g. a sibling Conexant dongle); never writes |
EQ file format (REW / AutoEQ ParametricEQ.txt)
Preamp: -10.0 dB
Filter 1: ON LSC Fc 60 Hz Gain 9.0 dB Q 0.70
Filter 2: ON PK Fc 2800 Hz Gain -2.5 dB Q 1.60
Filter 3: ON HSC Fc 14000 Hz Gain 1.5 dB Q 0.70
Types: PK/PEQ, LS/LSC/LSQ, HS/HSC/HSQ, LP/LPQ, HP/HPQ.
Preamp: optional — omit for automatic headroom (recommended).
Importing an AutoEq profile: live AutoEq lookup/CLI integration was
deliberately not built — the DUSK-SP pairs with one known IEM, and AutoEq's
main value is looking up unknown headphones, which doesn't apply here.
Retargeting the DUSK-SP itself or EQing a different IEM off the swapped cable
is already covered by downloading that headphone's ParametricEQ.txt
manually from the AutoEq results repo:
duskeq.py write ParametricEQ.txt, or drop it into the UI's REW/AutoEQ import
panel — both already parse that file format natively (see above).
Fitting a PEQ from a measurement
fit turns a measured frequency response into an N-band parametric EQ by
greedily placing PK filters on the largest residual against a target curve,
then polishing all bands jointly:
.venv/bin/python duskeq.py fit my_measurement.csv --target harman-ie -o fitted.txt
.venv/bin/python duskeq.py fit my_measurement.csv --target flat --bands 6 --apply
- Measurement input: any REW export, AutoEq measurement text, or squig.link
CSV — anything that's lines of
freq, dB(comment lines starting with#/*/;are skipped). - Targets:
harman-ie(default, Harman in-ear target),flat,df-tilt(diffuse-field tilt). --bandscaps how many PK filters are placed (default 9);--max-gaincaps per-band |gain| (default 12 dB);-o FILEwrites the result as a REW ParametricEQ.txt;--applyalso pushes it straight to the runtime DSP.- The web UI's Fit to target panel does the same thing via
POST /api/fit, overlaying the imported measurement and the target curve on the PEQ canvas (measurement/target overlay works with or without fitting).
Automation: apply-last, watch, hotplug
apply-lastre-applies whatever preset was last successfully written to the runtime DSP (bywrite,fit --apply, or the UI) — handy after a replug.watch FILEpolls a REW/AutoEQ file and re-applies it to the runtime DSP whenever it changes on disk (e.g. edit-and-save from REW).watch-devicepolls for the DUSK-SP's USB presence and callsapply-lastautomatically the moment it's plugged in.hotplug-installprints a ready-to-use background service that runswatch-device: asystemd --userunit on Linux (requires the udev rule,duskeq.py udev, so it can run without root) or a launchd agent on macOS (no extra permissions needed).
Other Conexant devices (scan)
scan VID:PID performs a read-only probe of any USB vendor:product id — useful
for checking whether a different Conexant-based dongle speaks the same CAF
protocol, without ever writing to it. Writing to a non-DUSK-SP device requires
explicitly overriding the safety allowlist: set DUSKEQ_DEVICE=vid:pid to
target it and DUSKEQ_UNSUPPORTED_OK=1 to permit writes — both the CLI and the
UI server enforce this guard, and it's off by default so a stray env var can't
silently redirect a write to unknown hardware.
Device model (short)
- USB
35d8:1499, HID interface 3, Conexant CX Freeman3 (chip code 31988). - Transport: Conexant CAF messages over HID
SET_REPORT/GET_REPORTcontrol transfers, report id 1; poll reply-ready flagresp[5] & 0x80. - Runtime DSP map (live, volatile): index 0 = preamp scalar · indexes 1–9 = nine biquad bands · indexes ≥10 = alias region, never written (breaks a channel until replug; the tool refuses).
- Banks 0/1 = L/R channels — every write mirrors both.
- Flash preset (persistent): separate 9-band store (cmd 220), coefficients per sample rate (44.1/48/96/192/384 kHz); firmware loads flash band N → runtime index N at boot. Preamp is folded into band 1's coefficients (flash format has no preamp field).
- Coefficients: RBJ biquads, 24-bit fixed point, scale
2^(25−Gain),A0=−a1,A1=−a2. - Full protocol + history:
DUSK_SP_PROGRESS.md.
Safety & recovery
- Runtime writes are volatile — any weirdness: unplug/replug, done.
- Flash writes are guarded (dry-run default on CLI, confirm dialog in UI) and verified working; don't unplug mid-write.
- The tool never writes DSP indexes ≥ 10 or single banks — the two known ways to break channel balance.
Known quirks
- Wedged channel: after heavy command bursts (flash write) the device may stop replying to everything. Harmless; fixed by a USB device reset. CLI and UI server both auto-recover. Symptom if unhandled: all reads come back empty.
- Shelf label ambiguity: a low shelf + negative preamp fold can decode as the equivalent high-shelf cut (same transfer function, different name). Response is correct; only the label differs.
- Firmware writes index 0 as a negative scalar at boot (≈ −1 dB, polarity-inverted). Convention, inaudible; the tool reports it.
- Flash metadata gains are integer dB — display only; stored coefficients are exact.
Roadmap / portability
- macOS: verified on hardware (Apple Silicon, 2026-07-29): CLI read/info/mode
and the web UI all work via
HidTransport(IOKit reportsinterface_number, so device discovery is identical to Linux hidraw).hotplug-installprints a launchd agent instead of a systemd unit. - Windows: implemented, not yet verified on hardware.
HidTransport(pip install .[hid],DUSKEQ_BACKEND=hid) speaks the same CAF protocol overhidapiinstead ofpyusb/libusb — code-identical CLI/UI, same protocol layer, no driver replacement needed.duskeq.specbuilds a PyInstaller binary (duskeq-ui) for a no-Python-install path. Remaining work is running it on an actual Windows box to confirm. - Any phone/tablet, today: bind the server to
0.0.0.0(--host 0.0.0.0, see LAN mode above), plug the DAC into any Linux/macOS box (e.g. Raspberry Pi), open the UI from the phone's browser. - Android (DAC on the phone): WebView app + ~200-line Kotlin USB transport port, or
Termux (
termux-usbfd + pyusb) for tinkerers. - iOS (DAC on the iPhone): blocked by Apple (no raw USB/HID API for third-party apps — the reason the official iOS app doesn't work). Use LAN mode.
Provenance
Protocol reverse-engineered from the official Moondrop Android app (FreemanCnxtUsbDevice,
CafCmdHelper, CnxtUsbCommand in the Conexant SDK inside the APK), then verified
against the device band-by-band. Not affiliated with MOONDROP or Synaptics/Conexant.
Use at your own risk; nothing here can permanently brick the device short of unplugging
mid-flash-write, and even that only affects the EQ preset.