Documentation · Manual
moondrop_control.py
Every flag, what it takes and what it prints. The first connected supported device is
used automatically; --pid names one explicitly where it matters. Examples
below use python3 moondrop_control.py — if you installed the package,
hub-moon is the same program.
- Bands
- 8 (index 0–7)
- Gain
- ±12 dB
- Frequency
- 20 Hz – 20 kHz
- Q
- 0.1 – 10
- Filter types
- 6
- Pre-gain
- −12 … 0 dB
- Writes
- flash by default
- Exclusive modes
- --json · -i
Synopsis
Read and write flags compose, and are applied in a fixed order with
--save-flash last — so one invocation can preview a change and then persist
it. --json and -i are exclusive modes and ignore the rest.
$ python3 moondrop_control.py --set-peq 0 peaking 1000 -3 1 --no-flash --save-flash # └─ written live to the DSP ─┘ └─ then committed ─┘
python3 moondrop_control.py runs the file directly — the form to use
from a clone. hub-moon is the installed console entry point, and
hub-moon-gui is the windowed one that goes straight to the desktop app.
Discovery and state
Every connected Moondrop device, with vendor and product ID and the hidraw path. Unsupported-but-known devices are listed with a note rather than hidden.
Device name, firmware version, the active EQ profile number, pre-gain, global gain and the band count. Read-only.
Dump every PEQ slot as it currently reads back from the DSP — filter type, frequency, gain and Q per band.
This file's own device table as JSON, including each device's
product_uuid. Opens no device.
The full device state on stdout, as one JSON object — the shape a front-end should read rather than reimplementing the registry. Exclusive mode.
Name a device explicitly instead of taking the first connected one, e.g.
--pid 011d. Also selects which device's library --presets
fetches.
$ python3 moondrop_control.py --json { "ok": true, "device_name": "DAWN PRO2", "product_id": 285, "firmware": "1.5", "active_eq_profile": 9, "peq_index": 7, "supports_pregain": true, "pregain": -4.80078125, "global_gain": 0.0, "bands": 8, "filters": [ { "index": 0, "frequency": 60, "q": 0.30078125, "gain": -4.5, "type": "peaking" }, { "index": 1, "frequency": 1500, "q": 0.6015625, "gain": -1.5, "type": "peaking" }, ... ] }
Writing
One band. Index is 0-based and must be inside the device's band count; the types are below. A filter the firmware cannot represent is refused with the largest gain that would fit — see §10.
Headroom, in dB. Boosting bands adds gain the DAC has to find somewhere; this is where it comes from. Three devices report no pre-gain support — the flag warns and writes anyway, since whether the firmware ignores it is untested.
The DAC's global output offset, independent of the EQ.
Select the active EQ profile slot. Custom PEQ is profile 7 on every supported device except the E.S. combo, which uses 4. Read §12 before drawing conclusions from this number.
| TYPE | Filter | Uses |
|---|---|---|
| peaking | Peaking / bell | The general case — lift or cut a band around a centre frequency |
| low_shelf | Low shelf | Everything below a corner, by a fixed amount |
| high_shelf | High shelf | Everything above a corner. The type most likely to hit the coefficient ceiling |
| low_pass | Low pass | Roll the top off entirely above the corner |
| high_pass | High pass | Roll the bottom off entirely below the corner |
| disabled | Disabled | Switch the slot off without touching the others |
$ python3 moondrop_control.py --set-pregain -3.5 $ python3 moondrop_control.py --set-globalgain 0.0 $ python3 moondrop_control.py --set-eq-index 7 # index · type · frequency (Hz) · gain (dB) · Q $ python3 moondrop_control.py --set-peq 0 peaking 1000 -3.0 1.0 $ python3 moondrop_control.py --set-peq 7 disabled 20000 0 0.7
Live, or flashed
Writes go to flash by default. --no-flash applies them to the DSP only, so
you can audition a change and walk away from it — unplug the DAC and it is gone.
Apply to the DSP without persisting. Verified: a --no-flash write
did not survive a physical unplug/replug, while a flashed one came back
byte-identical.
Commit whatever is currently in the DSP to flash. Applied last, so it can be
combined with a --no-flash write in the same command.
The DSP reports whatever was written to it last, not what is in flash — so reading the device back after a live edit returns the edit, not the saved state. That is why the desktop app keeps its own snapshot for revert, and why a backup file is worth more than a re-read.
Backup, restore and import
A full device snapshot: every band, pre-gain and global gain. Take one before experimenting.
Write a snapshot back. Bands outside the device's band count are skipped with a note; an unsupported filter type is an error rather than a guess.
AutoEQ / REW exported filter text. Maps PK, LS,
HS, LP and HP, honours the Preamp
line as pre-gain, and disables any bands the file does not define.
$ python3 moondrop_control.py --export-json profile.json $ python3 moondrop_control.py --import-json profile.json $ python3 moondrop_control.py --import-rew filters.txt # REW / AutoEQ
Community presets
Moondrop Hub carries about 59,700 user-made curves from 19,900 authors. Reading needs no account, no key and no token, so these two flags browse it — and only ever read. Publishing, liking and favouriting need a login and are deliberately not implemented.
The library for the connected device, as JSON. Add --pid 011d to
name a device instead.
Filter locally over the whole index — title, author and description.
Bypass the day-long cache and refetch the index.
Pull one config down and print it as bands you can apply.
$ python3 moondrop_control.py --presets --search "harman" | jq '.presets[0]' { "uuid": "4ba6fbe4-6a97-48f1-b487-9d2a640ee30c", "title": "水月雨aria2 模拟入耳式耳机使用哈曼2019在HEAD acoustics第三代人工头曲线", "author": "rockyuan", "downloads": 31072, "likes": 604, "file": "peq-config-file/fQ0QdflTTrx27gduE14KQpeq.txt" }
- You get your whole device family's presets, not just your model. The server
pools by the app's
sharedConfigGroupId, so a DAWN PRO2 sees ~6,900 curves (its own 1,270 plus every other FreeDSP-family device). - The index is cached for a day under
~/.cache/hub_moon/. It has to be: the API has no pagination at all —productUuidis the only parameter it honours, andpage,limitandsortByreturn zero rows rather than being ignored. The smallest possible request is the entire ~3.6 MB index for your device, so--searchruns locally over all of it. - Neither flag opens the DAC — strace-verified, zero
/dev/hidrawopens — so browsing cannot collide with a GUI that is mid-write. - Published presets carry no pre-gain, unlike AutoEQ. A loud community curve will clip unless you set your own headroom.
- Bands with no
filterTypebecome peaking, which is what the official app does, and it is the common case: most published bands omit the field. - The product-UUID table is hardcoded, because the API's own
products/allreportspid: nullfor all 102 products —--registryreports the UUIDs it uses.
Diagnostics and modes
Hardware-level ALSA stream diagnostics — the sample rate and bit format the
card is actually running, and the rates it supports. Reads /proc/asound,
so Linux only; everything else here is cross-platform through hidapi.
An interactive tuning panel in the terminal: the same controls, live. Exclusive mode. Written, but never exercised against real hardware.
Opens the desktop app. You do not normally need it: install a package and
open Hub Moon from your launcher, or run hub-moon with no
arguments at all — both do this. The flag exists for the case where you are already
in a shell. Needs the [gui] extra; the toolkit is lazy-imported so the
plain CLI keeps its footprint.
Building a front-end on this
The CLI is designed to be driven. --json reports the full device state, and
everything device-shaped comes from there rather than being reimplemented in the caller:
band count, which slot custom PEQ lives on, whether the device supports pre-gain at all.
This file stays the single source of truth for the device registry.
Only one process can usefully hold the hidraw at a time — two readers pick up each
other's replies. So anything passive (a tray icon, a status pill) must not be the second
one. --registry prints the device table and touches no hardware, so a
front-end can recognise a DAC from USB IDs the system already knows and never open the
device. --presets and --preset are hardware-free for the same
reason.
Identifying the DAC from the system side
PipeWire is not the source of truth for playback, so there are two paths:
- Normally the sink node carries
alsa.components = USB35d8:011d— the USB pair, no name-matching needed — and it updates reactively on hotplug. - A bit-perfect player (SONE, TIDAL) opens the card directly through exclusive
ALSA. PipeWire never sees that stream, so
defaultAudioSinkwill happily report "Speaker" while the music is physically going through the DAC — and PipeWire may hold no node at all for a card it cannot open. The fallback asks the kernel instead: whoever holds/proc/asound/card*/pcm*p/sub*outside pipewire, identified by that card's/proc/asound/cardN/usbid— the same35d8:011dpair, available whether or not PipeWire has any idea the device exists.
What the desktop app does
It is the worked example: it imports moondrop_control and calls the
hardware-tested engine rather than reimplementing the protocol, runs all HID I/O on one
worker thread so a read never interleaves with a write, and gives the community library
and the file dialogs threads of their own.
Notes
- The DSP runs at a fixed 96 kHz internal sample rate; biquad coefficients are computed against that regardless of the playback rate.
- Coefficients use the standard Bristow-Johnson formulas, packed as Q2.30
signed 32-bit integers in the layout
[b0, b1, b2, -a1, -a2]. This matches the official web app's packing function exactly. --stream-statusreads/proc/asoundand is Linux-only. Everything else is cross-platform through hidapi.--import-rewreads REW's exported filter text: it mapsPK/LS/HS/LP/HP, honours thePreampline, and disables any bands the file does not define. Filters past the device's band count are skipped with a note.- Read/write flags compose and are applied in a fixed order, with
--save-flashlast.--jsonand-iare exclusive modes and ignore the rest.
Filters this hardware cannot represent
Q2.30 spans only [−2, 2), and some otherwise reasonable filters need coefficients outside it. This tool refuses those with an error naming the safe ceiling. Two cases.
| Coefficient | When | Example |
|---|---|---|
| b1 < −2 | A high_shelf above roughly +5 dB (at 8 kHz / Q=0.7 the ceiling is
about +4.7 dB), or a high_shelf with a corner below roughly 200 Hz at
any gain |
high_shelf 150 6 0.7 |
| b0 > 2 | Any type at high gain, low Q and high frequency | peaking 20000 12 0.3 |
Its JavaScript packs coefficients with bitwise operators, which wrap modulo 2³²
instead of failing — so past these limits it silently programs a filter unrelated to the
curve it draws. A +6 dB shelf's b1 wraps from −2.303 to +1.697, flipping
sign. This tool rejects rather than reproduce that. What the firmware would actually do
with a wrapped coefficient is untested.
Protocol notes
Findings from reverse-engineering the official web app, verified against its JavaScript and — where marked — against real hardware. The full write-up, including hosts, endpoints and the product-UUID table, is in the repository.
- Coefficient packing is Q2.30, layout
[b0, b1, b2, -a1, -a2], scaled by 2³⁰ and computed against a fixed 96 kHz DSP rate. Confirmed byte-for-byte against the web app's own packing function, and corroborated by an independent reimplementation. - The device registry is transcribed from the app, correcting a scrambled
name/ID mapping:
0x011Dis DAWN PRO2 (confirmed against real hardware),0x43DAis MOONRIVER 3,0x011Bis Rays. "Rays Pro" does not exist. E.S. combo uses custom-PEQ profile slot 4; every other supported device uses 7. - Old Fashioned (
0x0122) is detected but refused. It does not use biquad coefficients at all — it writes PEQ through device registers as int8 gain ×10, uint16 frequency and int16 Q ×1000, exposes 5 bands, and reports no pre-gain or global-gain support. - HID replies must be matched to their request. A response echoes the command and sub-command it answers at bytes 1–2. Commands that never reply would otherwise leave the next read picking up the previous command's report, shifting every subsequent read by one and silently returning another register's data.
- The active EQ profile is not a PEQ-mode indicator — see below.
"I changed the EQ and nothing happened"
On a DAWN PRO2 the equaliser is toggled on the hardware: press both volume buttons to switch between the default (no EQ) mode and custom EQ. If your edits are inaudible, check that first — PEQ writes only affect the sound in custom-EQ mode.
That toggle is not reflected in any register we could find: sweeping every readable
sub-command (0–254) returns byte-identical data in both modes, so this tool cannot tell
you which mode you are in. --info reports the active EQ profile, but on
firmware 1.5 that reads 9 in both modes while PEQ writes are
audible in custom EQ regardless. Do not read anything into that number.
The official app assumes otherwise — it gates PEQ on
readEQIndex() === peqIndex (7 for this device), which does not describe
firmware 1.5. That check would report "not in PEQ mode" even while custom EQ is plainly
working.
Disclaimer
Unofficial and not affiliated with Moondrop. The USB HID protocol here was reverse engineered from the official web app with the assistance of AI. The coefficient packing and PEQ byte layout have since been checked against that app's own JavaScript and match it exactly, but the command set is still inferred from observed behaviour rather than any documented spec — treat it as a best-effort reconstruction that works on the hardware it was tested against, not as authoritative.
This script is tested with the Moondrop DAWN PRO2 only, which works as intended though further ironing is necessary. Other devices require testing by people who own them — see what has actually been exercised.
Export a backup with --export-json before experimenting.