nord-usb
Talk to Clavia / Nord keyboards over USB from Rust — the vendor protocol Nord Sound Manager speaks, reverse-engineered from packet captures.
This is the transport-and-protocol half of the Nord toolkit.
nord-format owns the bytes of a file; this crate owns getting
those bytes on and off the instrument. It depends on nord-format for the
container it wraps read data in, and on nothing else at its core — the backends
are optional features.
[!WARNING] Alpha software driving real hardware over a reverse-engineered protocol. The verbs listed under Status are hardware-verified; everything else is not. Back up your instrument (Nord Sound Manager makes a full backup) before pointing anything here at sounds you can't re-create.
Status
The wire protocol is decoded and validated. Implemented and hardware-verified on macOS: inventory, object info, dependencies, program read/write, the slot organization set (move, delete, rename, duplicate, select), and reads of the live slots (class 6) and the settings singleton (class 7). Writes of those two classes are unproven — whether either survives the delete-then-write sequence is unconfirmed on hardware, so nothing here has attempted one.
The WebUSB backend is hardware-verified for the read-only path (Chrome on macOS:
inventory and object info, via drawbar); its writes and multi-chunk bulk
reads have not been exercised.
Not implemented: bundle and backup transfer, firmware update, and the piano/sample library as first-class objects. Linux and Windows build and pass the replay tests but have not been run against hardware.
Usage
use ;
use UsbTransport;
let mut transport = open_first?;
// Read-only by default — the type system will not let a mutating op through.
// `from_user` takes the instrument's own one-indexed numbering: 7:4 on the panel.
let mut session = open.await?;
let at = from_user;
let info = info.await?;
let file = read_program.await?;
session.commit.await?;
Mutating operations need the capability to be asked for explicitly:
let mut session = open
.await?
.allow_destructive_writes;
delete.await?;
session.commit.await?;
Always commit(), including on the error path. The closing exchanges are what
clear the instrument's progress display; abandoning a transaction after a progress
label has been sent leaves the device stuck until it is power-cycled. Session
carries a Drop assertion to catch the mistake in debug builds.
Features
| Feature | Default | What it gives you |
|---|---|---|
nusb |
✅ | Desktop backend — macOS (IOKit), Linux (usbfs), Windows (WinUSB). Pure Rust. |
web |
Browser backend over WebUSB. Chrome/Edge only — Firefox and Safari declined the spec. | |
replay |
Drive the protocol from committed captures, no hardware. Used by the golden tests. | |
blocking |
Block on the async API from synchronous callers (the CLI). Tiny; not a runtime. | |
corpus |
Corpus-backed tests (NORD_CORPUS_ROOT), implies replay. |
WebUSB is the binding constraint on the API shape. Its handles are not Send, so
neither is this crate's Transport trait — which in turn keeps it
runtime-agnostic. Device enumeration is backend-specific rather than part of
the portable core, because the browser requires a user gesture to pick a device
and no portable signature can express that. block_on (the blocking feature)
exists for CLIs and tests that just want the answer, without pulling in a full
async runtime.
Building the web feature needs --cfg=web_sys_unstable_apis (WebUSB is still
gated in web-sys); crates/.cargo/config.toml supplies it for the wasm target,
so wasm builds must be run from crates/ or below.
drawbar is a browser app that drives this backend on hardware.
The protocol
Every message on the vendor bulk endpoints is a length-prefixed, CRC-trailered
frame of big-endian u32s (the file formats are little-endian — mixing
them up costs real debugging time):
┌────────┬─────────┬───────────┬─────────┬───────────────┬───────┐
│ length │ service │ subsystem │ command │ args… │ crc16 │
│ u32 │ u32 │ u32 │ u32 │ │ u16 │
└────────┴─────────┴───────────┴─────────┴───────────────┴───────┘
The CRC is CRC-16/CCITT-FALSE. A response is the request's command + 1 with
a u32 status inserted ahead of the echoed arguments, which is why responses run
exactly four bytes longer. Every message in the capture corpus decodes and
re-encodes with its CRC and length field intact.
Two hazards worth knowing up front:
- Requests are not reliably even.
SELECTis0x2fwith response0x30. Direction is the only dependable discriminator, so this crate records it at decode time rather than inferring it. Getting that wrong misaligns every argument by four bytes and hides device error codes. - Operations are primitives parameterised by an object class, not per-type
opcodes.
SESSION_OPENcarries the class (1 piano, 3 sample, 4 program, 5 set list, 6 live, 7 settings) and the samerename/move/delete/copycommands then apply to whichever it is.
Layering
The protocol is testable without hardware, which is the whole point of the split:
| Module | Role |
|---|---|
wire |
Message framing and codec. Pure, no I/O. |
transport |
The byte pipe. The only part that touches a device. |
session |
The transaction wrapper every operation runs inside. |
op |
Typed operations. |
Testing
The golden tests replay real captures through the whole stack and assert the bytes this crate emits are the bytes NSM sent — not merely self-consistent with its own encoder. No hardware, no platform dependency.
⚠️ replay is not a default feature, so a bare cargo test -p nord-usb compiles
the golden tests out and reports a pass having verified none of the wire encoding.
The Nix build enables it via [package.metadata.nix] testFeatures in Cargo.toml.
The replay sweep
tests/replay is one trial per *.script under tests/scripts, and — with
--features corpus and NORD_CORPUS_ROOT — under the private corpus too. A
capture joins the suite by existing; no test is written for it.
Every script is checked for framing. A script whose header declares an intent
is also driven: replayed through an exact-match transport, one section per
transaction, each judged against what it said to expect, and the whole script
required to be consumed.
# source: nord
# device: Nord Electro 5, firmware v2.04 build 592
# intent: program info 7:11
O 0000001200000006000000010000000006a1
…
# intent: program move 7:11 7:12
…
nord … --record <path> writes that header itself, so a capture made with the CLI
is a finished golden. The full vocabulary — header keys, the expect values, and
the intent → operation table — is in
tests/scripts/README.md.
Disclaimer
Not affiliated with, authorized, or endorsed by Clavia DMI AB. "Nord", "Clavia", and "Electro" are trademarks of Clavia DMI AB, used here only to identify the hardware this protocol belongs to. All reverse engineering is of traffic to and from hardware the author owns, for interoperability.