diff --git a/CMakeLists.txt b/CMakeLists.txt index 36ee349..1a95dfb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,26 @@ 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) + # macOS has no platform Surface: no class driver can enumerate the device and + # there is no quirk mechanism, so the libusb backend below is the only option + # and is mandatory here. This branch supplies the MCU-facing MidiPort only -- + # ordinary CoreMIDI via RtMidi, which can create virtual ports, so no loopback + # utility is needed. + find_package(PkgConfig REQUIRED) + find_package(Threads REQUIRED) + target_sources(command8 PRIVATE src/macos/macos_midi_port.cpp) + target_link_libraries(command8 PUBLIC 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) @@ -47,6 +67,50 @@ 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(APPLE AND NOT COMMAND8_USB_BACKEND) + message(FATAL_ERROR + "COMMAND8_USB_BACKEND cannot be disabled on macOS: it is the only Surface " + "implementation there.") +endif() +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) + # Where a platform Surface exists it keeps its class but yields the factory. + # macOS has none, so there is nothing to silence there. + if(WIN32) + set(C8_PLATFORM_SURFACE src/rtmidi/rtmidi_surface.cpp) + elseif(NOT APPLE) + set(C8_PLATFORM_SURFACE src/alsa/alsa_surface.cpp) + endif() + if(C8_PLATFORM_SURFACE) + set_source_files_properties(${C8_PLATFORM_SURFACE} PROPERTIES + COMPILE_DEFINITIONS COMMAND8_NO_FACTORY) + endif() + message(STATUS "command8: libusb Surface backend enabled") + elseif(APPLE) + message(FATAL_ERROR + "libusb-1.0 is required on macOS (brew install libusb): it provides the " + "only Surface implementation for this platform.") + 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 +162,18 @@ 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) + +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/README.md b/README.md index 8afab01..cf60664 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,43 @@ 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 +./build/command8-monitor # loopback demo +./build/command8-reaper # Reaper OSC bridge (identical OSC setup) +./build/command8-mackie # MCU bridge (no loopback needed) +``` + +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. + +### 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/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 { 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/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/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/surface.hpp b/src/surface.hpp index 30c1a3f..47b6402 100644 --- a/src/surface.hpp +++ b/src/surface.hpp @@ -20,8 +20,16 @@ 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 +// The value is unused wherever UsbSurface is the backend, which is everywhere +// libusb is available: it matches on VID/PID instead. No class driver on any +// platform successfully claims the MIDIStreaming interface -- on a stock Linux +// kernel snd-usb-audio binds neither interface, and the macOS and Windows class +// drivers reject it outright -- so there is no port to name and nothing to +// detach. On macOS there is no alternative backend at all. +#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 diff --git a/src/usb/usb_surface.cpp b/src/usb/usb_surface.cpp new file mode 100644 index 0000000..7189da0 --- /dev/null +++ b/src/usb/usb_surface.cpp @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "usb/usb_surface.hpp" + +#include +#include +#include +#include +#include + +// ALSA is Linux-only. Guarding on !_WIN32 would drag into the +// macOS build, where it does not exist. +#if defined(__linux__) +#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::device_present() { + // Once open, run() and send() maintain present_ from transfer results, which + // notices removal faster than a scan would. Before open there is no handle, + // so walk the bus instead of reporting a flag that is false by construction. + if (dev_) return present_.load(); + + libusb_context* ctx = ctx_; + libusb_context* tmp = nullptr; + if (!ctx) { + if (libusb_init(&tmp) != LIBUSB_SUCCESS) return false; + ctx = tmp; + } + + libusb_device** list = nullptr; + const ssize_t n = libusb_get_device_list(ctx, &list); + bool found = false; + for (ssize_t i = 0; i < n && !found; ++i) { + libusb_device_descriptor d{}; + if (libusb_get_device_descriptor(list[i], &d) == LIBUSB_SUCCESS && + d.idVendor == C8_USB_VID && d.idProduct == C8_USB_PID) + found = true; + } + if (list) libusb_free_device_list(list, 1); + if (tmp) libusb_exit(tmp); + return found; +} + +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; + } + + // Belt and braces. In practice no class driver claims this interface on any + // platform -- a stock Linux kernel binds neither interface because the + // descriptors fail to parse -- so there is normally nothing to detach. This + // covers the case where the quirk-patched snd-usb-audio did bind it, and is + // a no-op (NOT_SUPPORTED) on macOS and Windows. + 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() { +#if defined(__linux__) + // 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() { +#if defined(__linux__) + 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..489f034 --- /dev/null +++ b/src/usb/usb_surface.hpp @@ -0,0 +1,77 @@ +// 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; } + + // Scans the bus rather than reporting a cached flag, so it is meaningful + // before open() as well as after -- matching AlsaSurface, whose callers may + // poll it while waiting for the device to appear. + bool device_present() override; + +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_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; +} 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"