No description
  • Python 67.1%
  • HTML 32.9%
Find a file
Stefan Tanta 97c84f9ab3 feat: macOS support, verified on hardware (Apple Silicon)
- 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>
2026-07-29 00:35:29 +02:00
docs feat: name factory curves after official DUSK presets; ship as UI presets 2026-07-29 00:03:40 +02:00
presets feat: name factory curves after official DUSK presets; ship as UI presets 2026-07-29 00:03:40 +02:00
tests feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00
.gitignore fixup! chore: gitignore stray root venv, local presets, tool artifacts 2026-07-29 00:03:59 +02:00
autofit.py feat: autofit module - fit N-band PEQ from measurement + target 2026-07-04 21:36:12 +02:00
bass_boost_9band.txt initial commit 2026-07-04 17:45:41 +02:00
CLAUDE.md feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00
DUSK_SP_PROGRESS.md feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00
duskeq.py feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00
duskeq.spec build: pyproject packaging + pyinstaller spec for duskeq-ui 2026-07-04 23:24:14 +02:00
duskeq_ui.html feat(ui): band editor panel and multi-band select with relative trims 2026-07-29 00:03:45 +02:00
duskeq_ui.py feat: name factory curves after official DUSK presets; ship as UI presets 2026-07-29 00:03:40 +02:00
pyproject.toml feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00
README.md feat: macOS support, verified on hardware (Apple Silicon) 2026-07-29 00:35:29 +02:00

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 hid backend 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 by pip 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/*.json next to duskeq_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 uses navigator.clipboard, which browsers restrict to secure contexts (https:// or localhost) — over plain HTTP on a LAN it falls back to a prompt() 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 09 + 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).
  • --bands caps how many PK filters are placed (default 9); --max-gain caps per-band |gain| (default 12 dB); -o FILE writes the result as a REW ParametricEQ.txt; --apply also 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-last re-applies whatever preset was last successfully written to the runtime DSP (by write, fit --apply, or the UI) — handy after a replug.
  • watch FILE polls 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-device polls for the DUSK-SP's USB presence and calls apply-last automatically the moment it's plugged in.
  • hotplug-install prints a ready-to-use background service that runs watch-device: a systemd --user unit 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_REPORT control transfers, report id 1; poll reply-ready flag resp[5] & 0x80.
  • Runtime DSP map (live, volatile): index 0 = preamp scalar · indexes 19 = 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^(25Gain), 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 reports interface_number, so device discovery is identical to Linux hidraw). hotplug-install prints 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 over hidapi instead of pyusb/libusb — code-identical CLI/UI, same protocol layer, no driver replacement needed. duskeq.spec builds 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-usb fd + 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.