Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})
Expand Down Expand Up @@ -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)

Expand Down
66 changes: 58 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 18 additions & 8 deletions quirk/0001-ALSA-usb-audio-add-Digidesign-Command8-MIDI-quirk.patch
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions src/alsa/alsa_surface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,10 @@ void AlsaSurface::run() {
}
}

std::unique_ptr<Surface> make_surface() { return std::make_unique<AlsaSurface>(); }

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");
Expand Down Expand Up @@ -222,4 +223,9 @@ void print_midi_ports() {
snd_seq_close(seq);
}

#ifndef COMMAND8_NO_FACTORY
std::unique_ptr<Surface> make_surface() { return std::make_unique<AlsaSurface>(); }
void print_midi_ports() { alsa_print_midi_ports(); }
#endif

} // namespace command8
4 changes: 4 additions & 0 deletions src/alsa/alsa_surface.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion src/controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
66 changes: 61 additions & 5 deletions src/feedback.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,68 @@ void Feedback::fader(int strip, double v) {
s_.send(fader_position(static_cast<uint8_t>(strip), static_cast<uint8_t>(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<int>(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<double>(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<int>(v * METER_ROWS), 0, METER_ROWS);
const int bits = ((1 << rows) - 1) << (METER_ROWS - rows);
s_.send(command8::meter(static_cast<uint8_t>(strip), static_cast<uint8_t>(bits)));
if (strip < 0 || strip >= STRIPS) return;
const double rows = std::max(0.0, std::min<double>(METER_ROWS, v * METER_ROWS));
const auto now = std::chrono::steady_clock::now();

std::lock_guard<std::mutex> 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<std::mutex> 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<uint8_t>(due_strip[i]),
static_cast<uint8_t>(due_bits[i])));
}

void Feedback::ring_fill(int strip, double v) {
Expand Down
Loading
Loading