Skip to content

macOS support: consolidated libusb Surface backend - #2

Merged
alphonsom merged 6 commits into
mainfrom
libusb-backend
Aug 8, 2026
Merged

macOS support: consolidated libusb Surface backend#2
alphonsom merged 6 commits into
mainfrom
libusb-backend

Conversation

@alphonsom

Copy link
Copy Markdown
Owner

Summary

  • Adds macOS support via a libusb-based Surface backend that talks to the Command|8's bulk endpoints directly, bypassing CoreMIDI's class driver (which can't parse the device's malformed MIDIStreaming input descriptor and has no quirk mechanism, unlike Linux's ALSA path).
  • Consolidates onto one libusb Surface implementation shared across platforms (Linux/Windows can opt into it too, COMMAND8_USB_BACKEND=ON by default when libusb is present) instead of a macOS-only Surface, per the follow-up "consolidate on one libusb Surface" commit.
  • Adds a virtual-CoreMIDI MidiPort (RtMidi-backed) for the Mackie Control bridge on macOS — no loopback utility needed there, unlike Linux's snd-virmidi requirement.
  • Fader meter scaling + falloff ballistics fix.
  • New unit tests: test_feedback, test_usb_packets.
  • README corrected after hardware verification: macOS does not require sudo to claim the USB interface as originally documented — verified end-to-end (fader input, encoder input, LED feedback) on real hardware, unprivileged.

Testing

Built and verified from scratch on macOS (Intel, brew install cmake ninja libusb rtmidi liblo, cmake -B build -G Ninja && cmake --build build):

  • Clean build, zero warnings under -Wall -Wextra.
  • ctest --test-dir build: 3/3 pass (protocol, feedback, usb_packets).
  • command8-monitor run against real hardware as an unprivileged user: confirmed fader sweeps (strips 2 and 5, full 0.00→1.00 range), encoder→pan tracking, and select/mute/solo LED feedback all work without sudo.

🤖 Generated with Claude Code

gibsonsand others added 6 commits August 7, 2026 15:18
The Command|8's malformed MIDIStreaming input descriptor needs a different
workaround on each platform. Linux patches snd-usb-audio; Windows relies on
Digidesign's driver. CoreMIDI has no quirk mechanism, so the device's ports
enumerate but stay inert and no MIDI-API backend can reach the input.
MacosSurface therefore bypasses CoreMIDI on the device side entirely: it
claims interface 1 over libusb and speaks USB-MIDI event packets directly,
matching on VID/PID (0dba:8000) rather than a port name. Raw MIDI from the
protocol encoders is packed into 4-byte packets on the way out and unpacked
on the way in; the wake/keepalive contract is unchanged.
MacosMidiPort goes the other way. macOS is the only supported platform that
lets an application create MIDI endpoints, so the MCU side needs no loopback
utility at all: it publishes a virtual source and destination via RtMidi and
the DAW connects straight to them. Both endpoints are required - with only a
source, a DAW sees an input with no matching output and control-surface
support reports it cannot find a MIDI output.
Claiming the interface takes it from CoreMIDI's class driver, so the binaries
need root. Unprivileged processes cannot even enumerate the device (macOS
hides USB devices a process may not touch), which makes "absent" and "not
permitted" indistinguishable - open() reports both possibilities rather than
guessing.
Also add an __APPLE__ branch for the default MCU port names: the generic
non-Windows default is "VirMIDI", which is a Linux virmidi name and wrong for
ports we create ourselves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems, both visible on hardware and both affecting every platform.
Meters never fell. Feedback::meter() sent the host's value and nothing more,
so the display froze on whatever arrived last and stayed lit indefinitely once
playback stopped. That is not a host bug: hosts transmit meter values sparsely
(Reaper only when the quantised 0-12 level changes, roughly 1 Hz in practice)
and expect the surface to supply the ballistics in between, the way real MCU
hardware does in firmware. The host value is now treated as a peak - instant
rise, then decay driven from Feedback::tick(), which Controller runs on every
Surface tick. Default 1200 ms full-scale falloff, measured against captured
Reaper meter traffic; set_meter_decay_ms(0) restores exact host-following.
Levels were also mis-scaled. static_cast<int>(v * METER_ROWS) truncates, so
11/12 of full scale lit 5 of 6 LEDs and anything below 1/6 read as silence.
Now rounds, with any non-zero signal guaranteed at least one lit LED so quiet
material is distinguishable from nothing.
Unchanged rows are no longer re-sent, so a steady level costs one write rather
than one per tick.
tests/test_feedback.cpp covers both fixes against a recording fake Surface,
including the original symptom: full scale, then ticks with no host updates,
asserting the meter reaches zero.
Note that with falloff active a peak landing exactly on a half-row boundary
renders one row low, because some decay elapses before the tick reads it. It
is a sub-LED artifact at exact boundaries only, which is why the rounding
tests disable decay to isolate the two behaviours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dumping the device's real configuration descriptor shows the comment had
the defect backwards. The class-specific bulk-IN endpoint descriptor does
declare bNumEmbMIDIJack 3, but it carries only two jack IDs and bLength 6
is exactly right for two -- the count is the wrong field, not the length.
Padding bLength to 7 would be worse than the bug: it would hand the host a
third jack ID read from past the end of the configuration.
The interface declares only two Embedded MIDI OUT jacks (IDs 2 and 4), and
the MS header's wTotalLength (98) disagrees with the descriptors actually
present (82). The declared topology does not describe the hardware either:
both Embedded MIDI OUT jacks are sourced from external DIN input jacks,
yet the surface's own data is observed arriving on cable 0.
That last point is what justifies the fixed-endpoint quirk rather than a
narrower fix, so record it. No functional change; the hunk header is
adjusted for the longer comment and the patch still applies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives the Command|8's bulk endpoints directly instead of going through
the OS USB-MIDI class driver. The device's MIDIStreaming descriptors are
malformed (see quirk/), so no class parser will touch it: on a stock Linux
kernel snd-usb-audio binds neither interface, and the macOS and Windows
class drivers -- including Windows MIDI Services -- reject it outright.
Opening the endpoints from constants makes the malformation irrelevant
rather than merely tolerated, and gives one code path on all three
platforms with no kernel quirk and no Digidesign driver.
Because nothing claims the interface anywhere, there is also nothing to
detach, which is what makes this viable on macOS.
UsbSurface implements the existing Surface interface, so monitor, reaper
and mackie pick it up unchanged. It becomes the default wherever libusb is
found; COMMAND8_BACKEND=alsa still selects the quirk-based path at runtime
for A/B testing, and -DCOMMAND8_USB_BACKEND=OFF restores the old build.
The platform backends keep their classes and yield only the factory.
Verified against the hardware: interface claims cleanly and unprivileged
with the udev rule, bulk transfer works both ways on 0x01/0x81, the
surface is on cable 0, and command8-monitor decodes faders, encoders and
buttons through the full protocol stack.
tests/test_usb_packets.cpp pins the USB-MIDI event encoding, where a wrong
Code Index Number fails silently -- the device just ignores the message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches independently arrived at the same conclusion -- that the only
way to talk to this device is libusb on its bulk endpoints -- and both
implemented it. macos_surface.cpp and usb_surface.cpp used identical
constants (0dba:8000, interface 1, endpoints 0x01/0x81), matched on
VID/PID, ignored port_match, and packed the same USB-MIDI events.
They merged cleanly only because they sat in different directories: on
macOS CMake would have compiled both and hit duplicate definitions of
make_surface() and print_midi_ports(). Keep UsbSurface, which is
cross-platform by construction rather than APPLE-gated, and has been
verified against the hardware; drop src/macos/macos_surface.*.
Take two things from the macOS side. Its device_present() scans the bus
instead of reporting a cached flag, so it is meaningful before open() as
well as after -- which is what AlsaSurface does and what a caller polling
for the device to appear needs; UsbSurface now does the same, still using
transfer results once a handle exists because those notice removal sooner.
And macos_midi_port (RtMidi over CoreMIDI, with virtual ports) is the
MCU-facing half, complementary to all of this, so it stays: macOS now has
a Surface from the libusb backend and a MidiPort of its own.
Fix a bug this exposed: usb_surface.cpp guarded its ALSA include on
!_WIN32, which would have pulled <alsa/asoundlib.h> into the macOS build.
It is __linux__ now, as are the COMMAND8_BACKEND=alsa escape hatch and the
print_midi_ports fallback.
CMake: macOS has no platform Surface, so the libusb backend is mandatory
there and says so rather than failing at link time; nothing needs
COMMAND8_NO_FACTORY on that platform. Both branches' test registrations
are kept.
Also corrects comments on both sides claiming that CoreMIDI's class driver
holds the interface. Nothing claims it anywhere -- a stock Linux kernel
binds neither interface -- which is why there is nothing to detach and why
this approach works at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified command8-monitor on real hardware (macOS 15.6, Intel) as an
unprivileged user: fader input, encoder input, and select/mute/solo LED
feedback all worked without sudo. Replace the "sudo is required, and is
not incidental" claim (and the LaunchDaemon/root-daemon rationale built
on it) with what was actually observed, demoting sudo to a fallback
suggestion for stricter USB permission setups.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alphonsom
alphonsom merged commit 8e8bccb into mainAug 8, 2026
4 checks passed
@alphonsom
alphonsom deleted the libusb-backend branch August 8, 2026 17:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@alphonsom