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
29 changes: 29 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
72 changes: 64 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,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
Expand Down
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
31 changes: 30 additions & 1 deletion src/feedback.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// (meter fills bottom-up, pan shows a single dot, rings fill proportionally).
#pragma once

#include <array>
#include <chrono>
#include <mutex>
#include <string>

#include "surface.hpp"
Expand All @@ -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)

Expand All @@ -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<double, STRIPS> meter_level_{}; // current, in rows
std::array<int, STRIPS> meter_bits_{}; // last bitfield sent
std::array<bool, STRIPS> meter_sent_{}; // has meter_bits_ been sent yet
std::array<std::chrono::steady_clock::time_point, STRIPS> meter_t_{};
};

} // namespace command8
9 changes: 7 additions & 2 deletions src/mackie/mackie_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading