From f7df41e135209ccafa9db164f341db8d2c14d6a7 Mon Sep 17 00:00:00 2001 From: gibsons Date: Fri, 7 Aug 2026 15:18:53 +0100 Subject: [PATCH 1/5] Add macOS support: libusb Surface + virtual-CoreMIDI MidiPort 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 --- CMakeLists.txt | 29 ++++ README.md | 72 ++++++++-- src/mackie/mackie_backend.hpp | 9 +- src/macos/macos_midi_port.cpp | 94 +++++++++++++ src/macos/macos_midi_port.hpp | 54 ++++++++ src/macos/macos_surface.cpp | 253 ++++++++++++++++++++++++++++++++++ src/macos/macos_surface.hpp | 68 +++++++++ src/surface.hpp | 8 +- 8 files changed, 576 insertions(+), 11 deletions(-) create mode 100644 src/macos/macos_midi_port.cpp create mode 100644 src/macos/macos_midi_port.hpp create mode 100644 src/macos/macos_surface.cpp create mode 100644 src/macos/macos_surface.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 36ee349..7787622 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,30 @@ if(WIN32) target_link_libraries(command8 PUBLIC RtMidi::rtmidi) target_compile_definitions(command8 PUBLIC NOMINMAX WIN32_LEAN_AND_MEAN _CRT_SECURE_NO_WARNINGS) +elseif(APPLE) + # The surface is raw USB (libusb): CoreMIDI cannot expose the device's MIDI + # input, and unlike Linux there is no quirk mechanism to fix that. The MCU + # side is ordinary CoreMIDI, and macOS can create virtual ports, so no + # loopback utility is needed there. + find_package(PkgConfig REQUIRED) + pkg_check_modules(LIBUSB REQUIRED libusb-1.0) + find_package(Threads REQUIRED) + target_sources(command8 PRIVATE + src/macos/macos_surface.cpp + src/macos/macos_midi_port.cpp) + target_include_directories(command8 PUBLIC ${LIBUSB_INCLUDE_DIRS}) + target_link_directories(command8 PUBLIC ${LIBUSB_LIBRARY_DIRS}) + target_link_libraries(command8 PUBLIC ${LIBUSB_LIBRARIES} Threads::Threads) + # RtMidi ships a CMake config in some distributions and only a .pc in others. + find_package(RtMidi CONFIG QUIET) + if(RtMidi_FOUND) + target_link_libraries(command8 PUBLIC RtMidi::rtmidi) + else() + pkg_check_modules(RTMIDI REQUIRED rtmidi) + target_include_directories(command8 PUBLIC ${RTMIDI_INCLUDE_DIRS}) + target_link_directories(command8 PUBLIC ${RTMIDI_LIBRARY_DIRS}) + target_link_libraries(command8 PUBLIC ${RTMIDI_LIBRARIES}) + endif() else() find_package(PkgConfig REQUIRED) pkg_check_modules(ALSA REQUIRED alsa) @@ -98,6 +122,11 @@ target_link_libraries(test_protocol PRIVATE command8) target_compile_options(test_protocol PRIVATE ${C8_WARNINGS}) add_test(NAME protocol COMMAND test_protocol) +add_executable(test_feedback tests/test_feedback.cpp) +target_link_libraries(test_feedback PRIVATE command8) +target_compile_options(test_feedback PRIVATE ${C8_WARNINGS}) +add_test(NAME feedback COMMAND test_feedback) + # --- install --- include(GNUInstallDirs) diff --git a/README.md b/README.md index 8afab01..07e70bf 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,30 @@ # command8-cpp A native C++ userspace engine for the **Digidesign Command|8** control surface -on Linux and Windows: a DAW-agnostic core library + bridges for Reaper (OSC) +on Linux, Windows and macOS: a DAW-agnostic core library + bridges for Reaper (OSC) and any Mackie-Control-capable DAW (Bitwig, …). The protocol documentation ([docs/PROTOCOL.md](docs/PROTOCOL.md)), the Linux kernel quirk ([quirk/](quirk/)) and the Reaper OSC pattern ([reaper/](reaper/)) are all included here. -For Linux there is a `snd-usb-audio` quirk to expose the hidden MIDI -*input* port — the device's MIDIStreaming input descriptor is malformed, so the -standard parser does not create one. The patch is in -[quirk/](quirk/); apply it to your kernel tree or wrap it in a DKMS package. -(On Windows, Digidesign/Avid's own driver exposes the input.) Everything else (protocol translation, the wake/keepalive handshake, +The device's MIDIStreaming *input* descriptor is malformed, so a standard class +parser does not create an input port — and each platform needs a different way +around that: + +| | getting the input | MCU bridge needs | +|---|---|---| +| **Linux** | `snd-usb-audio` quirk ([quirk/](quirk/)) | `snd-virmidi` | +| **Windows** | Digidesign/Avid's own driver | a loopback pair | +| **macOS** | claim the USB interface directly (libusb) | nothing | + +macOS is the odd one out in both columns. CoreMIDI has no quirk mechanism, so +the device's own ports enumerate but stay inert; the backend bypasses CoreMIDI +on the device side and speaks USB-MIDI packets over libusb instead. In exchange, +macOS *can* create MIDI endpoints from an application, so the MCU bridge +publishes its own virtual pair and needs no loopback utility. + +Everything else (protocol translation, the wake/keepalive handshake, LED/fader/meter/ring/LCD feedback) is ordinary userspace logic: So this engine is a normal compiled program that talks to the device -over ALSA (Linux) or RtMidi/WinMM (Windows), giving full access to the surface controls and feedback, but with some buttons (EQ, Dynamics) not reproducing the exact function they have in Pro Tools. +over ALSA (Linux), RtMidi/WinMM (Windows) or libusb (macOS), giving full access to the surface controls and feedback, but with some buttons (EQ, Dynamics) not reproducing the exact function they have in Pro Tools. ## Layout @@ -22,6 +34,7 @@ src/surface.hpp Surface interface: device discovery, wake + keepalive, src/midi_port.hpp MidiPort interface: raw-bytes duplex port (MCU side) src/alsa/ ALSA-seq implementations of both (Linux) src/rtmidi/ RtMidi implementations of both (Windows) +src/macos/ libusb Surface + virtual-CoreMIDI MidiPort (macOS) src/feedback.{hpp,cpp} normalized (0..1) feedback: faders/meters/rings/LEDs/LCD src/backend.hpp Backend interface — host integrations subclass this src/controller.{hpp,cpp} wires Surface -> Backend, normalizes events @@ -55,7 +68,7 @@ ctest --test-dir build # protocol decode/encode unit tests ./build/command8-mackie # MCU bridge (needs snd-virmidi) ``` -## Reaper setup (both platforms) +## Reaper setup (all platforms) In Reaper: Preferences → Control/OSC/web → Add → **OSC**. Set the pattern config to [reaper/Command8.ReaperOSC](reaper/Command8.ReaperOSC) (installed @@ -90,6 +103,49 @@ systemctl --user enable --now command8-reaper to `~/.config/systemd/user/command8-reaper.service` and set `ExecStart` to your `build/command8-reaper`.) +## Build (macOS) + +Requires a C++17 compiler (Xcode command line tools), CMake ≥ 3.16, and +`libusb` + `rtmidi` (plus `liblo` for the Reaper bridge): + +```sh +brew install cmake ninja libusb rtmidi liblo +cmake -B build -G Ninja +cmake --build build +ctest --test-dir build +sudo ./build/command8-monitor # loopback demo +sudo ./build/command8-reaper # Reaper OSC bridge (identical OSC setup) +sudo ./build/command8-mackie # MCU bridge (no loopback needed) +``` + +**`sudo` is required, and is not incidental.** The backend has to claim the +USB interface, which takes it from CoreMIDI's class driver — a privileged +operation. CoreMIDI reclaims the interface as soon as anything releases it, so +this applies on every run. Worse, an unprivileged process cannot even *see* the +device: macOS hides USB devices a process may not touch, so "not plugged in" +and "not permitted" are indistinguishable from userspace (the error message +says so rather than guessing). + +To avoid typing it every time, run the bridge from a `LaunchDaemon`, which +starts as root at boot. Note that this does mean a permanently root-owned +process; the alternatives — unloading the system USB-MIDI driver, a codeless +kext (deprecated, and blocked on Apple Silicon), or a DriverKit driver +(needs an Apple entitlement) — are all worse for a self-hosted tool. + +If another Command|8 bridge is already running, stop it first: the interface is +exclusive. + +### Mackie bridge on macOS + +Nothing to install. `command8-mackie` publishes a virtual MIDI source and +destination, both named **`Command|8`**; point your DAW's Mackie Control +surface at that name for *both* its input and its output. Rename with +`--mcu-recv`/`--mcu-send` if you want something else. + +Publishing both endpoints matters: with only a source, a DAW sees an input with +no matching output and control-surface support reports that it cannot find a +MIDI output. + ## Build (Windows) Requires Visual Studio 2022+ (MSVC), CMake, and vcpkg (all bundled with a diff --git a/src/mackie/mackie_backend.hpp b/src/mackie/mackie_backend.hpp index e433c5c..dc5e970 100644 --- a/src/mackie/mackie_backend.hpp +++ b/src/mackie/mackie_backend.hpp @@ -25,10 +25,15 @@ namespace command8 { // Windows MIDI Services loopback pair (create once with // midi loopback create --name-a "Command8 MCU A" --name-b "Command8 MCU B" // ); the bridge opens A both ways and the DAW's Mackie Control uses B, so -// neither hears its own output. -#ifdef _WIN32 +// neither hears its own output. macOS: these are the names of the virtual +// ports the bridge CREATES rather than ones to search for, so no loopback is +// needed and the DAW points at this name for both directions. +#if defined(_WIN32) inline constexpr const char* kDefaultMcuRecvMatch = "Command8 MCU A"; inline constexpr const char* kDefaultMcuSendMatch = "Command8 MCU A"; +#elif defined(__APPLE__) +inline constexpr const char* kDefaultMcuRecvMatch = "Command|8"; +inline constexpr const char* kDefaultMcuSendMatch = "Command|8"; #else inline constexpr const char* kDefaultMcuRecvMatch = "VirMIDI"; inline constexpr const char* kDefaultMcuSendMatch = "VirMIDI"; diff --git a/src/macos/macos_midi_port.cpp b/src/macos/macos_midi_port.cpp new file mode 100644 index 0000000..085f02e --- /dev/null +++ b/src/macos/macos_midi_port.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "macos/macos_midi_port.hpp" + +#if __has_include() +#include +#else +#include +#endif + +#include + +namespace command8 { + +MacosMidiPort::~MacosMidiPort() { close(); } + +bool MacosMidiPort::open(const std::string& in_match, const std::string& out_match) { + try { + in_ = std::make_unique(RtMidi::UNSPECIFIED, "command8-mcu"); + out_ = std::make_unique(RtMidi::UNSPECIFIED, "command8-mcu"); + } catch (RtMidiError& e) { + std::fprintf(stderr, "command8-mcu: cannot init MIDI backend: %s\n", + e.getMessage().c_str()); + in_.reset(); + out_.reset(); + return false; + } + + try { + // Create rather than find. The virtual destination is what the DAW + // sends feedback to; the virtual source is what it receives on. + in_->openVirtualPort(in_match); + in_->ignoreTypes(false, true, true); // MCU LCD feedback is SysEx + out_->openVirtualPort(out_match); + } catch (RtMidiError& e) { + std::fprintf(stderr, "command8-mcu: cannot create virtual MIDI port: %s\n", + e.getMessage().c_str()); + close(); + return false; + } + + std::fprintf(stderr, + "command8-mcu: virtual MIDI ports created - select \"%s\" as BOTH " + "the input and output of your DAW's Mackie Control surface.\n", + in_match.c_str()); + if (in_match != out_match) + std::fprintf(stderr, "command8-mcu: (recv \"%s\", send \"%s\")\n", + in_match.c_str(), out_match.c_str()); + return true; +} + +void MacosMidiPort::close() { + stop(); + if (in_) { in_->closePort(); in_.reset(); } + { + std::lock_guard lock(out_mutex_); + if (out_) { out_->closePort(); out_.reset(); } + } +} + +void MacosMidiPort::send(const std::vector& bytes) { + std::lock_guard lock(out_mutex_); + if (!out_) return; + try { + out_->sendMessage(&bytes); + } catch (RtMidiError& e) { + if (!send_warned_) { + send_warned_ = true; + std::fprintf(stderr, "command8-mcu: MIDI send failed: %s\n", + e.getMessage().c_str()); + } + } +} + +void MacosMidiPort::midi_in_cb(double, std::vector* msg, void* user) { + auto* self = static_cast(user); + if (self->running_ && self->rx_ && msg && !msg->empty()) self->rx_(*msg); +} + +void MacosMidiPort::start() { + if (!in_ || running_) return; + running_ = true; + in_->setCallback(&MacosMidiPort::midi_in_cb, this); +} + +void MacosMidiPort::stop() { + running_ = false; + // cancelCallback() unregisters before returning, so no rx_ call can begin + // after this; the running_ gate covers one already past the registration. + if (in_) in_->cancelCallback(); +} + +std::unique_ptr make_midi_port() { return std::make_unique(); } + +} // namespace command8 diff --git a/src/macos/macos_midi_port.hpp b/src/macos/macos_midi_port.hpp new file mode 100644 index 0000000..cddae9c --- /dev/null +++ b/src/macos/macos_midi_port.hpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// macOS MidiPort implementation (RtMidi / CoreMIDI, virtual ports). +// +// Unlike Windows and Linux, macOS lets an application create MIDI endpoints +// directly, so there is no loopback utility to install: the bridge publishes +// its own duplex pair and the DAW connects straight to it. open()'s match +// arguments are therefore used as the *names to create*, not names to search +// for. A DAW that opens both sides sees one device; the bridge never receives +// its own output, because a virtual source and a virtual destination are +// separate endpoints. +// +// Both endpoints must exist. Publishing only a source gives the DAW an input +// with no matching output, and control-surface support reports that it cannot +// find a MIDI output. +#pragma once + +#include +#include +#include + +#include "midi_port.hpp" + +class RtMidiIn; +class RtMidiOut; + +namespace command8 { + +class MacosMidiPort : public MidiPort { +public: + MacosMidiPort() = default; + ~MacosMidiPort() override; + MacosMidiPort(const MacosMidiPort&) = delete; + MacosMidiPort& operator=(const MacosMidiPort&) = delete; + + // in_match / out_match name the virtual ports to create. Passing the same + // name for both (the usual case) publishes one duplex-looking device. + bool open(const std::string& in_match, const std::string& out_match) override; + void close() override; + bool ok() const override { return in_ != nullptr && out_ != nullptr; } + void send(const std::vector& bytes) override; + void start() override; + void stop() override; + +private: + static void midi_in_cb(double dt, std::vector* msg, void* user); + + std::unique_ptr in_; + std::unique_ptr out_; + std::atomic running_{false}; // gate rx delivery between start/stop + std::mutex out_mutex_; + bool send_warned_ = false; // log the first send failure only +}; + +} // namespace command8 diff --git a/src/macos/macos_surface.cpp b/src/macos/macos_surface.cpp new file mode 100644 index 0000000..1d894ad --- /dev/null +++ b/src/macos/macos_surface.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "macos/macos_surface.hpp" + +#include + +#include +#include +#include + +namespace command8 { + +namespace { + +constexpr int kReadTimeoutMs = 20; // short: run() also drives the tick +constexpr int kWriteTimeoutMs = 1000; +constexpr int kUsbPacketBytes = 4; +constexpr int kReadBufferBytes = 64; // wMaxPacketSize for both endpoints + +// Pack a raw MIDI byte sequence into 4-byte USB-MIDI event packets. The first +// nibble of each packet is the cable number (always 0 here); the second is the +// Code Index Number, which tells the device how many of the following three +// bytes are real. +std::vector to_usb_midi(const std::vector& msg) { + std::vector out; + if (msg.empty()) return out; + + if (msg[0] == 0xF0) { // SysEx: 3 bytes per packet + for (size_t i = 0; i < msg.size(); i += 3) { + const size_t left = msg.size() - i; + uint8_t cin; + if (left > 3) cin = 0x4; // continues + else if (left == 1) cin = 0x5; // ends with 1 byte + else if (left == 2) cin = 0x6; // ends with 2 + else cin = 0x7; // ends with 3 + out.push_back(cin); + for (size_t k = 0; k < 3; ++k) + out.push_back(i + k < msg.size() ? msg[i + k] : 0x00); + } + return out; + } + + // Channel voice: CIN is the status nibble. + out.push_back(static_cast(msg[0] >> 4)); + out.push_back(msg[0]); + out.push_back(msg.size() > 1 ? msg[1] : 0x00); + out.push_back(msg.size() > 2 ? msg[2] : 0x00); + return out; +} + +bool find_device(libusb_context* ctx) { + libusb_device** list = nullptr; + const ssize_t n = libusb_get_device_list(ctx, &list); + if (n < 0) return false; + bool found = false; + for (ssize_t i = 0; i < n && !found; ++i) { + libusb_device_descriptor desc{}; + if (libusb_get_device_descriptor(list[i], &desc) == 0 && + desc.idVendor == kUsbVendorId && desc.idProduct == kUsbProductId) + found = true; + } + libusb_free_device_list(list, 1); + return found; +} + +} // namespace + +MacosSurface::~MacosSurface() { close(); } + +bool MacosSurface::open(const std::string& /*port_match*/) { + if (libusb_init(&ctx_) != 0) { + std::fprintf(stderr, "command8: cannot initialise libusb\n"); + ctx_ = nullptr; + return false; + } + + // Distinguish "not on the bus" from "on the bus but we cannot open it" - + // they have completely different fixes, and libusb collapses both into a + // null handle. + const bool on_bus = find_device(ctx_); + handle_ = libusb_open_device_with_vid_pid(ctx_, kUsbVendorId, kUsbProductId); + if (!handle_) { + if (!on_bus) { + // Not conclusive: unprivileged libusb on macOS only enumerates + // devices it is allowed to touch, so a device held by another + // process - or simply requiring privileges - is invisible rather + // than merely unopenable. + std::fprintf(stderr, + "command8: no Digidesign Command|8 (%04x:%04x) visible.\n" + " If it IS plugged in and powered, this is usually one of:\n" + " - the binary needs privileges: try running with sudo\n" + " - another Command|8 bridge/driver already holds it\n" + " (macOS hides USB devices from unprivileged processes, so\n" + " 'absent' and 'not permitted' look identical here.)\n", + kUsbVendorId, kUsbProductId); + } else { + std::fprintf(stderr, + "command8: Command|8 (%04x:%04x) is on the USB bus but " + "cannot be opened. Another process is probably holding " + "it - stop any other Command|8 bridge/driver - or the " + "binary needs privileges (try sudo).\n", + kUsbVendorId, kUsbProductId); + } + close(); + return false; + } + + // Best effort: macOS usually reports this unsupported, in which case the + // claim below is what actually takes the interface from the class driver. + libusb_set_auto_detach_kernel_driver(handle_, 1); + + const int rc = libusb_claim_interface(handle_, kUsbInterface); + if (rc != 0) { + std::fprintf(stderr, + "command8: cannot claim USB interface %d: %s\n", + kUsbInterface, libusb_strerror(static_cast(rc))); + if (rc == LIBUSB_ERROR_ACCESS || rc == LIBUSB_ERROR_BUSY) { + std::fprintf(stderr, + "command8: CoreMIDI's class driver holds this interface. " + "Taking it back needs privileges - try running as root " + "(sudo), and close any app using the Command|8.\n"); + } + close(); + return false; + } + claimed_ = true; + present_ = true; + + std::fprintf(stderr, "command8: surface open (usb %04x:%04x interface %d)\n", + kUsbVendorId, kUsbProductId, kUsbInterface); + + // Wake the surface, then keep it online. Until this arrives the device + // ignores every LED/fader/meter/LCD message we send. + send(heartbeat()); + running_ = true; + keepalive_thread_ = std::thread(&MacosSurface::keepalive_loop, this); + return true; +} + +void MacosSurface::close() { + running_ = false; + if (keepalive_thread_.joinable()) keepalive_thread_.join(); + if (handle_) { + if (claimed_) { + libusb_release_interface(handle_, kUsbInterface); + claimed_ = false; + } + libusb_close(handle_); + handle_ = nullptr; + } + if (ctx_) { + libusb_exit(ctx_); + ctx_ = nullptr; + } + present_ = false; +} + +void MacosSurface::stop() { running_ = false; } + +void MacosSurface::send(const std::vector& bytes) { + std::lock_guard lock(out_mutex_); + if (!handle_ || bytes.empty()) return; + std::vector packets = to_usb_midi(bytes); + if (packets.empty()) return; + + int transferred = 0; + const int rc = libusb_bulk_transfer(handle_, kUsbEndpointOut, packets.data(), + static_cast(packets.size()), + &transferred, kWriteTimeoutMs); + if (rc != 0) { + if (rc == LIBUSB_ERROR_NO_DEVICE) present_ = false; + if (!send_warned_) { + send_warned_ = true; + std::fprintf(stderr, "command8: USB send failed: %s\n", + libusb_strerror(static_cast(rc))); + } + } +} + +void MacosSurface::keepalive_loop() { + // Timer-driven, never a reply: the device echoes host heartbeats, so + // replying would create an echo loop. Sleep in slices so stop() is prompt. + auto next = std::chrono::steady_clock::now() + keepalive_interval; + while (running_) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (std::chrono::steady_clock::now() >= next) { + send(heartbeat()); + next += keepalive_interval; + } + } +} + +void MacosSurface::handle_packet(uint8_t status, uint8_t d1, uint8_t d2) { + Event decoded = std::monostate{}; + switch (status & 0xF0) { + case 0x90: decoded = decode_note_on(d1, d2); break; + case 0x80: decoded = decode_note_on(d1, 0); break; // note-off = release + case 0xB0: decoded = decode_cc(d1, d2); break; + default: return; + } + if (std::holds_alternative(decoded)) return; // filter + if (std::holds_alternative(decoded)) return; + if (cb_) cb_(decoded); +} + +void MacosSurface::run() { + if (!handle_) return; + running_ = true; + uint8_t buf[kReadBufferBytes]; + + while (running_) { + int transferred = 0; + const int rc = libusb_bulk_transfer(handle_, kUsbEndpointIn, buf, + sizeof(buf), &transferred, + kReadTimeoutMs); + if (rc == 0) { + for (int i = 0; i + kUsbPacketBytes <= transferred; i += kUsbPacketBytes) + handle_packet(buf[i + 1], buf[i + 2], buf[i + 3]); + } else if (rc == LIBUSB_ERROR_NO_DEVICE || rc == LIBUSB_ERROR_IO) { + std::fprintf(stderr, "command8: device removed\n"); + present_ = false; + running_ = false; + break; + } + // LIBUSB_ERROR_TIMEOUT is the idle case: nothing to read, fall through + // to the tick so meter ballistics and other periodic work still run. + + if (tick_cb_) tick_cb_(); + } +} + +bool MacosSurface::device_present() { + if (!ctx_) return false; + if (!present_) return false; + return find_device(ctx_); +} + +std::unique_ptr make_surface() { return std::make_unique(); } + +void print_midi_ports() { + // There are no MIDI ports on this backend - the surface is raw USB. Report + // whether the device is on the bus instead, which is the equivalent check. + libusb_context* ctx = nullptr; + if (libusb_init(&ctx) != 0) { + std::fprintf(stderr, "command8: cannot initialise libusb\n"); + return; + } + std::fprintf(stderr, "command8: macOS backend talks raw USB (no MIDI ports).\n"); + std::fprintf(stderr, " Command|8 (%04x:%04x): %s\n", kUsbVendorId, kUsbProductId, + find_device(ctx) ? "present" : "NOT FOUND"); + libusb_exit(ctx); +} + +} // namespace command8 diff --git a/src/macos/macos_surface.hpp b/src/macos/macos_surface.hpp new file mode 100644 index 0000000..5574c14 --- /dev/null +++ b/src/macos/macos_surface.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// macOS Surface implementation (raw USB via libusb). +// +// Unlike the ALSA and RtMidi backends this one does not go through a MIDI API +// at all. The Command|8's MIDIStreaming *input* descriptor is malformed, so +// class MIDI parsers do not expose a usable input endpoint: Linux patches +// around it with a snd-usb-audio quirk, but CoreMIDI has no quirk mechanism, +// so on macOS the device's own ports enumerate and stay inert. The only way +// in is to claim the interface and speak USB-MIDI packets directly. +// +// Consequences of that choice: +// * The device is matched by VID/PID, not by port name, so the port_match +// argument to open() is ignored. +// * Claiming the interface takes it away from CoreMIDI's class driver, which +// is a privileged operation - the binary generally needs root. +// * Raw MIDI byte sequences from protocol::* must be packed into 4-byte +// USB-MIDI event packets on the way out, and unpacked on the way in. +#pragma once + +#include +#include +#include + +#include "surface.hpp" + +struct libusb_context; +struct libusb_device_handle; + +namespace command8 { + +// Digidesign Command|8, USB Audio Class 1.0 MIDIStreaming interface. +inline constexpr uint16_t kUsbVendorId = 0x0DBA; +inline constexpr uint16_t kUsbProductId = 0x8000; +inline constexpr int kUsbInterface = 1; +inline constexpr uint8_t kUsbEndpointOut = 0x01; +inline constexpr uint8_t kUsbEndpointIn = 0x81; + +class MacosSurface : public Surface { +public: + MacosSurface() = default; + ~MacosSurface() override; + MacosSurface(const MacosSurface&) = delete; + MacosSurface& operator=(const MacosSurface&) = delete; + + // port_match is ignored: the device is found by VID/PID. + bool open(const std::string& port_match = kDefaultPortMatch) override; + void close() override; + void send(const std::vector& bytes) override; + void run() override; + void stop() override; + bool device_present() override; + +private: + void keepalive_loop(); + void handle_packet(uint8_t status, uint8_t d1, uint8_t d2); + + libusb_context* ctx_ = nullptr; + libusb_device_handle* handle_ = nullptr; + bool claimed_ = false; + + std::thread keepalive_thread_; + std::atomic running_{false}; + std::atomic present_{false}; + std::mutex out_mutex_; + bool send_warned_ = false; // log the first send failure only +}; + +} // namespace command8 diff --git a/src/surface.hpp b/src/surface.hpp index 30c1a3f..a003f2f 100644 --- a/src/surface.hpp +++ b/src/surface.hpp @@ -20,8 +20,14 @@ namespace command8 { // quirk. On Windows the surface is the device's first port, named exactly // "Command|8" (the later ports show up as "MIDIIN2/3 (Command|8)"); the bar // also keeps it from matching the "Command8 MCU" loopback endpoints. -#ifdef _WIN32 +// On macOS there is no MIDI port to match: CoreMIDI cannot expose the device's +// input (the same malformed descriptor the Linux quirk patches around, with no +// quirk mechanism available), so that backend claims the USB interface and +// matches on VID/PID instead. The value is unused there. +#if defined(_WIN32) inline constexpr const char* kDefaultPortMatch = "Command|8"; +#elif defined(__APPLE__) +inline constexpr const char* kDefaultPortMatch = ""; #else inline constexpr const char* kDefaultPortMatch = "Command|8 MIDI 1"; #endif From 4296852d42fd4e3412c2c8d67496d15d2a0685ae Mon Sep 17 00:00:00 2001 From: gibsons Date: Fri, 7 Aug 2026 15:19:07 +0100 Subject: [PATCH 2/5] Fix meter scaling and add falloff ballistics 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(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 --- src/controller.cpp | 7 ++- src/feedback.cpp | 66 +++++++++++++++++++++++-- src/feedback.hpp | 31 +++++++++++- tests/test_feedback.cpp | 107 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 tests/test_feedback.cpp diff --git a/src/controller.cpp b/src/controller.cpp index a92ccb1..d8d4e85 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -20,7 +20,12 @@ void Controller::dispatch(const Event& ev) { void Controller::run() { surface_.set_callback([this](const Event& ev) { dispatch(ev); }); - surface_.set_tick([this]() { backend_.tick(); }); + // Feedback::tick() drives meter falloff, so it must run on every tick + // regardless of what the back-end does with its own. + surface_.set_tick([this]() { + feedback_.tick(); + backend_.tick(); + }); backend_.on_start(); surface_.run(); } diff --git a/src/feedback.cpp b/src/feedback.cpp index 0857c1b..b6e1643 100644 --- a/src/feedback.cpp +++ b/src/feedback.cpp @@ -15,12 +15,68 @@ void Feedback::fader(int strip, double v) { s_.send(fader_position(static_cast(strip), static_cast(val))); } +int Feedback::rows_to_bits(double rows) { + // Round rather than truncate: truncation loses the top row (11/12 of full + // scale showed 5 of 6 LEDs). Any non-zero signal lights at least one LED, + // so quiet material is distinguishable from silence. + if (rows < 0.05) return 0; + const int n = clampi(static_cast(std::lround(rows)), 1, METER_ROWS); + // Fill from the high bits down so a low signal lights the BOTTOM LED (the + // meter is addressed top-to-bottom). + return ((1 << n) - 1) << (METER_ROWS - n); +} + +double Feedback::decayed_locked(int strip, std::chrono::steady_clock::time_point now) { + double cur = meter_level_[strip]; + if (meter_decay_ms_ > 0 && cur > 0.0) { + const auto t = meter_t_[strip]; + if (t.time_since_epoch().count() != 0) { + const double dt = + std::chrono::duration(now - t).count(); + cur = std::max(0.0, cur - dt * (METER_ROWS * 1000.0 / meter_decay_ms_)); + } + } + return cur; +} + void Feedback::meter(int strip, double v) { - // rows lit from the value; fill from the high bits down so a low signal - // lights the BOTTOM LED (the meter is addressed top-to-bottom). - const int rows = clampi(static_cast(v * METER_ROWS), 0, METER_ROWS); - const int bits = ((1 << rows) - 1) << (METER_ROWS - rows); - s_.send(command8::meter(static_cast(strip), static_cast(bits))); + if (strip < 0 || strip >= STRIPS) return; + const double rows = std::max(0.0, std::min(METER_ROWS, v * METER_ROWS)); + const auto now = std::chrono::steady_clock::now(); + + std::lock_guard lock(meter_mutex_); + // The host value is a PEAK: rise to it instantly, then let tick() decay it. + meter_level_[strip] = meter_decay_ms_ > 0 + ? std::max(decayed_locked(strip, now), rows) + : rows; + meter_t_[strip] = now; +} + +void Feedback::tick() { + const auto now = std::chrono::steady_clock::now(); + int due_strip[STRIPS]; + int due_bits[STRIPS]; + int n_due = 0; + + { + std::lock_guard lock(meter_mutex_); + for (int i = 0; i < STRIPS; ++i) { + const double cur = decayed_locked(i, now); + meter_level_[i] = cur; + meter_t_[i] = now; + const int bits = rows_to_bits(cur); + if (meter_sent_[i] && bits == meter_bits_[i]) continue; // unchanged + meter_bits_[i] = bits; + meter_sent_[i] = true; + due_strip[n_due] = i; + due_bits[n_due] = bits; + ++n_due; + } + } + // Send outside the lock: s_.send() blocks on USB. + for (int i = 0; i < n_due; ++i) + s_.send(command8::meter(static_cast(due_strip[i]), + static_cast(due_bits[i]))); } void Feedback::ring_fill(int strip, double v) { diff --git a/src/feedback.hpp b/src/feedback.hpp index 8c338fd..bfe6fc6 100644 --- a/src/feedback.hpp +++ b/src/feedback.hpp @@ -4,6 +4,9 @@ // (meter fills bottom-up, pan shows a single dot, rings fill proportionally). #pragma once +#include +#include +#include #include #include "surface.hpp" @@ -14,11 +17,26 @@ class Feedback { public: static constexpr int RING_LEDS = 11; // LEDs per encoder ring static constexpr int METER_ROWS = 6; // LED rows per strip meter + static constexpr int STRIPS = 8; + + // Meter falloff: time for a full-scale meter to reach zero. Hosts send + // meter values sparsely (Reaper transmits only when the quantised 0-12 + // level changes - about 1 Hz in practice) and expect the surface to supply + // the ballistics in between, the way real MCU hardware does in firmware. + // Without this the display freezes on the last value and never falls when + // playback stops. 0 disables it and follows the host exactly. + static constexpr int DEFAULT_METER_DECAY_MS = 1200; explicit Feedback(Surface& surface) : s_(surface) {} + void set_meter_decay_ms(int ms) { meter_decay_ms_ = ms; } + + // Advance meter falloff and push any changed rows. Safe to call often; + // Controller drives it from the Surface tick. + void tick(); + void fader(int strip, double value01); // motor fader - void meter(int strip, double value01); // fills from the bottom LED up + void meter(int strip, double value01); // peak in, ballistics out void ring_fill(int strip, double value01); // thermometer (level-like params) void ring_dot(int strip, double value01); // single dot (pan position) @@ -33,7 +51,18 @@ class Feedback { void lcd_status(int strip, const std::string& text); // top row private: + // Level after decay since the last advance. Caller holds meter_mutex_. + double decayed_locked(int strip, std::chrono::steady_clock::time_point now); + static int rows_to_bits(double rows); + Surface& s_; + + int meter_decay_ms_ = DEFAULT_METER_DECAY_MS; + std::mutex meter_mutex_; // meter() runs on the host's rx thread, tick() on the surface thread + std::array meter_level_{}; // current, in rows + std::array meter_bits_{}; // last bitfield sent + std::array meter_sent_{}; // has meter_bits_ been sent yet + std::array meter_t_{}; }; } // namespace command8 diff --git a/tests/test_feedback.cpp b/tests/test_feedback.cpp new file mode 100644 index 0000000..491dd40 --- /dev/null +++ b/tests/test_feedback.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Feedback meter behaviour, against a fake Surface that records what was sent. +// +// Covers the two things that made meters look wrong on real hardware: +// * truncation lost the top LED row, and any level below one row read as +// silence; +// * without falloff the display froze on the last value the host sent, so +// meters never fell to zero when playback stopped. +#include "feedback.hpp" +#include +#include +#include +using namespace command8; + +struct FakeSurface : Surface { + std::vector> sent; + bool open(const std::string&) override { return true; } + void close() override {} + void send(const std::vector& b) override { sent.push_back(b); } + void run() override {} void stop() override {} + bool device_present() override { return true; } +}; + +// meter(): [0x90, note = 64|bits, vel = strip]. tick() refreshes every strip, +// so pick out the LAST message addressed to the strip under test. +static int rows_for(const std::vector>& sent, int strip) { + int rows = -1; + for (const auto& m : sent) { + if (m.size() < 3 || m[2] != strip) continue; + int bits = m[1] & 0x3F, n = 0; + while (bits) { n += bits & 1; bits >>= 1; } + rows = n; + } + return rows; +} +static size_t writes_for(const std::vector>& sent, int strip) { + size_t n = 0; + for (const auto& m : sent) if (m.size() >= 3 && m[2] == strip) ++n; + return n; +} + +int main() { + int fails = 0; + // --- 1. rounding vs truncation: 11/12 of full scale must light 6, not 5 + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(0); // isolate rounding from falloff + fb.meter(0, 11.0/12.0); fb.tick(); + int r = rows_for(s.sent, 0); + std::printf(" 11/12 scale -> %d rows (old truncating code gave 5) %s\n", + r, r == 6 ? "ok" : "FAIL"); fails += (r != 6); } + + // --- 2. any non-zero signal lights at least one LED + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(0); + fb.meter(0, 1.0/12.0); fb.tick(); + int r = rows_for(s.sent, 0); + std::printf(" 1/12 scale -> %d rows (must be >=1, was 0) %s\n", + r, r >= 1 ? "ok" : "FAIL"); fails += (r < 1); } + + // --- 3. silence is still silence + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(0); + fb.meter(0, 0.0); fb.tick(); + int r = rows_for(s.sent, 0); + std::printf(" 0/12 scale -> %d rows (must be 0) %s\n", + r, r == 0 ? "ok" : "FAIL"); fails += (r != 0); } + + // --- 4. THE BUG: full scale then host goes silent -> must fall to zero + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(300); // short, to keep the test quick + fb.meter(3, 1.0); fb.tick(); + int first = rows_for(s.sent, 3); + for (int i = 0; i < 40; ++i) { // 400 ms of ticks, NO host updates + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + fb.tick(); + } + int last = rows_for(s.sent, 3); + std::printf(" playback stops: %d rows -> %d rows after 400ms %s\n", + first, last, (first == 6 && last == 0) ? "ok" : "FAIL"); + fails += !(first == 6 && last == 0); } + + // --- 5. decay disabled = follow the host exactly (no falloff) + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(0); + fb.meter(3, 1.0); fb.tick(); + size_t n_after_peak = writes_for(s.sent, 3); + for (int i = 0; i < 20; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + fb.tick(); + } + bool held = (writes_for(s.sent, 3) == n_after_peak); + std::printf(" decay=0: holds host value, no extra writes %s\n", + held ? "ok" : "FAIL"); fails += !held; } + + // --- 6. unchanged level must not re-send (no USB flood) + { FakeSurface s; Feedback fb(s); + fb.set_meter_decay_ms(0); + fb.meter(2, 0.5); + for (int i = 0; i < 50; ++i) fb.tick(); + const size_t w = writes_for(s.sent, 2); + std::printf(" 50 ticks at a steady level -> %zu writes to that strip %s\n", + w, w == 1 ? "ok" : "FAIL"); + fails += (w != 1); } + + std::printf("\n %s\n", fails ? "FAILURES" : "all ballistics tests passed"); + return fails ? 1 : 0; +} From 8b6cfe67c4d6a458309e3db109e24dd8fb0fb5f1 Mon Sep 17 00:00:00 2001 From: alphonsom Date: Sat, 8 Aug 2026 15:14:27 +0100 Subject: [PATCH 3/5] quirk: correct the descriptor diagnosis in the comment 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 --- ...o-add-Digidesign-Command8-MIDI-quirk.patch | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/quirk/0001-ALSA-usb-audio-add-Digidesign-Command8-MIDI-quirk.patch b/quirk/0001-ALSA-usb-audio-add-Digidesign-Command8-MIDI-quirk.patch index 3be6470..acd9c23 100644 --- a/quirk/0001-ALSA-usb-audio-add-Digidesign-Command8-MIDI-quirk.patch +++ b/quirk/0001-ALSA-usb-audio-add-Digidesign-Command8-MIDI-quirk.patch @@ -1,18 +1,28 @@ --- a/sound/usb/quirks-table.h 2026-06-19 12:42:39.000000000 +0100 +++ b/sound/usb/quirks-table.h 2026-06-28 12:31:18.017827678 +0100 -@@ -2404,6 +2404,33 @@ +@@ -2404,6 +2404,43 @@ } }, { + /* + * Digidesign Command|8 control surface. -+ * It is a class-compliant USB-MIDI device, but the MIDIStreaming -+ * bulk-IN endpoint's class-specific descriptor is malformed -+ * (bLength 6 while declaring bNumEmbMIDIJack 3), so the standard -+ * parser fails to create any input port - leaving the surface's -+ * faders/buttons unreadable. Force fixed endpoints with 3 in and -+ * 3 out cables on the MIDIStreaming interface (1); the bulk -+ * endpoints 0x01/0x81 are auto-detected. ++ * ++ * The MIDIStreaming descriptors cannot be trusted. The class-specific ++ * bulk-IN endpoint descriptor declares bNumEmbMIDIJack 3 but carries ++ * only two jack IDs; bLength 6 is correct for the two that are there, ++ * so the count is the wrong field, not the length. The interface also ++ * declares only two Embedded MIDI OUT jacks, and the MS header's ++ * wTotalLength (98) disagrees with the descriptors actually present ++ * (82). The standard parser therefore creates no input port and the ++ * surface's faders and buttons are unreadable. ++ * ++ * The declared jack 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. ++ * In practice the device presents three inputs (the surface plus two ++ * DIN) and three outputs, so ignore the descriptors and force fixed ++ * endpoints with 3 in and 3 out cables on the MIDIStreaming interface ++ * (1); the bulk endpoints 0x01/0x81 are auto-detected. + */ + USB_DEVICE(0x0dba, 0x8000), + QUIRK_DRIVER_INFO { From dd45b0cabc006a77baf95f2e394c9067cf4cec63 Mon Sep 17 00:00:00 2001 From: alphonsom Date: Sat, 8 Aug 2026 15:14:42 +0100 Subject: [PATCH 4/5] usb: libusb Surface backend, verified on hardware 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 --- CMakeLists.txt | 39 +++++ src/alsa/alsa_surface.cpp | 12 +- src/alsa/alsa_surface.hpp | 4 + src/usb/usb_surface.cpp | 319 +++++++++++++++++++++++++++++++++++++ src/usb/usb_surface.hpp | 73 +++++++++ tests/test_usb_packets.cpp | 119 ++++++++++++++ udev/70-command8.rules | 15 ++ 7 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 src/usb/usb_surface.cpp create mode 100644 src/usb/usb_surface.hpp create mode 100644 tests/test_usb_packets.cpp create mode 100644 udev/70-command8.rules diff --git a/CMakeLists.txt b/CMakeLists.txt index 36ee349..374bb5f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,38 @@ else() target_link_libraries(command8 PUBLIC ${ALSA_LIBRARIES} Threads::Threads) endif() +# libusb Surface backend: drives the device's bulk endpoints directly, so no +# operating system ever parses the Command|8's malformed MIDIStreaming +# descriptor. Works identically on Linux, macOS and Windows, which the ALSA and +# RtMidi backends cannot. Enabled automatically wherever libusb is available. +option(COMMAND8_USB_BACKEND "Use the libusb Surface backend when available" ON) +if(COMMAND8_USB_BACKEND) + if(NOT PkgConfig_FOUND) + find_package(PkgConfig) + endif() + if(PkgConfig_FOUND) + pkg_check_modules(LIBUSB libusb-1.0) + endif() + if(LIBUSB_FOUND) + find_package(Threads REQUIRED) + target_sources(command8 PRIVATE src/usb/usb_surface.cpp) + target_include_directories(command8 PUBLIC ${LIBUSB_INCLUDE_DIRS}) + target_link_directories(command8 PUBLIC ${LIBUSB_LIBRARY_DIRS}) + target_link_libraries(command8 PUBLIC ${LIBUSB_LIBRARIES} Threads::Threads) + # The platform backend keeps its class but yields the factory. + if(WIN32) + set(C8_PLATFORM_SURFACE src/rtmidi/rtmidi_surface.cpp) + else() + set(C8_PLATFORM_SURFACE src/alsa/alsa_surface.cpp) + endif() + set_source_files_properties(${C8_PLATFORM_SURFACE} PROPERTIES + COMPILE_DEFINITIONS COMMAND8_NO_FACTORY) + message(STATUS "command8: libusb Surface backend enabled") + else() + message(STATUS "command8: libusb not found, using the platform MIDI backend") + endif() +endif() + add_executable(command8-monitor src/main.cpp) target_link_libraries(command8-monitor PRIVATE command8) target_compile_options(command8-monitor PRIVATE ${C8_WARNINGS}) @@ -98,6 +130,13 @@ target_link_libraries(test_protocol PRIVATE command8) target_compile_options(test_protocol PRIVATE ${C8_WARNINGS}) add_test(NAME protocol COMMAND test_protocol) +if(LIBUSB_FOUND) + add_executable(test_usb_packets tests/test_usb_packets.cpp) + target_link_libraries(test_usb_packets PRIVATE command8) + target_compile_options(test_usb_packets PRIVATE ${C8_WARNINGS}) + add_test(NAME usb_packets COMMAND test_usb_packets) +endif() + # --- install --- include(GNUInstallDirs) diff --git a/src/alsa/alsa_surface.cpp b/src/alsa/alsa_surface.cpp index 2c4402f..9ca6313 100644 --- a/src/alsa/alsa_surface.cpp +++ b/src/alsa/alsa_surface.cpp @@ -192,9 +192,10 @@ void AlsaSurface::run() { } } -std::unique_ptr make_surface() { return std::make_unique(); } - -void print_midi_ports() { +// The libusb backend supplies its own factory when it is built; see +// src/usb/usb_surface.cpp. AlsaSurface remains constructible either way, so +// COMMAND8_BACKEND=alsa can still select it at runtime. +void alsa_print_midi_ports() { snd_seq_t* seq = nullptr; if (snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) { std::fprintf(stderr, "command8: cannot open ALSA sequencer\n"); @@ -222,4 +223,9 @@ void print_midi_ports() { snd_seq_close(seq); } +#ifndef COMMAND8_NO_FACTORY +std::unique_ptr make_surface() { return std::make_unique(); } +void print_midi_ports() { alsa_print_midi_ports(); } +#endif + } // namespace command8 diff --git a/src/alsa/alsa_surface.hpp b/src/alsa/alsa_surface.hpp index ac612cb..18bba89 100644 --- a/src/alsa/alsa_surface.hpp +++ b/src/alsa/alsa_surface.hpp @@ -43,4 +43,8 @@ class AlsaSurface : public Surface { std::mutex out_mutex_; }; +// List the ALSA sequencer ports. Always available so the libusb backend's +// diagnostics can fall back to it under COMMAND8_BACKEND=alsa. +void alsa_print_midi_ports(); + } // namespace command8 diff --git a/src/usb/usb_surface.cpp b/src/usb/usb_surface.cpp new file mode 100644 index 0000000..2bef95b --- /dev/null +++ b/src/usb/usb_surface.cpp @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "usb/usb_surface.hpp" + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include "alsa/alsa_surface.hpp" +#endif + +namespace command8 { +namespace { + +constexpr uint8_t CIN_SYSEX_START = 0x4; +constexpr uint8_t CIN_SYSEX_END_1 = 0x5; +constexpr uint8_t CIN_SYSEX_END_2 = 0x6; +constexpr uint8_t CIN_SYSEX_END_3 = 0x7; +constexpr uint8_t CIN_NOTE_OFF = 0x8; +constexpr uint8_t CIN_NOTE_ON = 0x9; +constexpr uint8_t CIN_CC = 0xB; +constexpr uint8_t CIN_SINGLE_BYTE = 0xF; + +void emit(std::vector& out, uint8_t header, uint8_t b1, uint8_t b2, uint8_t b3) { + out.push_back(header); + out.push_back(b1); + out.push_back(b2); + out.push_back(b3); +} + +} // namespace + +// USB-MIDI 1.0 §4: every event is 4 bytes, the first holding the cable number in +// the high nibble and a Code Index Number in the low nibble. The CIN encodes how +// many of the following three bytes are real, so the receiver never has to run a +// MIDI parser. +void usb_midi_packetize(const std::vector& midi, uint8_t cable, + std::vector& out) { + const uint8_t cn = static_cast(cable << 4); + size_t i = 0; + + while (i < midi.size()) { + const uint8_t st = midi[i]; + + // SysEx: split into 3-byte groups, with the CIN of the final group + // reporting how many bytes it carries. + if (st == 0xF0) { + size_t end = i; + while (end < midi.size() && midi[end] != 0xF7) ++end; + if (end >= midi.size()) return; // unterminated: drop rather than guess + + size_t pos = i; + size_t n = end - i + 1; + while (n > 3) { + emit(out, static_cast(cn | CIN_SYSEX_START), midi[pos], midi[pos + 1], + midi[pos + 2]); + pos += 3; + n -= 3; + } + const uint8_t cin = (n == 1) ? CIN_SYSEX_END_1 + : (n == 2) ? CIN_SYSEX_END_2 + : CIN_SYSEX_END_3; + emit(out, static_cast(cn | cin), midi[pos], (n >= 2) ? midi[pos + 1] : 0, + (n >= 3) ? midi[pos + 2] : 0); + i = end + 1; + continue; + } + + // Realtime bytes may appear anywhere and are always one byte. + if (st >= 0xF8) { + emit(out, static_cast(cn | CIN_SINGLE_BYTE), st, 0, 0); + ++i; + continue; + } + + if (st >= 0x80) { + const uint8_t hi = static_cast(st & 0xF0); + size_t len; + uint8_t cin; + if (st == 0xF1 || st == 0xF3) { + len = 2; + cin = 0x2; + } else if (st == 0xF2) { + len = 3; + cin = 0x3; + } else if (st == 0xF6) { + len = 1; + cin = CIN_SYSEX_END_1; // doubles as single-byte system common + } else if (hi == 0xC0 || hi == 0xD0) { + len = 2; + cin = static_cast(hi >> 4); + } else { + len = 3; + cin = static_cast(hi >> 4); + } + if (i + len > midi.size()) return; // truncated message + emit(out, static_cast(cn | cin), midi[i], (len >= 2) ? midi[i + 1] : 0, + (len >= 3) ? midi[i + 2] : 0); + i += len; + continue; + } + + // A data byte with no status: the encoders never emit running status, so + // this is malformed input. Skip it rather than desynchronising. + ++i; + } +} + +UsbSurface::~UsbSurface() { close(); } + +bool UsbSurface::open(const std::string& port_match) { + (void)port_match; + + if (libusb_init(&ctx_) != LIBUSB_SUCCESS) { + std::fprintf(stderr, "command8: cannot initialise libusb\n"); + return false; + } + + dev_ = libusb_open_device_with_vid_pid(ctx_, C8_USB_VID, C8_USB_PID); + if (!dev_) { + std::fprintf(stderr, + "command8: no Command|8 (%04x:%04x) found, or insufficient " + "permission. On Linux add a udev rule granting access; on " + "Windows bind WinUSB to the device.\n", + C8_USB_VID, C8_USB_PID); + close(); + return false; + } + + // Linux only: hands the interface over from snd-usb-audio and reattaches it + // on release. Returns NOT_SUPPORTED on macOS and Windows, where no class + // driver successfully claims this interface anyway. + libusb_set_auto_detach_kernel_driver(dev_, 1); + + const int r = libusb_claim_interface(dev_, C8_USB_INTERFACE); + if (r != LIBUSB_SUCCESS) { + std::fprintf(stderr, + "command8: cannot claim interface %d (%s)%s\n", C8_USB_INTERFACE, + libusb_error_name(r), + r == LIBUSB_ERROR_BUSY + ? " - another process holds it; stop any running command8 daemon" + : ""); + close(); + return false; + } + claimed_ = true; + present_ = true; + + std::fprintf(stderr, "command8: surface open (usb %04x:%04x interface %d)\n", C8_USB_VID, + C8_USB_PID, C8_USB_INTERFACE); + + // Wake the surface, then keep it online. The device ignores all output until + // it receives this. + send(heartbeat()); + running_ = true; + keepalive_thread_ = std::thread(&UsbSurface::keepalive_loop, this); + return true; +} + +void UsbSurface::close() { + running_ = false; + if (keepalive_thread_.joinable()) keepalive_thread_.join(); + if (dev_) { + if (claimed_) libusb_release_interface(dev_, C8_USB_INTERFACE); + libusb_close(dev_); + dev_ = nullptr; + } + claimed_ = false; + present_ = false; + if (ctx_) { + libusb_exit(ctx_); + ctx_ = nullptr; + } +} + +void UsbSurface::send(const std::vector& bytes) { + if (!dev_ || bytes.empty()) return; + + std::vector packets; + packets.reserve(bytes.size() * 2); + usb_midi_packetize(bytes, C8_CABLE_SURFACE, packets); + if (packets.empty()) return; + + std::lock_guard lock(out_mutex_); + size_t off = 0; + while (off < packets.size()) { + const int chunk = + static_cast(std::min(C8_USB_EP_SIZE, packets.size() - off)); + int transferred = 0; + const int r = libusb_bulk_transfer(dev_, C8_USB_EP_OUT, packets.data() + off, chunk, + &transferred, 100); + if (r == LIBUSB_ERROR_NO_DEVICE) { + present_ = false; + return; + } + if (r != LIBUSB_SUCCESS || transferred <= 0) return; + off += static_cast(transferred); + } +} + +void UsbSurface::keepalive_loop() { + // Timer-driven, never a reply: the device echoes host heartbeats, so + // replying would create an echo loop. Sleep in slices so stop() is prompt. + auto next = std::chrono::steady_clock::now() + keepalive_interval; + while (running_) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (std::chrono::steady_clock::now() >= next) { + send(heartbeat()); + next += keepalive_interval; + } + } +} + +void UsbSurface::dispatch_packet(const uint8_t* p) { + if ((p[0] >> 4) != C8_CABLE_SURFACE) return; // DIN jacks are not surface input + + Event decoded = std::monostate{}; + switch (p[0] & 0x0F) { + case CIN_NOTE_ON: + decoded = decode_note_on(p[2], p[3]); + break; + case CIN_NOTE_OFF: + decoded = decode_note_on(p[2], 0); // vel-0 release + break; + case CIN_CC: + decoded = decode_cc(p[2], p[3]); + break; + default: + return; + } + if (std::holds_alternative(decoded)) return; // filtered + if (std::holds_alternative(decoded)) return; + if (cb_) cb_(decoded); +} + +void UsbSurface::run() { + if (!dev_) return; + running_ = true; + + uint8_t buf[C8_USB_EP_SIZE]; + auto last_tick = std::chrono::steady_clock::now(); + + while (running_) { + int transferred = 0; + const int r = + libusb_bulk_transfer(dev_, C8_USB_EP_IN, buf, sizeof(buf), &transferred, 100); + + if (r == LIBUSB_SUCCESS) { + for (int i = 0; i + 4 <= transferred; i += 4) dispatch_packet(buf + i); + } else if (r == LIBUSB_ERROR_TIMEOUT) { + // Idle surface. Not an error: bulk IN NAKs until there is input. + } else if (r == LIBUSB_ERROR_NO_DEVICE || r == LIBUSB_ERROR_IO) { + std::fprintf(stderr, "command8: device removed\n"); + present_ = false; + running_ = false; + break; + } + + // The Surface contract promises a ~10 Hz tick; a busy fader would + // otherwise call it far more often. + const auto now = std::chrono::steady_clock::now(); + if (tick_cb_ && now - last_tick >= std::chrono::milliseconds(100)) { + last_tick = now; + tick_cb_(); + } + } +} + +// ---- factory ---------------------------------------------------------------- + +std::unique_ptr make_surface() { +#ifndef _WIN32 + // Escape hatch: the quirk-patched ALSA path still works where it is + // installed, and is useful for A/B testing this backend against it. + const char* backend = std::getenv("COMMAND8_BACKEND"); + if (backend && std::strcmp(backend, "alsa") == 0) { + std::fprintf(stderr, "command8: using ALSA backend (COMMAND8_BACKEND=alsa)\n"); + return std::make_unique(); + } +#endif + return std::make_unique(); +} + +void print_midi_ports() { +#ifndef _WIN32 + const char* backend = std::getenv("COMMAND8_BACKEND"); + if (backend && std::strcmp(backend, "alsa") == 0) { + alsa_print_midi_ports(); + return; + } +#endif + libusb_context* ctx = nullptr; + if (libusb_init(&ctx) != LIBUSB_SUCCESS) { + std::fprintf(stderr, "command8: cannot initialise libusb\n"); + return; + } + + libusb_device** list = nullptr; + const ssize_t n = libusb_get_device_list(ctx, &list); + std::fprintf(stderr, "USB devices:\n"); + bool found = false; + for (ssize_t i = 0; i < n; i++) { + libusb_device_descriptor d{}; + if (libusb_get_device_descriptor(list[i], &d) != LIBUSB_SUCCESS) continue; + const bool is_c8 = d.idVendor == C8_USB_VID && d.idProduct == C8_USB_PID; + if (is_c8) found = true; + std::fprintf(stderr, " %04x:%04x bus %3d addr %3d%s\n", d.idVendor, d.idProduct, + libusb_get_bus_number(list[i]), libusb_get_device_address(list[i]), + is_c8 ? " <- Command|8" : ""); + } + if (!found) std::fprintf(stderr, " (no Command|8 found)\n"); + + if (list) libusb_free_device_list(list, 1); + libusb_exit(ctx); +} + +} // namespace command8 diff --git a/src/usb/usb_surface.hpp b/src/usb/usb_surface.hpp new file mode 100644 index 0000000..1020d6e --- /dev/null +++ b/src/usb/usb_surface.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Surface implementation that talks to the Command|8's bulk endpoints directly +// with libusb, bypassing every operating system's USB-MIDI class parser. +// +// The device's class-specific bulk-IN endpoint descriptor declares +// bNumEmbMIDIJack 3 with bLength 6 (the required length is 4 + 3 = 7). Linux's +// snd-usb-audio therefore builds no input port, and the macOS and Windows class +// drivers reject the MIDIStreaming interface outright. The endpoints themselves +// are perfectly serviceable -- so this backend opens them from constants and +// never reads a descriptor, which makes the malformation irrelevant rather than +// merely tolerated. +// +// Consequences: no kernel quirk on Linux, no Digidesign driver on Windows (bind +// WinUSB instead), and one code path on all three platforms. +#pragma once + +#include + +#include +#include +#include + +#include "surface.hpp" + +namespace command8 { + +// Everything the backend needs to know about the device, stated rather than +// discovered. These match the values encoded in the snd-usb-audio quirk. +inline constexpr uint16_t C8_USB_VID = 0x0dba; +inline constexpr uint16_t C8_USB_PID = 0x8000; +inline constexpr int C8_USB_INTERFACE = 1; // MIDIStreaming +inline constexpr uint8_t C8_USB_EP_OUT = 0x01; // host -> surface +inline constexpr uint8_t C8_USB_EP_IN = 0x81; // surface -> host +inline constexpr int C8_USB_EP_SIZE = 64; // Full-Speed bulk + +// The device carries three cables: 0 is the control surface, 1 and 2 are the +// rear DIN jacks. Only cable 0 is a Surface; the DIN jacks are MIDI ports and +// belong behind MidiPort if they are ever wired up. +inline constexpr uint8_t C8_CABLE_SURFACE = 0; + +class UsbSurface : public Surface { +public: + ~UsbSurface() override; + + // port_match is accepted for interface compatibility and ignored: this + // backend matches on VID/PID, which is identical on every platform. + bool open(const std::string& port_match = kDefaultPortMatch) override; + void close() override; + void send(const std::vector& bytes) override; + void run() override; + void stop() override { running_ = false; } + bool device_present() override { return present_.load(); } + +private: + void keepalive_loop(); + void dispatch_packet(const uint8_t* p); + + libusb_context* ctx_ = nullptr; + libusb_device_handle* dev_ = nullptr; + bool claimed_ = false; + + std::atomic running_{false}; + std::atomic present_{false}; + std::mutex out_mutex_; + std::thread keepalive_thread_; +}; + +// Convert a raw MIDI byte stream into USB-MIDI 1.0 event packets on `cable`. +// Exposed for testing; see tests/test_usb_packets.cpp. +void usb_midi_packetize(const std::vector& midi, uint8_t cable, + std::vector& out); + +} // namespace command8 diff --git a/tests/test_usb_packets.cpp b/tests/test_usb_packets.cpp new file mode 100644 index 0000000..11361a1 --- /dev/null +++ b/tests/test_usb_packets.cpp @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Framework-free unit tests for the USB-MIDI 1.0 event packetiser used by the +// libusb Surface backend. +// +// Every packet is 4 bytes: a header carrying the cable number in the high nibble +// and a Code Index Number in the low nibble, then up to three MIDI bytes. Getting +// the CIN wrong is silent -- the device simply ignores the message -- so the +// encoding is pinned down here rather than discovered on hardware. +#include +#include +#include + +#include "protocol.hpp" +#include "usb/usb_surface.hpp" + +using namespace command8; + +static int g_fail = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + ++g_fail; \ + } \ + } while (0) + +static std::vector pack(std::initializer_list midi, uint8_t cable = 0) { + std::vector in, out; + for (int x : midi) in.push_back(static_cast(x)); + usb_midi_packetize(in, cable, out); + return out; +} + +static bool eq(const std::vector& a, std::initializer_list b) { + if (a.size() != b.size()) return false; + size_t i = 0; + for (int x : b) + if (a[i++] != static_cast(x)) return false; + return true; +} + +int main() { + // --- channel voice: 3-byte messages, CIN = status high nibble --- + CHECK(eq(pack({0x90, 0x00, 0x7F}), {0x09, 0x90, 0x00, 0x7F})); // note-on + CHECK(eq(pack({0x80, 0x05, 0x00}), {0x08, 0x80, 0x05, 0x00})); // note-off + CHECK(eq(pack({0xB0, 0x40, 0x41}), {0x0B, 0xB0, 0x40, 0x41})); // CC + + // --- 2-byte channel messages keep their own CIN and length --- + CHECK(eq(pack({0xC0, 0x03}), {0x0C, 0xC0, 0x03, 0x00})); // program change + CHECK(eq(pack({0xD0, 0x40}), {0x0D, 0xD0, 0x40, 0x00})); // channel pressure + + // --- cable number occupies the header's high nibble --- + CHECK(eq(pack({0x90, 0x00, 0x7F}, 2), {0x29, 0x90, 0x00, 0x7F})); + + // --- back-to-back messages produce back-to-back packets --- + CHECK(eq(pack({0x90, 0x00, 0x7F, 0xB0, 0x01, 0x02}), + {0x09, 0x90, 0x00, 0x7F, 0x0B, 0xB0, 0x01, 0x02})); + + // --- the wake/keepalive heartbeat, which is what actually brings the + // surface online, must land as a single note-on packet --- + { + std::vector out; + usb_midi_packetize(heartbeat(), C8_CABLE_SURFACE, out); + CHECK(out.size() == 4); + CHECK(out[0] == 0x09); + CHECK(out[2] == HEARTBEAT_NOTE); + CHECK(out[3] == HEARTBEAT_VEL); + } + + // --- SysEx: exactly 3 bytes ends with CIN 7 --- + CHECK(eq(pack({0xF0, 0x13, 0xF7}), {0x07, 0xF0, 0x13, 0xF7})); + + // --- SysEx: a 4-byte message splits into a start group then a 1-byte end --- + CHECK(eq(pack({0xF0, 0x13, 0x01, 0xF7}), + {0x04, 0xF0, 0x13, 0x01, 0x05, 0xF7, 0x00, 0x00})); + + // --- SysEx: 5 bytes -> start group + 2-byte end --- + CHECK(eq(pack({0xF0, 0x13, 0x01, 0x00, 0xF7}), + {0x04, 0xF0, 0x13, 0x01, 0x06, 0x00, 0xF7, 0x00})); + + // --- a real encoder-ring message round-trips to whole packets --- + { + const std::vector ring = encoder_ring(0, 0x20); + std::vector out; + usb_midi_packetize(ring, C8_CABLE_SURFACE, out); + CHECK(out.size() % 4 == 0); + CHECK(!out.empty()); + // First packet must be a SysEx start or a complete short SysEx. + const uint8_t cin = out[0] & 0x0F; + CHECK(cin == 0x4 || cin == 0x5 || cin == 0x6 || cin == 0x7); + CHECK(out[1] == 0xF0); + // Last packet must be a SysEx-end CIN, and 0xF7 must be its final byte. + const uint8_t last_cin = out[out.size() - 4] & 0x0F; + CHECK(last_cin == 0x5 || last_cin == 0x6 || last_cin == 0x7); + const size_t tail = (last_cin == 0x5) ? 3 : (last_cin == 0x6) ? 2 : 1; + CHECK(out[out.size() - tail] == 0xF7); + } + + // --- an LCD write is longer than one bulk packet's worth of MIDI and must + // still packetise cleanly --- + { + const std::vector text = lcd_channel(0, "CHAN 1"); + std::vector out; + usb_midi_packetize(text, C8_CABLE_SURFACE, out); + CHECK(out.size() % 4 == 0); + CHECK(out[1] == 0xF0); + } + + // --- realtime bytes are single-byte packets --- + CHECK(eq(pack({0xF8}), {0x0F, 0xF8, 0x00, 0x00})); + + // --- malformed input is dropped, never allowed to desynchronise --- + CHECK(pack({0xF0, 0x13, 0x01}).empty()); // unterminated SysEx + CHECK(pack({0x90, 0x00}).empty()); // truncated note-on + CHECK(pack({0x40, 0x41}).empty()); // data bytes with no status + + if (g_fail == 0) std::printf("usb packet tests passed\n"); + return g_fail ? 1 : 0; +} diff --git a/udev/70-command8.rules b/udev/70-command8.rules new file mode 100644 index 0000000..909a258 --- /dev/null +++ b/udev/70-command8.rules @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Digidesign Command|8 (0dba:8000) — access for the libusb Surface backend. +# +# The backend drives the device's bulk endpoints directly, which needs write +# access to the USB device node. By default that node is root-only. +# +# sudo install -m644 udev/70-command8.rules /etc/udev/rules.d/ +# sudo udevadm control --reload-rules && sudo udevadm trigger +# +# Then replug the Command|8 (or power-cycle it) so the rule is applied. +# +# uaccess hands the device to whoever is logged in at the seat, which is what +# you want for a desktop DAW machine. The plugdev fallback covers headless or +# non-logind setups; drop it if your distribution has no plugdev group. +SUBSYSTEM=="usb", ATTR{idVendor}=="0dba", ATTR{idProduct}=="8000", TAG+="uaccess", MODE="0660", GROUP="plugdev" From 869f8334fe1d450ea0252334761b16728c67ec37 Mon Sep 17 00:00:00 2001 From: gibsons Date: Sat, 8 Aug 2026 09:31:08 -0700 Subject: [PATCH 5/5] docs: correct macOS sudo claim after hardware verification 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 --- README.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 07e70bf..cf60664 100644 --- a/README.md +++ b/README.md @@ -113,24 +113,18 @@ brew install cmake ninja libusb rtmidi liblo cmake -B build -G Ninja cmake --build build ctest --test-dir build -sudo ./build/command8-monitor # loopback demo -sudo ./build/command8-reaper # Reaper OSC bridge (identical OSC setup) -sudo ./build/command8-mackie # MCU bridge (no loopback needed) +./build/command8-monitor # loopback demo +./build/command8-reaper # Reaper OSC bridge (identical OSC setup) +./build/command8-mackie # MCU bridge (no loopback needed) ``` -**`sudo` is required, and is not incidental.** The backend has to claim the -USB interface, which takes it from CoreMIDI's class driver — a privileged -operation. CoreMIDI reclaims the interface as soon as anything releases it, so -this applies on every run. Worse, an unprivileged process cannot even *see* the -device: macOS hides USB devices a process may not touch, so "not plugged in" -and "not permitted" are indistinguishable from userspace (the error message -says so rather than guessing). - -To avoid typing it every time, run the bridge from a `LaunchDaemon`, which -starts as root at boot. Note that this does mean a permanently root-owned -process; the alternatives — unloading the system USB-MIDI driver, a codeless -kext (deprecated, and blocked on Apple Silicon), or a DriverKit driver -(needs an Apple entitlement) — are all worse for a self-hosted tool. +No `sudo` needed: on the machine this was verified on (macOS 15.6, Intel, +Homebrew, libusb 1.0.30) `command8-monitor` claims the USB interface and gets +live fader/encoder input and LED feedback as a normal user. If your setup +instead reports "device not found" or a claim failure, it's most likely +another process already holding the interface (see below) or a stricter USB +permission policy on your machine — try `sudo` as a fallback in that case, and +consider a `LaunchDaemon` if you need it every run. If another Command|8 bridge is already running, stop it first: the interface is exclusive.