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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/build/
/build-*/
*.o
*.a
compile_commands.json
Expand Down
18 changes: 17 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
cmake_minimum_required(VERSION 3.16)
project(command8 LANGUAGES CXX)
project(command8 LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release)
endif()
Expand All @@ -28,6 +30,15 @@ add_library(command8 STATIC
target_include_directories(command8 PUBLIC src)
target_compile_options(command8 PRIVATE ${C8_WARNINGS})

# Freestanding C99 Command|8 <-> Mackie Control translator, shared verbatim with
# the dongle firmware, where it runs on bare metal with no allocator and no C++
# runtime. It is a separate target with no link dependencies on purpose: the
# build then enforces that property instead of leaving it to convention, so an
# accidental #include of a host header fails here rather than on the RP2040.
add_library(command8-mcu STATIC src/mcu/c8_mcu.c)
target_include_directories(command8-mcu PUBLIC src)
target_compile_options(command8-mcu PRIVATE ${C8_WARNINGS})

if(WIN32)
find_package(RtMidi CONFIG REQUIRED)
target_sources(command8 PRIVATE
Expand Down Expand Up @@ -162,6 +173,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_mcu tests/test_mcu.cpp)
target_link_libraries(test_mcu PRIVATE command8 command8-mcu)
target_compile_options(test_mcu PRIVATE ${C8_WARNINGS})
add_test(NAME mcu COMMAND test_mcu)

add_executable(test_feedback tests/test_feedback.cpp)
target_link_libraries(test_feedback PRIVATE command8)
target_compile_options(test_feedback PRIVATE ${C8_WARNINGS})
Expand Down
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,44 @@ talks to the engine (over OSC, or over the MCU loopback pair), never to the
surface directly: the Command|8 speaks a proprietary protocol, so a DAW sending
it generic MIDI just makes the faders twitch and leaves the display Offline.

### No bridge at all: the dongle's MCU cable

A dongle built with on-board translation exposes a fourth cable that *is* a
Mackie Control endpoint, so none of the above is needed — no engine process, no
loopback pair, no OSC. Point the DAW's Mackie Control support straight at it,
input and output, and leave the surface's own port disabled.

The cable is named `Command8 MCU` on Linux and macOS. On Windows, WinMM names
cables positionally and ignores the jack strings, so it appears as
`MIDIIN4 (Command|8 Bridge)` / `MIDIOUT4 (Command|8 Bridge)` — the fourth port.

Verified on hardware 2026-08-11: Reaper driving a Command|8 through the dongle
on Windows with no host software running.

The translation is the same `src/mcu/c8_mcu.c` this repo unit-tests on the
desktop; the firmware compiles that file directly rather than a copy.

### Mackie bridge on Windows

Windows has no app-created virtual MIDI ports, so create a loopback pair once
with [Windows MIDI Services](https://aka.ms/midi) (or two loopMIDI cables and
`--mcu-recv`/`--mcu-send`):

```bat
midi loopback create --name-a "Command8 MCU A" --name-b "Command8 MCU B"
midi loopback create --name-a "Command8 MCU Bridge" --name-b "Command8 MCU DAW"
```

`command8-mackie` uses side **A** by default; point the DAW's Mackie Control
input *and* output at side **B**. The pair is crossed, so neither end hears its
own output.
The ends are named for who owns them. `command8-mackie` takes **Command8 MCU
Bridge** by default; point the DAW's Mackie Control input *and* output at
**Command8 MCU DAW**. The pair is crossed, so neither end hears its own output.

Note there is no bar in these names, deliberately. `Command|8` is how the tools
find the surface itself, by prefix, so a loopback named `Command|8 MCU …` could
be matched as the device. Keep the loopback pair on the unbarred `Command8`.

Verified end to end on Windows: with the three `Command|8 Bridge` device ports
left **disabled** in Reaper's MIDI Devices, `Command8 MCU DAW` enabled, and a
Mackie Control Universal surface pointed at it for both input and output.


## License
Expand Down
28 changes: 19 additions & 9 deletions src/mackie/mackie_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,26 @@
namespace command8 {

// Default MCU port-name matches. recv = DAW-to-bridge, send = bridge-to-DAW.
// Linux: one duplex virmidi port carries both directions. Windows: side A of a
// 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. 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.
// Linux: one duplex virmidi port carries both directions. Windows: the bridge
// end of a Windows MIDI Services loopback pair (create once with
// midi loopback create --name-a "Command8 MCU Bridge" --name-b "Command8 MCU DAW"
// ); the bridge opens its own end both ways and the DAW's Mackie Control uses
// the other, so 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.
//
// The ends are named for who owns them because "A" and "B" gave no clue which
// was which, and picking the wrong one in the DAW produces a silent failure
// that looks exactly like broken hardware.
//
// Do NOT put a bar in these names. "Command|8" is the surface port matcher (see
// kDefaultPortMatch in surface.hpp), and it takes the first prefix match it
// finds -- a loopback called "Command|8 MCU ..." could win that match ahead of
// the real device depending on enumeration order. The unbarred "Command8" is
// what keeps the two families of port distinguishable.
#if defined(_WIN32)
inline constexpr const char* kDefaultMcuRecvMatch = "Command8 MCU A";
inline constexpr const char* kDefaultMcuSendMatch = "Command8 MCU A";
inline constexpr const char* kDefaultMcuRecvMatch = "Command8 MCU Bridge";
inline constexpr const char* kDefaultMcuSendMatch = "Command8 MCU Bridge";
#elif defined(__APPLE__)
inline constexpr const char* kDefaultMcuRecvMatch = "Command|8";
inline constexpr const char* kDefaultMcuSendMatch = "Command|8";
Expand Down
11 changes: 7 additions & 4 deletions src/mackie/mackie_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
// Linux: use a snd-virmidi kernel port so DAWs (e.g. Bitwig) can see it:
// sudo modprobe snd-virmidi -> "Virtual Raw MIDI 4-0..4-3"
// Windows: create a Windows MIDI Services loopback pair once:
// midi loopback create --name-a "Command8 MCU A" --name-b "Command8 MCU B"
// The bridge uses side A; point the DAW's Mackie Control at side B (in + out).
// midi loopback create --name-a "Command8 MCU Bridge" --name-b "Command8 MCU DAW"
// The bridge takes the "Bridge" end; point the DAW's Mackie Control at the
// "DAW" end (in + out). Leave the "Command|8 Bridge" device ports themselves
// disabled in the DAW -- those belong to this process, and a DAW holding them
// stops it opening the surface at all.
//
// ./command8-mackie [--mcu-port <match>] [--mcu-recv <match>]
// [--mcu-send <match>] [--port <device-match>]
Expand Down Expand Up @@ -83,8 +86,8 @@ int main(int argc, char** argv) {
#ifdef _WIN32
std::fprintf(stderr, "Could not open the MCU ports ('%s' / '%s'). Create "
"the loopback pair first:\n midi loopback create --name-a "
"\"Command8 MCU A\" --name-b \"Command8 MCU B\"\n(or create "
"cables in loopMIDI) and retry.\n",
"\"Command8 MCU Bridge\" --name-b \"Command8 MCU DAW\"\n"
"(or create cables in loopMIDI) and retry.\n",
recv_match.c_str(), send_match.c_str());
#else
std::fprintf(stderr, "Could not open an MCU port matching '%s'. Load "
Expand Down
Loading
Loading