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/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/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 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; +}