Read the Waves Nx Head Tracker (NXIMU010) over Bluetooth LE on Linux
(BlueZ), decode head orientation, and re-emit it as OSC compatible with the
IEM SceneRotator (IEM Plugin Suite). Built as a standalone crate, kept
modular (ble / decode / osc) for later integration into the
Omniphony spatial renderer. Licensed
GPLv3 (GPL-3.0-only), matching Omniphony.
Status: Phase 3 done —
runstreams decoded orientation over OSC (IEM SceneRotator or a Sensors2OSC-style feed for Omniphony), with software recenter, smoothing, rate-limit and auto-reconnect.
| Phase | Command | State |
|---|---|---|
| 0 | skeleton + CLI (scan/raw/run) |
done |
| 1 | raw — connect, start, dump frames + live candidate interpretations + CSV |
done |
| 2 | decode — decode(&[u8]) -> Orientation (quaternion + yaw/pitch/roll), unit-tested on captured frames |
done |
| 3 | run — stream orientation as OSC (SceneRotator + Sensors2OSC/Omniphony), recenter/smoothing/rate-limit |
done |
10-byte notification:
| bytes | meaning |
|---|---|
| 0..2 | x — int16 LE, Q1.14 (value / 16384) |
| 2..4 | y — int16 LE, Q1.14 |
| 4..6 | z — int16 LE, Q1.14 |
| 6..8 | w — int16 LE, Q1.14 |
| 8..10 | constant 00 03 (footer/flags) — ignored |
(w, x, y, z) is a unit quaternion (measured norm = 1.0000, sd 2e-5 across
all captures). Device body axes match the ADM convention used by Omniphony:
X = pitch, Y = roll, Z = yaw. yaw/pitch/roll are extracted for the intrinsic
Z-X-Y sequence (inverse of HeadPose::from_euler_deg). Near pitch = ±90° the
Euler triple hits gimbal lock; the quaternion stays valid.
nxosc talks to the system BlueZ daemon over D-Bus; it does not pair the
device itself. Pair and trust the tracker once with bluetoothctl:
rfkill unblock bluetooth # if the adapter is soft-blocked
bluetoothctl
[bluetooth]# power on
[bluetooth]# scan on
# ... wait until a device named like "nx tracker" appears, note its MAC ...
[bluetooth]# pair AA:BB:CC:DD:EE:FF
[bluetooth]# trust AA:BB:CC:DD:EE:FF
[bluetooth]# scan off
[bluetooth]# quitNotes:
- The tracker advertises a name containing "nx tracker" (case-insensitive).
trustlets it reconnect automatically after sleep — required for the auto-reconnect behaviour ofraw/run.- Permissions: using the BlueZ D-Bus API normally needs no extra
privileges or group membership. If
scan/rawreports no adapter, checksystemctl status bluetoothandbluetoothctl show(adapterPowered: yes). - If the device is busy/connected elsewhere (e.g. the macOS NXOSC app or a phone), disconnect it there first.
cargo build --release # binary at target/release/nxoscLinux build dependency: the BlueZ backend talks D-Bus; ensure a D-Bus
development setup is present if the build complains (pkg-config, dbus).
# 1. Find the tracker (Nx devices are listed first and marked).
nxosc scan
nxosc scan --secs 8
# 2. Capture (Phase 1). Move your head on ONE axis at a time and watch which
# rows/columns develop a wide RANGE — those slots encode that axis.
nxosc raw # discover by name, live range table
nxosc raw --address AA:BB:CC:DD:EE:FF # force a MAC
nxosc raw --csv capture.csv # also log every raw frame for offline analysis
nxosc raw --stream # one block per packet instead of the live table
nxosc raw --decode # also show decoded quaternion + yaw/pitch/roll (Phase 2)
nxosc raw --csv yaw.csv # suggested: one CSV per axis (yaw/pitch/roll/still)
# verbosity
nxosc -v raw # debug logs (RUST_LOG overrides, e.g. RUST_LOG=trace)For each notification it tracks four competing readings of the payload —
i16 LE, i16 BE, f32 LE, f32 BE — and, per slot, the live value plus the
min / max / range observed since start, with a bar scaled within each
group. A slot whose bar grows when you yaw (and stays flat otherwise) is a yaw
component; flat constant bytes are header; a slot that only ever increases is a
packet counter. --csv always records every frame as ts_us,len,hex for
offline analysis.
# IEM SceneRotator (default profile). yaw/pitch/roll in degrees:
nxosc run --address AA:BB:CC:DD:EE:FF
nxosc run --mode quaternion # send /SceneRotator/quaternions w x y z instead
nxosc run --osc-target 127.0.0.1:9000
# Omniphony / Sensors2OSC emulation (test directly in Omniphony):
nxosc run --profile omniphony --osc-address /gamerotationvector --osc-target 127.0.0.1:9000OSC wire formats (verified against IEM docs / a reference head-tracker script and Omniphony's own parser):
| profile | mode | message |
|---|---|---|
scenerotator |
ypr (default) |
/SceneRotator/ypr <yaw> <pitch> <roll> (degrees) |
scenerotator |
quaternion |
/SceneRotator/quaternions <w> <x> <y> <z> |
omniphony |
quaternion (default) |
<--osc-address> <x> <y> <z> <w> (Android order; Omniphony reads from_quat(w,x,y,z)) |
omniphony |
ypr |
<--osc-address> <yaw> <pitch> <roll> (requires Omniphony format = euler) |
To test in Omniphony, set render.binaural.head_tracking.osc_address to the
same value as --osc-address (and format to auto/quat for quaternion).
Extras:
- Recenter: press Enter while
runis active to set "forward = current heading" (yaw-only; pitch/roll stay gravity-referenced).--recenter-on-startzeroes at launch. --smoothing <0..1>: exponential quaternion smoothing (0 = instant).--max-hz <n>: cap the OSC send rate (default 60).- Sleep-aware reconnect: the tracker auto-sleeps when idle.
rundetects the no-data state (noa015within a few seconds) and tells you to "move it or short-press its button to wake it". When the link merely goes silent but stays connected,runholds the connection and waits — moving the tracker or a short button press resumes the stream on the same link (reconnecting instead would only churn the BLE link, which is what wedges this device). It reconnects only if the link actually drops, and keeps retrying if the tracker is out of range — no need to restartnxosc.
# Map the device: connect WITHOUT sending start, print every service +
# characteristic and read the readable ones (battery, firmware, manufacturer…).
nxosc gatt
# Experiment on the a011 command characteristic and measure the a015 rate.
nxosc probe --rate 100 # write [100 u32 LE, 0x01] -> expect ~100 Hz
nxosc probe --stop # write [0x32,0,0,0,0x00] -> expect the stream to stop
nxosc probe --cmd "32 00 00 00 01" # write arbitrary bytes to a011
nxosc probe --sweep 50:100:5 # one connection; sweep rates on one link
# Try to recover a stalled/degraded stream over GATT (re-arm notifications).
nxosc kickprobe --sweep takes a comma list ("50,60,70") or a range ("lo:hi:step") and
writes each rate then measures on the same connection — avoiding the
reconnect churn that can wedge the BLE link / send the tracker to sleep.
nxosc kick measures the rate, then re-arms notifications (CCCD
unsubscribe→subscribe) and re-sends start, and measures again. A full stall
(0 Hz) is firmware-internal: neither the re-arm, a plain reconnect, nor the
vendor characteristics revive it (all tested) — only a short press of the
device button does (a long press resets the tracker, with a red flash).
A degraded stream (e.g. a half-rate ~25 Hz drop) was once seen to climb
back to ~50 Hz across a re-arm, but that is a single observation and not
validated — the stream may simply recover on its own. Use kick --no-rearm
on a degraded stream as a control: if the rate rises in the second window
without any re-arm, the recovery is spontaneous and the re-arm is not the
cause.
The start command is [rate (u32 LE), enable (u8)] (0x32 = 50 = the
default 50 Hz). On a fresh connection the byte maps essentially 1:1 to the
output rate across ~20–100 Hz:
| requested | measured | requested | measured |
|---|---|---|---|
| 100 | 100 Hz | 30 | 30 Hz |
| 90 | 91 Hz | 25 | 25 Hz |
| 75 | 77 Hz | 20 | 20 Hz |
| 50 | 50 Hz | ≤ 15 | collapses (15→11, 12→6, 10→4, …) |
So the useful range is 20–100 Hz (rate ≈ Hz); --rate 100 genuinely gives
~100 Hz, halving the head-tracking sampling latency vs the 50 Hz default. Below
~20 Hz the rate falls off in plateaus and is unusable.
⚠️ The rate is reliable only on the first clean connection after a device reset/power-cycle. The tracker degrades after a couple of rapid reconnects (writes still ACK but the stream stalls), which also skews any--sweep. The 5th byte is not a clean enable (writing0does not stop the stream — to stop, just disconnect).gattis read-only andprobeonly ever writes toa011, so a DFU/firmware service (if present) is never touched.
Both raw and run accept --rate <hz> (default 50), e.g.
nxosc run --rate 100 … for lower-latency head tracking.
GATT map (from nxosc gatt): Device Information reports firmware v100,
hardware v4.4, software A v1.30 B v1.13, "Waves Audio"; a Battery service;
and vendor characteristics including a018 (the writable device name) and two
further vendor services (a030, a050) of unknown purpose — a030 looks like
a DFU control point, so it is left untouched.
To pin down the layout, capture a few short, labelled runs and send the CSVs:
still.csv— tracker flat and motionless (baseline / constant bytes).yaw.csv— slow full left/right rotation only.pitch.csv— slow nod up/down only.roll.csv— slow tilt left/right only.
GPL-3.0-only. See LICENSE.