diff --git a/.gitignore b/.gitignore index cc9d41e..8369263 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /build/ +/build-*/ *.o *.a compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a95dfb..afc26cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() @@ -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 @@ -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}) diff --git a/README.md b/README.md index 35aa41b..7894e47 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,23 @@ 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 @@ -211,12 +228,20 @@ 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 diff --git a/src/mackie/mackie_backend.hpp b/src/mackie/mackie_backend.hpp index dc5e970..5d61a83 100644 --- a/src/mackie/mackie_backend.hpp +++ b/src/mackie/mackie_backend.hpp @@ -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"; diff --git a/src/mackie/mackie_main.cpp b/src/mackie/mackie_main.cpp index 527c4a1..f9f03eb 100644 --- a/src/mackie/mackie_main.cpp +++ b/src/mackie/mackie_main.cpp @@ -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 ] [--mcu-recv ] // [--mcu-send ] [--port ] @@ -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 " diff --git a/src/mcu/c8_mcu.c b/src/mcu/c8_mcu.c new file mode 100644 index 0000000..e9454f4 --- /dev/null +++ b/src/mcu/c8_mcu.c @@ -0,0 +1,489 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include "c8_mcu.h" + +#include + +/* ---- Command|8 native protocol ------------------------------------------ + * Mirrors protocol.hpp. Kept as literals here rather than shared with the C++ + * header because this module must build with no C++ in sight; test_mcu.cpp + * includes both and asserts they agree, so a drift is a test failure. + */ +#define C8_HEARTBEAT_NOTE 0 +#define C8_HEARTBEAT_VEL 127 +#define C8_VEL_ON 0x40 /* velocity bit 6 = pressed / LED-on */ +#define C8_SUBID_MASK 0x3F +#define C8_STRIP_MASK 0x07 +#define C8_STRIP_SUBID_MAX 7 + +#define C8_NOTE_SELECT 0 +#define C8_NOTE_SOLO 2 +#define C8_NOTE_MUTE 3 +#define C8_NOTE_FADER_TOUCH 5 + +#define C8_NOTE_SELECT_GREEN_LED 1 +#define C8_NOTE_RECARM_LED 4 + +#define C8_ENCODER_CC_BASE 64 +#define C8_ENCODER_CC_COUNT 8 +#define C8_ENC_RIGHT 0x41 +#define C8_ENC_LEFT 0x3F +#define C8_FADER_CC_MAX 63 + +#define C8_METER_NOTE_BASE 64 + +#define C8_DIGIDESIGN_MFR 0x13 +#define C8_RING_DEVICE 0x01 +#define C8_RING_CMD 0x00 +#define C8_LCD_CMD 0x40 +#define C8_LCD_CHANNEL_CELL_BASE 0x10 /* bottom row (channel names) */ +#define C8_LCD_STATUS_CELL_BASE 0x00 /* top row */ + +/* ---- Mackie Control note map --------------------------------------------- */ + +#define N_REC 0x00 +#define N_SOLO 0x08 +#define N_MUTE 0x10 +#define N_SELECT 0x18 + +#define N_PLAY 0x5E +#define N_STOP 0x5D +#define N_REC_BTN 0x5F +#define N_REW 0x5B +#define N_FFWD 0x5C +#define N_CYCLE 0x56 + +#define VPOT_CC 0x10 +#define VPOT_LED_CC 0x30 + +#define N_SEND 0x29 +#define N_PAN 0x2A +#define N_PLUGIN 0x2B +#define N_EQ 0x2C +#define N_INST 0x2D + +#define N_FLIP 0x32 +#define N_BANK_L 0x2E +#define N_BANK_R 0x2F +#define N_CHAN_L 0x30 +#define N_CHAN_R 0x31 +#define N_CUR_UP 0x60 +#define N_CUR_DN 0x61 +#define N_CUR_L 0x62 +#define N_CUR_R 0x63 +#define N_ZOOM 0x64 + +/* Navigation cluster, all at subid 13. Bank/Nudge/Zoom are a local radio group; + * ScrlBack/ScrlFwd and ViewUp/Down translate according to the active mode. */ +#define NAV_SUBID 13 +#define BTN_BANK 2 +#define BTN_NUDGE 3 +#define BTN_ZOOM 4 +#define BTN_SCRL_BACK 5 +#define BTN_SCRL_FWD 6 +#define BTN_VIEW_UP 7 +#define BTN_VIEW_DN 8 + +/* Command|8 (note, subid) -> MCU note. RecSel and the nav cluster are handled + * separately in from_surface_note. A flat array beats a std::map here: twelve + * entries scanned linearly is faster than a tree walk and costs no heap. */ +typedef struct { uint8_t note, subid, mcu; } btn_map_t; + +static const btn_map_t kBtnToMcu[] = { + {10, 14, N_PLAY}, { 9, 14, N_STOP}, {11, 14, N_REC_BTN}, + { 3, 14, N_CYCLE}, { 7, 14, N_REW}, { 8, 14, N_FFWD}, + /* V-pot assignment (Pan/Send/Insert/EQ/Dynamics) */ + { 0, 10, N_PAN}, { 1, 10, N_SEND}, { 2, 10, N_PLUGIN}, + { 0, 11, N_EQ}, { 1, 11, N_INST}, + /* Flip: an independent toggle, not part of the nav mode group */ + { 0, 13, N_FLIP}, +}; + +/* MCU note -> Command|8 LED (note, subid). The nav mode LEDs are driven + * locally, not from DAW feedback, so they are absent here. */ +typedef struct { uint8_t mcu, note, subid; } led_map_t; + +static const led_map_t kMcuToLed[] = { + {N_PLAY, 10, 14}, {N_STOP, 9, 14}, {N_REC_BTN, 11, 14}, {N_CYCLE, 3, 14}, + {N_PAN, 0, 10}, {N_SEND, 1, 10}, {N_PLUGIN, 2, 10}, {N_EQ, 0, 11}, + {N_INST, 1, 11}, {N_FLIP, 0, 13}, +}; + +#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0])) + +/* ---- helpers ------------------------------------------------------------- */ + +static int clampi(int v, int lo, int hi) { + return v < lo ? lo : (v > hi ? hi : v); +} + +static void emit_daw(c8_mcu_t *m, const uint8_t *b, size_t n) { + if (m->to_daw) m->to_daw(m->user, b, n); +} + +static void emit_surface(c8_mcu_t *m, const uint8_t *b, size_t n) { + if (m->to_surface) m->to_surface(m->user, b, n); +} + +/* --- towards the DAW --- */ + +static void send_note(c8_mcu_t *m, int note, bool on) { + const uint8_t b[3] = {0x90, (uint8_t)(note & 0x7F), (uint8_t)(on ? 127 : 0)}; + emit_daw(m, b, sizeof(b)); +} + +static void send_cc(c8_mcu_t *m, int cc, int value) { + const uint8_t b[3] = {0xB0, (uint8_t)(cc & 0x7F), (uint8_t)(value & 0x7F)}; + emit_daw(m, b, sizeof(b)); +} + +static void send_pitch(c8_mcu_t *m, int channel, int value) { + const int v = clampi(value, -8192, 8191) + 8192; /* 0..16383 */ + const uint8_t b[3] = {(uint8_t)(0xE0 | (channel & 0x0F)), + (uint8_t)(v & 0x7F), (uint8_t)((v >> 7) & 0x7F)}; + emit_daw(m, b, sizeof(b)); +} + +/* --- towards the surface --- */ + +static void surface_button_led(c8_mcu_t *m, uint8_t note, uint8_t subid, bool on) { + const uint8_t s = (uint8_t)(subid & C8_SUBID_MASK); + const uint8_t b[3] = {0x90, note, (uint8_t)(on ? (C8_VEL_ON | s) : s)}; + emit_surface(m, b, sizeof(b)); +} + +static void surface_fader(c8_mcu_t *m, int strip, int value7) { + const uint8_t b[3] = {0xB0, (uint8_t)(strip & C8_STRIP_MASK), + (uint8_t)(value7 & 0x7F)}; + emit_surface(m, b, sizeof(b)); +} + +static void surface_meter(c8_mcu_t *m, int strip, int level_bits) { + const uint8_t b[3] = {0x90, + (uint8_t)(C8_METER_NOTE_BASE | (level_bits & 0x3F)), + (uint8_t)(strip & C8_STRIP_MASK)}; + emit_surface(m, b, sizeof(b)); +} + +/* Ring LED bitfield: low 7 bits drive LEDs 1-7 in the data byte, bits 7-10 are + * packed into the high nibble of the address byte. */ +static void surface_ring(c8_mcu_t *m, int encoder, uint16_t led_bits) { + const uint8_t addr = (uint8_t)((encoder & C8_STRIP_MASK) | + (((led_bits >> 7) << 3) & 0x78)); + const uint8_t b[7] = {0xF0, C8_DIGIDESIGN_MFR, C8_RING_DEVICE, C8_RING_CMD, + addr, (uint8_t)(led_bits & 0x7F), 0xF7}; + emit_surface(m, b, sizeof(b)); +} + +static void surface_lcd(c8_mcu_t *m, uint8_t cell, const uint8_t *text) { + uint8_t b[7 + C8_MCU_LCD_WIDTH + 1]; + b[0] = 0xF0; + b[1] = C8_DIGIDESIGN_MFR; + b[2] = C8_RING_DEVICE; + b[3] = C8_LCD_CMD; + b[4] = (uint8_t)(cell & 0x7F); + b[5] = 0x7F; + b[6] = 0x00; + for (int i = 0; i < C8_MCU_LCD_WIDTH; i++) { + const uint8_t c = text[i]; + b[7 + i] = (c >= 0x20 && c <= 0x7E) ? c : 0x20; + } + b[7 + C8_MCU_LCD_WIDTH] = 0xF7; + emit_surface(m, b, sizeof(b)); +} + +/* ---- meters -------------------------------------------------------------- + * Q8 rows in, Command|8 bitfield out. Rounds rather than truncates: truncation + * lost the top row (11/12 of full scale lit 5 of 6 LEDs). Any non-zero signal + * lights at least one LED, so quiet material is distinct from silence, and the + * fill runs from the high bits down because the meter is addressed top-down. + */ +static int rows_q8_to_bits(uint32_t rows_q8) { + /* The C++ path tests rows < 0.05, which in Q8 is rows_q8 < 12.8; integer + * levels make that rows_q8 <= 12. */ + if (rows_q8 < 13) return 0; + const int n = clampi((int)((rows_q8 + 128) >> 8), 1, C8_MCU_METER_ROWS); + return ((1 << n) - 1) << (C8_MCU_METER_ROWS - n); +} + +static uint32_t meter_decayed(const c8_mcu_t *m, int strip, uint32_t now_ms) { + uint32_t cur = m->meter_level_q8[strip]; + if (m->meter_decay_ms == 0 || cur == 0 || !m->meter_t_valid) return cur; + + const uint32_t dt_ms = now_ms - m->meter_t_ms[strip]; /* wrap-safe */ + /* Full scale (METER_ROWS rows) falls to zero in meter_decay_ms. */ + const uint32_t drop = + (dt_ms * (C8_MCU_METER_ROWS << 8)) / m->meter_decay_ms; + return drop >= cur ? 0 : cur - drop; +} + +/* ---- lifecycle ----------------------------------------------------------- */ + +static void paint_nav_leds(c8_mcu_t *m) { + surface_button_led(m, BTN_BANK, NAV_SUBID, m->nav_mode == C8_NAV_BANK); + surface_button_led(m, BTN_NUDGE, NAV_SUBID, m->nav_mode == C8_NAV_NUDGE); + surface_button_led(m, BTN_ZOOM, NAV_SUBID, m->nav_mode == C8_NAV_ZOOM); +} + +void c8_mcu_init(c8_mcu_t *m, c8_mcu_emit_fn to_daw, c8_mcu_emit_fn to_surface, + void *user) { + memset(m, 0, sizeof(*m)); + m->to_daw = to_daw; + m->to_surface = to_surface; + m->user = user; + m->nav_mode = C8_NAV_BANK; + m->meter_decay_ms = C8_MCU_DEFAULT_METER_DECAY_MS; + memset(m->lcd, ' ', sizeof(m->lcd)); +} + +void c8_mcu_start(c8_mcu_t *m) { + memset(m->lcd, ' ', sizeof(m->lcd)); + m->nav_mode = C8_NAV_BANK; + paint_nav_leds(m); +} + +/* ---- Command|8 -> MCU ----------------------------------------------------- */ + +static void set_nav_mode(c8_mcu_t *m, int mode) { + if (mode == m->nav_mode) return; + const bool was_zoom = (m->nav_mode == C8_NAV_ZOOM); + const bool now_zoom = (mode == C8_NAV_ZOOM); + m->nav_mode = (uint8_t)mode; + /* Entering or leaving Zoom toggles the DAW's Zoom modifier so the arrows + * zoom rather than move the cursor. */ + if (now_zoom != was_zoom) { + send_note(m, N_ZOOM, true); + send_note(m, N_ZOOM, false); + } + paint_nav_leds(m); +} + +static void on_button(c8_mcu_t *m, uint8_t note, uint8_t subid, bool pressed) { + if (note == 3 && subid == 12) { /* RecSel: arm the selected track(s) */ + for (int ch = 0; ch < C8_MCU_STRIPS; ch++) + if (m->selected & (1u << ch)) send_note(m, N_REC + ch, pressed); + return; + } + + if (subid == NAV_SUBID) { + switch (note) { + case BTN_BANK: if (pressed) set_nav_mode(m, C8_NAV_BANK); return; + case BTN_NUDGE: if (pressed) set_nav_mode(m, C8_NAV_NUDGE); return; + case BTN_ZOOM: if (pressed) set_nav_mode(m, C8_NAV_ZOOM); return; + case BTN_SCRL_BACK: /* '<': per-mode step left */ + send_note(m, m->nav_mode == C8_NAV_BANK ? N_BANK_L + : m->nav_mode == C8_NAV_NUDGE ? N_CHAN_L + : N_CUR_L, pressed); + return; + case BTN_SCRL_FWD: /* '>': per-mode step right */ + send_note(m, m->nav_mode == C8_NAV_BANK ? N_BANK_R + : m->nav_mode == C8_NAV_NUDGE ? N_CHAN_R + : N_CUR_R, pressed); + return; + case BTN_VIEW_UP: send_note(m, N_CUR_UP, pressed); return; + case BTN_VIEW_DN: send_note(m, N_CUR_DN, pressed); return; + default: break; /* Flip (note 0), MstrFadrs (note 1) fall through */ + } + } + + for (size_t i = 0; i < ARRAY_LEN(kBtnToMcu); i++) { + if (kBtnToMcu[i].note == note && kBtnToMcu[i].subid == subid) { + send_note(m, kBtnToMcu[i].mcu, pressed); + return; + } + } +} + +static void on_surface_note(c8_mcu_t *m, uint8_t note, uint8_t vel) { + /* Heartbeat echo. Checked before anything else, exactly as decode_note_on + * does, because note 0 doubles as Select and velocity 127 would otherwise + * decode as a Select press on a nonexistent strip 63. Never reply: the + * device echoes what it receives, so a reply builds a loop. */ + if (note == C8_HEARTBEAT_NOTE && vel == C8_HEARTBEAT_VEL) return; + + const uint8_t subid = (uint8_t)(vel & C8_SUBID_MASK); + const bool pressed = (vel & C8_VEL_ON) != 0; + + if (subid <= C8_STRIP_SUBID_MAX) { + switch (note) { + case C8_NOTE_FADER_TOUCH: + return; /* the MCU backend does not forward fader touch */ + case C8_NOTE_SELECT: send_note(m, N_SELECT + (subid & 7), pressed); return; + case C8_NOTE_SOLO: send_note(m, N_SOLO + (subid & 7), pressed); return; + case C8_NOTE_MUTE: send_note(m, N_MUTE + (subid & 7), pressed); return; + default: break; + } + } + on_button(m, note, subid, pressed); +} + +static void on_surface_cc(c8_mcu_t *m, uint8_t cc, uint8_t val) { + if (cc >= C8_ENCODER_CC_BASE && + cc < C8_ENCODER_CC_BASE + C8_ENCODER_CC_COUNT) { + const int delta = (val == C8_ENC_RIGHT) ? 1 : (val == C8_ENC_LEFT) ? -1 : 0; + if (delta == 0) return; /* not a detent: ignore rather than guess */ + const int enc = cc - C8_ENCODER_CC_BASE; + /* MCU relative-encoder convention: bit 6 set means anticlockwise. */ + send_cc(m, VPOT_CC + (enc & 7), delta > 0 ? 1 : (0x40 | 1)); + return; + } + + if (cc <= C8_FADER_CC_MAX) { + const int fader = cc & C8_STRIP_MASK; + const int hi = (cc >> 3) & C8_STRIP_MASK; /* 3 extra low-order bits */ + const int value10 = ((int)val << 3) | hi; /* 0..1023 */ + /* value10/1023 scaled to 0..16383, then biased to MCU's signed range. + * Written as (2N + d)/(2d) so it rounds like lround(). */ + const int pitch14 = (int)(((int32_t)value10 * 16383 * 2 + 1023) / 2046); + send_pitch(m, fader & 7, pitch14 - 8192); + } +} + +void c8_mcu_from_surface(c8_mcu_t *m, const uint8_t *msg, size_t len) { + if (len < 3 || !msg) return; + switch (msg[0] & 0xF0) { + case 0x90: on_surface_note(m, msg[1], msg[2]); break; + case 0xB0: on_surface_cc(m, msg[1], msg[2]); break; + default: break; + } +} + +/* ---- MCU -> Command|8 ----------------------------------------------------- */ + +static void lcd_sysex(c8_mcu_t *m, const uint8_t *d, size_t len) { + /* F0 00 00 66 12 F7 */ + if (len < 8 || d[1] != 0x00 || d[2] != 0x00 || d[3] != 0x66 || d[5] != 0x12) + return; + + const int offset = d[6] & 0x7F; + const int n = (int)len - 8; /* strip F0, 6 header bytes and trailing F7 */ + + /* Which 7-character cells the write touched. 2 rows x 8 cells fits a + * 16-bit mask, which replaces the std::set the host version allocates. */ + uint16_t dirty = 0; + for (int i = 0; i < n; i++) { + const int p = offset + i; + if (p < 0 || p >= C8_MCU_LCD_CELLS) continue; + const uint8_t c = d[7 + i]; + m->lcd[p] = (c >= 0x20 && c <= 0x7E) ? c : ' '; + const int line = p / 56, col = (p % 56) / 7; + if (col < 8) dirty |= (uint16_t)(1u << (line * 8 + col)); + } + + for (int line = 0; line < 2; line++) { + for (int col = 0; col < 8; col++) { + if (!(dirty & (1u << (line * 8 + col)))) continue; + const uint8_t *cell = &m->lcd[line * 56 + col * 7]; + surface_lcd(m, (uint8_t)((line == 0 ? C8_LCD_STATUS_CELL_BASE + : C8_LCD_CHANNEL_CELL_BASE) + + (col & C8_STRIP_MASK)), + cell); + } + } +} + +void c8_mcu_from_daw(c8_mcu_t *m, const uint8_t *msg, size_t len) { + if (!msg || len == 0) return; + + if (msg[0] == 0xF0) { + lcd_sysex(m, msg, len); + return; + } + + const int type = msg[0] & 0xF0, ch = msg[0] & 0x0F; + switch (type) { + case 0xE0: { /* pitchbend = motor fader position */ + if (len < 3) break; + const int v = ((int)msg[2] << 7) | msg[1]; /* 0..16383 */ + if (ch < C8_MCU_STRIPS) { + /* v/16383 scaled to 0..127, rounding like lround(). */ + const int val = clampi((int)(((int32_t)v * 127 * 2 + 16383) / 32766), + 0, 127); + surface_fader(m, ch, val); + } + break; + } + case 0xD0: { /* channel pressure = meter level */ + if (len < 2) break; + const int strip = (msg[1] >> 4) & 7; + const int level = msg[1] & 0x0F; /* 0..12 in practice */ + /* min(1.0, level/12) of METER_ROWS rows, in Q8: level * 128. */ + uint32_t rows_q8 = (uint32_t)level * 128u; + const uint32_t full = (uint32_t)C8_MCU_METER_ROWS << 8; + if (rows_q8 > full) rows_q8 = full; + /* The host value is a PEAK: rise to it at once and let tick() decay + * it. Unlike the host version this does not first decay to "now" -- + * from_daw has no clock, and the next tick applies whatever falloff + * was missed, which at 10 Hz is at most 100 ms of it. With decay + * disabled the level simply follows the host. */ + const uint32_t cur = m->meter_decay_ms ? m->meter_level_q8[strip] : 0; + m->meter_level_q8[strip] = (uint16_t)(rows_q8 > cur ? rows_q8 : cur); + break; + } + case 0xB0: { /* V-pot ring LEDs */ + if (len < 3) break; + const int cc = msg[1]; + if (cc < VPOT_LED_CC || cc > VPOT_LED_CC + 7) break; + const int enc = cc - VPOT_LED_CC; + const int pos = msg[2] & 0x0F, mode = (msg[2] >> 4) & 0x03; + if (pos == 0) { + surface_ring(m, enc, 0); + } else if (mode == 2) { + /* wrap/fill: pos of 11 LEDs, lit as a thermometer */ + const int p = clampi(pos, 0, C8_MCU_RING_LEDS); + surface_ring(m, enc, (uint16_t)((1u << p) - 1u)); + } else { + /* single dot (pan position): pos 1..11 -> LED 0..10 */ + const int p = clampi(pos - 1, 0, C8_MCU_RING_LEDS - 1); + surface_ring(m, enc, (uint16_t)(1u << p)); + } + break; + } + case 0x90: + case 0x80: { /* button LEDs */ + if (len < 3) break; + const bool on = (type == 0x90 && msg[2] > 0); + const int n = msg[1]; + if (n >= N_REC && n < N_REC + 8) { + surface_button_led(m, C8_NOTE_RECARM_LED, (uint8_t)(n - N_REC), on); + } else if (n >= N_SOLO && n < N_SOLO + 8) { + surface_button_led(m, C8_NOTE_SOLO, (uint8_t)(n - N_SOLO), on); + } else if (n >= N_MUTE && n < N_MUTE + 8) { + surface_button_led(m, C8_NOTE_MUTE, (uint8_t)(n - N_MUTE), on); + } else if (n >= N_SELECT && n < N_SELECT + 8) { + const int c = n - N_SELECT; + if (on) m->selected |= (uint8_t)(1u << c); + else m->selected &= (uint8_t)~(1u << c); + surface_button_led(m, C8_NOTE_SELECT_GREEN_LED, (uint8_t)c, on); + } else { + for (size_t i = 0; i < ARRAY_LEN(kMcuToLed); i++) { + if (kMcuToLed[i].mcu == n) { + surface_button_led(m, kMcuToLed[i].note, + kMcuToLed[i].subid, on); + break; + } + } + } + break; + } + default: + break; + } +} + +/* ---- meter ballistics ----------------------------------------------------- */ + +void c8_mcu_tick(c8_mcu_t *m, uint32_t now_ms) { + for (int i = 0; i < C8_MCU_STRIPS; i++) { + const uint32_t cur = meter_decayed(m, i, now_ms); + m->meter_level_q8[i] = (uint16_t)cur; + m->meter_t_ms[i] = now_ms; + + const int bits = rows_q8_to_bits(cur); + const bool sent = (m->meter_sent & (1u << i)) != 0; + if (sent && bits == m->meter_bits[i]) continue; /* unchanged */ + m->meter_bits[i] = (uint8_t)bits; + m->meter_sent |= (uint8_t)(1u << i); + surface_meter(m, i, bits); + } + m->meter_t_valid = true; +} diff --git a/src/mcu/c8_mcu.h b/src/mcu/c8_mcu.h new file mode 100644 index 0000000..2a850b4 --- /dev/null +++ b/src/mcu/c8_mcu.h @@ -0,0 +1,105 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * Command|8 <-> Mackie Control translation, as a freestanding C99 module. + * + * This is the same logic MackieBackend implements on the host, with every + * dependency removed: no allocation, no floating point, no threads, no C++ + * runtime, no MIDI backend. Bytes in, bytes out through two callbacks. That is + * what lets it run inside the dongle firmware on an RP2040 -- where core 1 is + * bit-banging USB and a malloc or a blocking call in the data path would show + * up as a dropped bus -- while still being compiled and tested on the desktop, + * which is the only place debugging it is cheap. + * + * Fixed point rather than double is not an optimisation. The Cortex-M0+ has no + * FPU, but more importantly integer arithmetic is exactly reproducible, so the + * unit tests pin real values instead of tolerances. Every conversion below is + * chosen to round identically to the lround() it replaces; where the C++ path + * divides by a constant the integer form is written as (2*N + d) / (2*d) so the + * half-way case lands the same way. + * + * The module holds all its state in c8_mcu_t. Nothing is static, so a test can + * run many independent instances, and the caller owns the memory. + */ +#ifndef C8_MCU_H +#define C8_MCU_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define C8_MCU_STRIPS 8 +#define C8_MCU_RING_LEDS 11 +#define C8_MCU_METER_ROWS 6 +#define C8_MCU_LCD_CELLS 112 /* Mackie LCD is 2 rows x 56 columns */ +#define C8_MCU_LCD_WIDTH 7 /* characters per Command|8 LCD cell */ + +/* Meter falloff: milliseconds for a full-scale meter to reach zero. Hosts send + * meter levels sparsely (Reaper only when the quantised 0-12 level changes, + * about 1 Hz) and expect the surface to supply ballistics in between, the way + * real MCU hardware does. 0 disables it and follows the host exactly. */ +#define C8_MCU_DEFAULT_METER_DECAY_MS 1200 + +/* Longest message the module ever emits: an LCD cell SysEx, 7 header + 7 text + * + F7 = 15 bytes. Sized with headroom so a caller can use it for a buffer. */ +#define C8_MCU_MAX_MSG 16 + +/* Emitted messages are complete MIDI messages, never split across calls. */ +typedef void (*c8_mcu_emit_fn)(void *user, const uint8_t *msg, size_t len); + +/* Bank/Nudge/Zoom form a radio group local to the surface -- the DAW has no + * equivalent, so the mode lives here and retargets the arrow buttons. */ +typedef enum { + C8_NAV_BANK = 0, + C8_NAV_NUDGE = 1, + C8_NAV_ZOOM = 2, +} c8_nav_mode_t; + +typedef struct { + c8_mcu_emit_fn to_daw; /* Mackie Control messages towards the DAW */ + c8_mcu_emit_fn to_surface; /* Command|8 native messages towards the unit */ + void *user; + + uint8_t nav_mode; /* c8_nav_mode_t */ + uint8_t selected; /* bitmask of selected strips, bit N = strip N */ + uint8_t lcd[C8_MCU_LCD_CELLS]; + + /* Meter ballistics. Levels are Q8 fixed point in meter rows, so full scale + * is C8_MCU_METER_ROWS << 8. */ + uint16_t meter_level_q8[C8_MCU_STRIPS]; + uint8_t meter_bits[C8_MCU_STRIPS]; + uint8_t meter_sent; /* bitmask: has meter_bits been emitted yet */ + uint32_t meter_t_ms[C8_MCU_STRIPS]; + uint32_t meter_decay_ms; + bool meter_t_valid; /* false until the first tick supplies a clock */ +} c8_mcu_t; + +/* Either callback may be NULL, in which case that direction is discarded. */ +void c8_mcu_init(c8_mcu_t *m, c8_mcu_emit_fn to_daw, c8_mcu_emit_fn to_surface, + void *user); + +/* Paint the initial surface state (the default nav-mode LED). Call once the + * surface is known to be online, not before -- LEDs sent to a sleeping unit + * are lost. */ +void c8_mcu_start(c8_mcu_t *m); + +/* One complete MIDI message from the Command|8. Emits via to_daw. */ +void c8_mcu_from_surface(c8_mcu_t *m, const uint8_t *msg, size_t len); + +/* One complete MIDI message from the DAW's Mackie Control. Emits via + * to_surface. */ +void c8_mcu_from_daw(c8_mcu_t *m, const uint8_t *msg, size_t len); + +/* Advance meter falloff and emit any rows that changed. now_ms is a free-running + * millisecond clock; only differences matter, so wrap is harmless. Call at + * roughly 10 Hz or better. */ +void c8_mcu_tick(c8_mcu_t *m, uint32_t now_ms); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* C8_MCU_H */ diff --git a/tests/test_mcu.cpp b/tests/test_mcu.cpp new file mode 100644 index 0000000..f7de210 --- /dev/null +++ b/tests/test_mcu.cpp @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Framework-free unit tests for the freestanding C translator in src/mcu/. +// +// This module is the one piece of the project that will run on bare metal +// inside the dongle, where a wrong byte is expensive to find: there is no +// debugger in the data path, because halting a core stops the bit-banged USB +// host and drops the bus. So the contract is pinned here instead, on the +// desktop, and the firmware only ever ships code that passed. +// +// Two kinds of check: +// 1. Behaviour -- exact bytes emitted for a given input. +// 2. Agreement -- the C module's private copies of the Command|8 protocol +// constants still match protocol.hpp. The C module cannot +// include the C++ header, so drift is possible; this file +// includes both and makes drift a test failure. +#include +#include +#include +#include +#include +#include + +#include "mcu/c8_mcu.h" +#include "protocol.hpp" + +using namespace command8; + +// MCU note-map bases, repeated here so the tests state the expected wire bytes +// independently of the module's own private constants. +static constexpr int N_REC_TEST = 0x00; + +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) + +// --- capture harness ------------------------------------------------------- + +using Msg = std::vector; + +struct Capture { + std::vector daw; + std::vector surface; + void clear() { daw.clear(); surface.clear(); } +}; + +static void cap_daw(void* u, const uint8_t* b, size_t n) { + static_cast(u)->daw.emplace_back(b, b + n); +} +static void cap_surface(void* u, const uint8_t* b, size_t n) { + static_cast(u)->surface.emplace_back(b, b + n); +} + +struct Fixture { + c8_mcu_t m{}; + Capture cap; + Fixture() { c8_mcu_init(&m, cap_daw, cap_surface, &cap); } + + void from_surface(std::initializer_list bytes) { + std::vector v; + for (int b : bytes) v.push_back(static_cast(b)); + c8_mcu_from_surface(&m, v.data(), v.size()); + } + void from_daw(std::initializer_list bytes) { + std::vector v; + for (int b : bytes) v.push_back(static_cast(b)); + c8_mcu_from_daw(&m, v.data(), v.size()); + } +}; + +static Msg msg(std::initializer_list bytes) { + Msg v; + for (int b : bytes) v.push_back(static_cast(b)); + return v; +} + +static void dump(const char* what, const std::vector& got) { + std::printf(" %s: %zu message(s)\n", what, got.size()); + for (const auto& m : got) { + std::printf(" "); + for (uint8_t b : m) std::printf(" %02X", b); + std::printf("\n"); + } +} + +// Assert the captured messages are exactly `want`, printing both on mismatch. +static void expect(const char* what, const std::vector& got, + const std::vector& want, int line) { + if (got == want) return; + std::printf("FAIL %s:%d %s\n", __FILE__, line, what); + dump("got ", got); + dump("want ", want); + ++g_fail; +} +#define EXPECT_DAW(f, ...) expect("to DAW", (f).cap.daw, {__VA_ARGS__}, __LINE__) +#define EXPECT_SURFACE(f, ...) \ + expect("to surface", (f).cap.surface, {__VA_ARGS__}, __LINE__) + +// --- 1. agreement with protocol.hpp --------------------------------------- +// +// The C module hardcodes these because it must build without any C++ header. +// If protocol.hpp ever changes one, this catches it at build time rather than +// on hardware. +static void test_constants_agree() { + // Reproduce the C module's private values. Keep in step with c8_mcu.c. + CHECK(HEARTBEAT_NOTE == 0); + CHECK(HEARTBEAT_VEL == 127); + CHECK(VEL_ON == 0x40); + CHECK(SUBID_MASK == 0x3F); + CHECK(STRIP_MASK == 0x07); + CHECK(STRIP_SUBID_MAX == 7); + CHECK(NOTE_SELECT == 0); + CHECK(NOTE_SOLO == 2); + CHECK(NOTE_MUTE == 3); + CHECK(NOTE_FADER_TOUCH == 5); + CHECK(NOTE_SELECT_GREEN_LED == 1); + CHECK(NOTE_RECARM_LED == 4); + CHECK(ENCODER_CC_BASE == 64); + CHECK(ENCODER_CC_COUNT == 8); + CHECK(ENC_RIGHT == 0x41); + CHECK(ENC_LEFT == 0x3F); + CHECK(FADER_CC_MAX == 63); + CHECK(METER_NOTE_BASE == 64); + CHECK(DIGIDESIGN_MFR == 0x13); + CHECK(RING_DEVICE == 0x01); + CHECK(RING_CMD == 0x00); + CHECK(LCD_CMD == 0x40); + CHECK(LCD_CELL_WIDTH == C8_MCU_LCD_WIDTH); + CHECK(LCD_CHANNEL_CELL_BASE == 0x10); + CHECK(LCD_STATUS_CELL_BASE == 0x00); +} + +// The C module must emit byte-for-byte what protocol.cpp's encoders produce, +// since the surface is the same hardware either way. +static void test_encoders_match_protocol_cpp() { + Fixture f; + + // Select LED on strip 3, driven by an MCU note-on. + f.from_daw({0x90, 0x18 + 3, 0x7F}); + const auto want_led = button_led(NOTE_SELECT_GREEN_LED, 3, true); + expect("select LED matches protocol.cpp", f.cap.surface, {want_led}, __LINE__); + + // Ring, single dot at pan centre. + f.cap.clear(); + f.from_daw({0xB0, 0x30, 0x06}); // mode 0, pos 6 -> LED 5 + const auto want_ring = encoder_ring(0, 1 << 5); + expect("ring dot matches protocol.cpp", f.cap.surface, {want_ring}, __LINE__); + + // Motor fader from a pitchbend. + f.cap.clear(); + f.from_daw({0xE2, 0x00, 0x40}); // channel 2, 0x2000 = 8192 + const auto want_fader = fader_position(2, 64); + expect("fader matches protocol.cpp", f.cap.surface, {want_fader}, __LINE__); +} + +// --- 2. Command|8 -> MCU --------------------------------------------------- + +static void test_heartbeat_is_swallowed() { + Fixture f; + // Note 0 velocity 127 is the heartbeat echo, not a Select press on strip + // 63. Forwarding it would spray junk at the DAW four times a second. + f.from_surface({0x90, 0, 127}); + EXPECT_DAW(f); + EXPECT_SURFACE(f); +} + +static void test_strip_buttons() { + Fixture f; + f.from_surface({0x90, NOTE_SELECT, VEL_ON | 5}); // Select 5 pressed + EXPECT_DAW(f, msg({0x90, 0x18 + 5, 127})); + + f.cap.clear(); + f.from_surface({0x90, NOTE_SELECT, 5}); // released + EXPECT_DAW(f, msg({0x90, 0x18 + 5, 0})); + + f.cap.clear(); + f.from_surface({0x90, NOTE_SOLO, VEL_ON | 2}); + EXPECT_DAW(f, msg({0x90, 0x08 + 2, 127})); + + f.cap.clear(); + f.from_surface({0x90, NOTE_MUTE, VEL_ON | 7}); + EXPECT_DAW(f, msg({0x90, 0x10 + 7, 127})); +} + +static void test_fader_touch_not_forwarded() { + Fixture f; + f.from_surface({0x90, NOTE_FADER_TOUCH, VEL_ON | 0}); + EXPECT_DAW(f); +} + +static void test_fader_scaling() { + Fixture f; + // Fader CC: low 3 bits of the CC carry extra position bits, so + // cc = (hi << 3) | fader and value10 = (val << 3) | hi. + // Bottom of travel -> pitchbend 0. + f.from_surface({0xB0, 0x00, 0x00}); + EXPECT_DAW(f, msg({0xE0, 0x00, 0x00})); + + // Top of travel: val 127, hi 7 -> value10 1023 -> full scale 16383. + f.cap.clear(); + f.from_surface({0xB0, (7 << 3) | 0, 0x7F}); + EXPECT_DAW(f, msg({0xE0, 0x7F, 0x7F})); + + // Fader 3 near mid travel: 512/1023 * 16383 = 8199.51, rounding to 8200. + // Slightly above centre because 512 is just over half of 1023. + f.cap.clear(); + f.from_surface({0xB0, (0 << 3) | 3, 0x40}); // val 64, hi 0 -> value10 512 + const int expect14 = 8200; + EXPECT_DAW(f, msg({0xE3, expect14 & 0x7F, (expect14 >> 7) & 0x7F})); +} + +// The integer fader conversion must round exactly as the double path did. +static void test_fader_rounding_matches_double() { + for (int value10 = 0; value10 <= 1023; ++value10) { + const int with_int = (value10 * 16383 * 2 + 1023) / 2046; + const double v = value10 / 1023.0; + const int with_double = static_cast(std::lround(v * 16383.0)); + if (with_int != with_double) { + std::printf("FAIL %s:%d fader rounding differs at value10=%d " + "(int %d, double %d)\n", + __FILE__, __LINE__, value10, with_int, with_double); + ++g_fail; + return; + } + } +} + +static void test_encoders() { + Fixture f; + f.from_surface({0xB0, ENCODER_CC_BASE + 2, ENC_RIGHT}); + EXPECT_DAW(f, msg({0xB0, 0x10 + 2, 1})); + + f.cap.clear(); + f.from_surface({0xB0, ENCODER_CC_BASE + 2, ENC_LEFT}); + EXPECT_DAW(f, msg({0xB0, 0x10 + 2, 0x41})); + + // A value that is neither detent is not a movement; guessing a direction + // here would make the encoder drift. + f.cap.clear(); + f.from_surface({0xB0, ENCODER_CC_BASE + 2, 0x00}); + EXPECT_DAW(f); +} + +static void test_transport_buttons() { + Fixture f; + f.from_surface({0x90, 10, VEL_ON | 14}); // Play + EXPECT_DAW(f, msg({0x90, 0x5E, 127})); + + f.cap.clear(); + f.from_surface({0x90, 9, VEL_ON | 14}); // Stop + EXPECT_DAW(f, msg({0x90, 0x5D, 127})); + + f.cap.clear(); + f.from_surface({0x90, 42, VEL_ON | 20}); // unmapped: silence, not junk + EXPECT_DAW(f); +} + +// --- 3. navigation mode ---------------------------------------------------- + +static void test_nav_mode_retargets_arrows() { + Fixture f; + c8_mcu_start(&f.m); + // Default is Bank: only the Bank LED lights. + EXPECT_SURFACE(f, msg({0x90, 2, VEL_ON | 13}), + msg({0x90, 3, 13}), + msg({0x90, 4, 13})); + + // In Bank mode '>' is Bank Right. + f.cap.clear(); + f.from_surface({0x90, 6, VEL_ON | 13}); + EXPECT_DAW(f, msg({0x90, 0x2F, 127})); + + // Switch to Nudge: LEDs repaint, no Zoom toggle (neither side is Zoom). + f.cap.clear(); + f.from_surface({0x90, 3, VEL_ON | 13}); + EXPECT_DAW(f); + EXPECT_SURFACE(f, msg({0x90, 2, 13}), + msg({0x90, 3, VEL_ON | 13}), + msg({0x90, 4, 13})); + + // Now '>' is Channel Right instead. + f.cap.clear(); + f.from_surface({0x90, 6, VEL_ON | 13}); + EXPECT_DAW(f, msg({0x90, 0x31, 127})); + + // Entering Zoom pulses the DAW's Zoom modifier so the arrows zoom. + f.cap.clear(); + f.from_surface({0x90, 4, VEL_ON | 13}); + EXPECT_DAW(f, msg({0x90, 0x64, 127}), msg({0x90, 0x64, 0})); + + // And '>' becomes cursor right. + f.cap.clear(); + f.from_surface({0x90, 6, VEL_ON | 13}); + EXPECT_DAW(f, msg({0x90, 0x63, 127})); + + // Re-pressing the active mode is a no-op, not a second Zoom pulse. + f.cap.clear(); + f.from_surface({0x90, 4, VEL_ON | 13}); + EXPECT_DAW(f); + EXPECT_SURFACE(f); +} + +static void test_recsel_arms_selected_tracks() { + Fixture f; + // Nothing selected: RecSel does nothing rather than arming everything. + f.from_surface({0x90, 3, VEL_ON | 12}); + EXPECT_DAW(f); + + // DAW reports strips 1 and 4 selected. + f.cap.clear(); + f.from_daw({0x90, 0x18 + 1, 0x7F}); + f.from_daw({0x90, 0x18 + 4, 0x7F}); + + f.cap.clear(); + f.from_surface({0x90, 3, VEL_ON | 12}); + EXPECT_DAW(f, msg({0x90, N_REC_TEST + 1, 127}), + msg({0x90, N_REC_TEST + 4, 127})); + + // Deselecting strip 1 removes it from the arm set. + f.from_daw({0x90, 0x18 + 1, 0x00}); + f.cap.clear(); + f.from_surface({0x90, 3, VEL_ON | 12}); + EXPECT_DAW(f, msg({0x90, N_REC_TEST + 4, 127})); +} + +// --- 4. MCU -> Command|8 --------------------------------------------------- + +static void test_led_feedback() { + Fixture f; + f.from_daw({0x90, 0x00 + 6, 0x7F}); // rec-arm 6 on + EXPECT_SURFACE(f, msg({0x90, NOTE_RECARM_LED, VEL_ON | 6})); + + f.cap.clear(); + f.from_daw({0x80, 0x08 + 1, 0x00}); // note-off = solo 1 off + EXPECT_SURFACE(f, msg({0x90, NOTE_SOLO, 1})); + + f.cap.clear(); + f.from_daw({0x90, 0x10 + 0, 0x00}); // velocity 0 = mute 0 off + EXPECT_SURFACE(f, msg({0x90, NOTE_MUTE, 0})); + + f.cap.clear(); + f.from_daw({0x90, 0x5E, 0x7F}); // Play LED + EXPECT_SURFACE(f, msg({0x90, 10, VEL_ON | 14})); +} + +static void test_ring_modes() { + Fixture f; + // pos 0 clears the ring whatever the mode nibble says. + f.from_daw({0xB0, 0x30 + 1, 0x20}); + EXPECT_SURFACE(f, msg({0xF0, 0x13, 0x01, 0x00, 1, 0x00, 0xF7})); + + // mode 2 = wrap/fill: pos 4 lights LEDs 0..3. + f.cap.clear(); + f.from_daw({0xB0, 0x30 + 1, 0x24}); + EXPECT_SURFACE(f, msg({0xF0, 0x13, 0x01, 0x00, 1, 0x0F, 0xF7})); + + // Fill past bit 7 spills into the address byte's high nibble. + f.cap.clear(); + f.from_daw({0xB0, 0x30 + 0, 0x2B}); // pos 11 -> 11 LEDs lit + const uint16_t bits = (1u << 11) - 1u; + EXPECT_SURFACE(f, msg({0xF0, 0x13, 0x01, 0x00, + static_cast((bits >> 7) << 3), + bits & 0x7F, 0xF7})); + + // mode 0 = single dot: pos 1 is the first LED. + f.cap.clear(); + f.from_daw({0xB0, 0x30 + 3, 0x01}); + EXPECT_SURFACE(f, msg({0xF0, 0x13, 0x01, 0x00, 3, 0x01, 0xF7})); +} + +static void test_lcd_writes_only_touched_cells() { + Fixture f; + // Write "Kick" at offset 0: touches cell (0,0) only. + f.from_daw({0xF0, 0x00, 0x00, 0x66, 0x14, 0x12, 0x00, + 'K', 'i', 'c', 'k', 0xF7}); + CHECK(f.cap.surface.size() == 1); + if (f.cap.surface.size() == 1) { + const Msg want = msg({0xF0, 0x13, 0x01, 0x40, LCD_STATUS_CELL_BASE + 0, + 0x7F, 0x00, 'K', 'i', 'c', 'k', ' ', ' ', ' ', 0xF7}); + expect("LCD cell 0", {f.cap.surface[0]}, {want}, __LINE__); + } + + // A write spanning a cell boundary must repaint both cells, and only those. + f.cap.clear(); + f.from_daw({0xF0, 0x00, 0x00, 0x66, 0x14, 0x12, 0x05, + 'A', 'B', 'C', 0xF7}); // offsets 5,6 then 7 + CHECK(f.cap.surface.size() == 2); + + // Second row: offset 56 is the bottom row, cell 0 -> channel-name base. + f.cap.clear(); + f.from_daw({0xF0, 0x00, 0x00, 0x66, 0x14, 0x12, 56, + 'V', 'o', 'x', 0xF7}); + CHECK(f.cap.surface.size() == 1); + if (f.cap.surface.size() == 1) + CHECK(f.cap.surface[0][4] == LCD_CHANNEL_CELL_BASE + 0); + + // Non-printable bytes become spaces rather than reaching the display. + f.cap.clear(); + f.from_daw({0xF0, 0x00, 0x00, 0x66, 0x14, 0x12, 0x00, 0x01, 0x02, 0xF7}); + CHECK(f.cap.surface.size() == 1); + if (f.cap.surface.size() == 1) { + CHECK(f.cap.surface[0][7] == ' '); + CHECK(f.cap.surface[0][8] == ' '); + } + + // A SysEx that is not the MCU LCD message is ignored, not misparsed. + f.cap.clear(); + f.from_daw({0xF0, 0x00, 0x00, 0x67, 0x14, 0x12, 0x00, 'X', 0xF7}); + EXPECT_SURFACE(f); +} + +// --- 5. meter ballistics --------------------------------------------------- + +static void test_meter_decay() { + Fixture f; + // First tick establishes the clock and emits the initial all-zero state. + c8_mcu_tick(&f.m, 0); + CHECK(f.cap.surface.size() == C8_MCU_STRIPS); + + // Full scale on strip 0: level 12 of 12. + f.cap.clear(); + f.from_daw({0xD0, (0 << 4) | 12}); + c8_mcu_tick(&f.m, 10); + CHECK(!f.cap.surface.empty()); + if (!f.cap.surface.empty()) { + // note = 64 | bits, vel = strip. All six rows lit. + CHECK(f.cap.surface[0][1] == (64 | 0x3F)); + CHECK(f.cap.surface[0][2] == 0); + } + + // Nothing further from the host: the meter must fall on its own, or the + // display freezes at the last value when playback stops. + f.cap.clear(); + c8_mcu_tick(&f.m, 10 + 600); // half the decay time + CHECK(!f.cap.surface.empty()); + if (!f.cap.surface.empty()) { + const int bits = f.cap.surface[0][1] & 0x3F; + CHECK(bits != 0x3F); // fallen + CHECK(bits != 0x00); // but not yet silent + } + + // Past the full decay it reaches zero and stays there. + f.cap.clear(); + c8_mcu_tick(&f.m, 10 + 2000); + CHECK(!f.cap.surface.empty()); + if (!f.cap.surface.empty()) CHECK((f.cap.surface[0][1] & 0x3F) == 0); + + // Unchanged rows are not resent -- the surface link is the scarce resource. + f.cap.clear(); + c8_mcu_tick(&f.m, 10 + 3000); + EXPECT_SURFACE(f); +} + +static void test_meter_peak_holds_then_falls() { + Fixture f; + c8_mcu_tick(&f.m, 0); + f.cap.clear(); + + // A peak must not be lowered by a smaller value arriving immediately after. + f.from_daw({0xD0, (3 << 4) | 12}); + f.from_daw({0xD0, (3 << 4) | 2}); + c8_mcu_tick(&f.m, 5); + + bool saw_strip3 = false; + for (const auto& s : f.cap.surface) + if (s[2] == 3) { + saw_strip3 = true; + CHECK((s[1] & 0x3F) == 0x3F); // still full scale + } + CHECK(saw_strip3); +} + +static void test_meter_decay_disabled_follows_host() { + Fixture f; + f.m.meter_decay_ms = 0; + c8_mcu_tick(&f.m, 0); + f.cap.clear(); + + f.from_daw({0xD0, (1 << 4) | 12}); + c8_mcu_tick(&f.m, 10); + f.cap.clear(); + + // With ballistics off the level must hold exactly, not fall. + c8_mcu_tick(&f.m, 5000); + EXPECT_SURFACE(f); +} + +// --- 6. robustness --------------------------------------------------------- + +static void test_truncated_and_junk_input() { + Fixture f; + // Nothing below should read past the end or emit anything. + const uint8_t empty[1] = {0}; + c8_mcu_from_surface(&f.m, empty, 0); + c8_mcu_from_daw(&f.m, empty, 0); + c8_mcu_from_surface(&f.m, nullptr, 3); + c8_mcu_from_daw(&f.m, nullptr, 3); + + f.from_surface({0x90, 0x01}); // 2 bytes, needs 3 + f.from_daw({0xE0, 0x00}); // truncated pitchbend + f.from_daw({0xB0, 0x30}); // truncated CC + f.from_daw({0xF0, 0x00, 0xF7}); // SysEx too short for a header + f.from_surface({0xF8}); // realtime clock + f.from_daw({0xFE}); // active sensing + + EXPECT_DAW(f); + EXPECT_SURFACE(f); +} + +static void test_null_callbacks_are_safe() { + // The dongle wires only one direction during bring-up; a missing callback + // must discard rather than crash. + c8_mcu_t m{}; + c8_mcu_init(&m, nullptr, nullptr, nullptr); + c8_mcu_start(&m); + const uint8_t note[3] = {0x90, 0x00, 0x40}; + c8_mcu_from_surface(&m, note, sizeof(note)); + const uint8_t pb[3] = {0xE0, 0x00, 0x40}; + c8_mcu_from_daw(&m, pb, sizeof(pb)); + c8_mcu_tick(&m, 100); +} + +int main() { + test_constants_agree(); + test_encoders_match_protocol_cpp(); + test_heartbeat_is_swallowed(); + test_strip_buttons(); + test_fader_touch_not_forwarded(); + test_fader_scaling(); + test_fader_rounding_matches_double(); + test_encoders(); + test_transport_buttons(); + test_nav_mode_retargets_arrows(); + test_recsel_arms_selected_tracks(); + test_led_feedback(); + test_ring_modes(); + test_lcd_writes_only_touched_cells(); + test_meter_decay(); + test_meter_peak_holds_then_falls(); + test_meter_decay_disabled_follows_host(); + test_truncated_and_junk_input(); + test_null_callbacks_are_safe(); + + if (g_fail == 0) std::printf("test_mcu: all checks passed\n"); + return g_fail == 0 ? 0 : 1; +}