macOS support: consolidated libusb Surface backend - #2
Merged
Conversation
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>
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Surfacebackend 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).COMMAND8_USB_BACKEND=ONby default when libusb is present) instead of a macOS-only Surface, per the follow-up "consolidate on one libusb Surface" commit.MidiPort(RtMidi-backed) for the Mackie Control bridge on macOS — no loopback utility needed there, unlike Linux'ssnd-virmidirequirement.test_feedback,test_usb_packets.sudoto 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):-Wall -Wextra.ctest --test-dir build: 3/3 pass (protocol,feedback,usb_packets).command8-monitorrun 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 withoutsudo.🤖 Generated with Claude Code