diff --git a/CMakeLists.txt b/CMakeLists.txt index afc26cb..e4fe645 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -163,7 +163,9 @@ endif() add_executable(command8-mackie src/mackie/mackie_main.cpp src/mackie/mackie_backend.cpp) -target_link_libraries(command8-mackie PRIVATE command8) +# MackieBackend is an adapter over the shared C translator, not a second copy +# of it, so the binary links the same module the dongle firmware compiles. +target_link_libraries(command8-mackie PRIVATE command8 command8-mcu) target_compile_options(command8-mackie PRIVATE ${C8_WARNINGS}) # --- tests (CTest) --- @@ -173,6 +175,14 @@ target_link_libraries(test_protocol PRIVATE command8) target_compile_options(test_protocol PRIVATE ${C8_WARNINGS}) add_test(NAME protocol COMMAND test_protocol) +# Pins the Event -> wire-byte round trip MackieBackend performs. Header-only +# helpers, so this does not need mackie_backend.cpp (which lives in the +# executable, not the library). +add_executable(test_mackie_roundtrip tests/test_mackie_roundtrip.cpp) +target_link_libraries(test_mackie_roundtrip PRIVATE command8 command8-mcu) +target_compile_options(test_mackie_roundtrip PRIVATE ${C8_WARNINGS}) +add_test(NAME mackie_roundtrip COMMAND test_mackie_roundtrip) + 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}) diff --git a/src/feedback.cpp b/src/feedback.cpp index b6e1643..2c9191a 100644 --- a/src/feedback.cpp +++ b/src/feedback.cpp @@ -111,4 +111,8 @@ void Feedback::lcd_status(int strip, const std::string& text) { s_.send(command8::lcd_status(static_cast(strip), text)); } +void Feedback::raw(const uint8_t* bytes, size_t n) { + s_.send(std::vector(bytes, bytes + n)); +} + } // namespace command8 diff --git a/src/feedback.hpp b/src/feedback.hpp index bfe6fc6..d992d30 100644 --- a/src/feedback.hpp +++ b/src/feedback.hpp @@ -50,6 +50,13 @@ class Feedback { void lcd_channel(int strip, const std::string& text); // bottom row void lcd_status(int strip, const std::string& text); // top row + // Pass device-ready bytes straight through. For back-ends that do their own + // encoding -- specifically MackieBackend, which delegates to the shared C + // translator in src/mcu/. That module already emits Command|8 wire format, + // so re-deriving it through the calls above would mean decoding its output + // only to encode it again. Everything else should use the named methods. + void raw(const uint8_t* bytes, size_t n); + private: // Level after decay since the last advance. Caller holds meter_mutex_. double decayed_locked(int strip, std::chrono::steady_clock::time_point now); diff --git a/src/mackie/mackie_backend.cpp b/src/mackie/mackie_backend.cpp index 0753ba8..18f3c5a 100644 --- a/src/mackie/mackie_backend.cpp +++ b/src/mackie/mackie_backend.cpp @@ -1,72 +1,43 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "mackie/mackie_backend.hpp" -#include -#include -#include -#include +#include #include #include #include "feedback.hpp" +#include "protocol.hpp" namespace command8 { namespace { -// MCU note map -constexpr int N_REC = 0, N_SOLO = 8, N_MUTE = 16, N_SELECT = 24; -constexpr int N_PLAY = 0x5E, N_STOP = 0x5D, N_REC_BTN = 0x5F; -constexpr int N_REW = 0x5B, N_FFWD = 0x5C, N_CYCLE = 0x56; -constexpr int N_BANK_L = 0x2E, N_BANK_R = 0x2F; -constexpr int VPOT_CC = 0x10, VPOT_LED_CC = 0x30; -// V-pot assignment section -constexpr int N_SEND = 0x29, N_PAN = 0x2A, N_PLUGIN = 0x2B, N_EQ = 0x2C, N_INST = 0x2D; -// nav / view -constexpr int N_FLIP = 0x32; -constexpr int N_CHAN_L = 0x30, N_CHAN_R = 0x31; // move by 1 track -constexpr int N_CUR_UP = 0x60, N_CUR_DN = 0x61, N_CUR_L = 0x62, N_CUR_R = 0x63; -constexpr int N_ZOOM = 0x64; - -// Navigation cluster (all at subid 13): Bank/Nudge/Zoom are a local mode radio -// group; ScrlBack/ScrollFwd and ViewUP/Down translate per the active mode. These -// are handled specially in on_button (not via kBtnToMcu below). -constexpr int NAV_SUBID = 13; -constexpr int BTN_BANK = 2, BTN_NUDGE = 3, BTN_ZOOM = 4; -constexpr int BTN_SCRL_BACK = 5, BTN_SCRL_FWD = 6, BTN_VIEW_UP = 7, BTN_VIEW_DN = 8; -enum NavMode { NAV_BANK = 0, NAV_NUDGE = 1, NAV_ZOOM = 2 }; - -// Command|8 discrete (note,subid) -> MCU note. RecSel and the nav cluster are -// special-cased in on_button. -const std::map, int> 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 (independent toggle, not part of the nav mode group) - {{0, 13}, N_FLIP}, -}; -// MCU note -> Command|8 LED (note, subid) for feedback. The nav mode LEDs -// (Bank/Nudge/Zoom) are driven locally, not from DAW feedback. -const std::map> 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}}, -}; + +// Milliseconds for the translator's meter ballistics. Only differences matter, +// so the epoch is irrelevant and wrapping is harmless. +uint32_t now_ms() { + using namespace std::chrono; + return static_cast( + duration_cast(steady_clock::now().time_since_epoch()).count()); +} + } // namespace MackieBackend::MackieBackend(std::string recv_match, std::string send_match) : port_(make_midi_port()) { + c8_mcu_init(&mcu_, &MackieBackend::to_daw, &MackieBackend::to_surface, this); + if (!port_->open(recv_match, send_match)) port_.reset(); - if (port_) port_->set_rx([this](const std::vector& m) { handle_mcu(m); }); + if (port_) { + port_->set_rx([this](const std::vector& m) { + c8_mcu_from_daw(&mcu_, m.data(), m.size()); + }); + } } MackieBackend::~MackieBackend() { stop(); } void MackieBackend::on_start() { - lcd_.fill(' '); - nav_mode_ = NAV_BANK; - paint_nav_leds(); // light the default nav mode (Bank) + c8_mcu_start(&mcu_); // paints the default nav-mode LED via to_surface if (port_) port_->start(); } @@ -74,162 +45,56 @@ void MackieBackend::stop() { if (port_) port_->stop(); } -// --- send helpers ---------------------------------------------------------- -void MackieBackend::send_note(int note, bool on) { - if (!port_) return; - port_->send({0x90, static_cast(note & 0x7F), - static_cast(on ? 127 : 0)}); -} -void MackieBackend::send_cc(int cc, int value) { - if (!port_) return; - port_->send({0xB0, static_cast(cc & 0x7F), - static_cast(value & 0x7F)}); +void MackieBackend::tick() { c8_mcu_tick(&mcu_, now_ms()); } + +// --- translator output ----------------------------------------------------- + +void MackieBackend::to_daw(void* user, const uint8_t* msg, size_t len) { + auto* self = static_cast(user); + if (self->port_) self->port_->send(std::vector(msg, msg + len)); } -void MackieBackend::send_pitch(int channel, int value) { - if (!port_) return; - const int v = std::max(-8192, std::min(8191, value)) + 8192; // 0..16383 - port_->send({static_cast(0xE0 | (channel & 0x0F)), - static_cast(v & 0x7F), - static_cast((v >> 7) & 0x7F)}); + +void MackieBackend::to_surface(void* user, const uint8_t* msg, size_t len) { + auto* self = static_cast(user); + // The module emits Command|8 wire format already, so this goes straight + // out rather than back through Feedback's encoders. + if (self->fb_) self->fb_->raw(msg, len); } -// --- Command|8 surface -> MCU --------------------------------------------- -void MackieBackend::on_fader(int strip, double v) { - int pitch = static_cast(std::lround(v * 16383.0)) - 8192; - send_pitch(strip & 7, pitch); +// --- surface input --------------------------------------------------------- + +void MackieBackend::feed_surface(uint8_t status, uint8_t d1, uint8_t d2) { + const uint8_t m[3] = {status, d1, d2}; + c8_mcu_from_surface(&mcu_, m, sizeof(m)); } -void MackieBackend::on_encoder(int strip, int delta) { - send_cc(VPOT_CC + (strip & 7), delta > 0 ? 1 : (0x40 | 1)); + +void MackieBackend::on_fader(int strip, double value01) { + uint8_t m[3]; + surface_bytes_for_fader(strip, value01, m); + feed_surface(m[0], m[1], m[2]); } -void MackieBackend::on_select(int strip, bool pressed) { send_note(N_SELECT + (strip & 7), pressed); } -void MackieBackend::on_mute(int strip, bool pressed) { send_note(N_MUTE + (strip & 7), pressed); } -void MackieBackend::on_solo(int strip, bool pressed) { send_note(N_SOLO + (strip & 7), pressed); } -void MackieBackend::on_button(uint8_t note, uint8_t subid, bool pressed) { - if (note == 3 && subid == 12) { // RecSel -> arm the selected track(s) - std::lock_guard lk(state_m_); - for (int ch : selected_) send_note(N_REC + ch, pressed); - return; - } - if (subid == NAV_SUBID) { - switch (note) { - case BTN_BANK: if (pressed) set_nav_mode(NAV_BANK); return; - case BTN_NUDGE: if (pressed) set_nav_mode(NAV_NUDGE); return; - case BTN_ZOOM: if (pressed) set_nav_mode(NAV_ZOOM); return; - case BTN_SCRL_BACK: // '<' : per-mode step left - send_note(nav_mode_ == NAV_BANK ? N_BANK_L - : nav_mode_ == NAV_NUDGE ? N_CHAN_L : N_CUR_L, pressed); - return; - case BTN_SCRL_FWD: // '>' : per-mode step right - send_note(nav_mode_ == NAV_BANK ? N_BANK_R - : nav_mode_ == NAV_NUDGE ? N_CHAN_R : N_CUR_R, pressed); - return; - case BTN_VIEW_UP: send_note(N_CUR_UP, pressed); return; // zoom in Zoom mode - case BTN_VIEW_DN: send_note(N_CUR_DN, pressed); return; - default: break; // Flip (note 0), MstrFadrs (note 1): fall through - } - } - auto it = kBtnToMcu.find({note, subid}); - if (it != kBtnToMcu.end()) send_note(it->second, pressed); +void MackieBackend::on_encoder(int strip, int delta) { + if (delta == 0) return; // not a detent; the translator would ignore it too + uint8_t m[3]; + surface_bytes_for_encoder(strip, delta, m); + feed_surface(m[0], m[1], m[2]); } -// Bank/Nudge/Zoom radio group. Only the active mode's LED lights; entering or -// leaving Zoom toggles the DAW's Zoom modifier so the arrows zoom. -void MackieBackend::set_nav_mode(int mode) { - if (mode == nav_mode_) return; - const bool was_zoom = (nav_mode_ == NAV_ZOOM), now_zoom = (mode == NAV_ZOOM); - nav_mode_ = mode; - if (now_zoom != was_zoom) { send_note(N_ZOOM, true); send_note(N_ZOOM, false); } - paint_nav_leds(); +void MackieBackend::on_select(int strip, bool pressed) { + on_button(NOTE_SELECT, static_cast(strip), pressed); } -void MackieBackend::paint_nav_leds() { - if (!fb_) return; - fb_->strip_led(BTN_BANK, NAV_SUBID, nav_mode_ == NAV_BANK); - fb_->strip_led(BTN_NUDGE, NAV_SUBID, nav_mode_ == NAV_NUDGE); - fb_->strip_led(BTN_ZOOM, NAV_SUBID, nav_mode_ == NAV_ZOOM); +void MackieBackend::on_mute(int strip, bool pressed) { + on_button(NOTE_MUTE, static_cast(strip), pressed); } - -// --- MCU (DAW feedback) -> Command|8 -------------------------------------- -void MackieBackend::handle_mcu(const std::vector& m) { - if (!fb_ || m.empty()) return; - if (m[0] == 0xF0) { - lcd_sysex(m.data(), static_cast(m.size())); - return; - } - const int type = m[0] & 0xF0, ch = m[0] & 0x0F; - switch (type) { - case 0xE0: { // pitchbend = fader - if (m.size() < 3) break; - const int v = (m[2] << 7) | m[1]; // 0..16383 - if (ch < 8) fb_->fader(ch, v / 16383.0); - break; - } - case 0xD0: { // channel pressure = meters - if (m.size() < 2) break; - const int v = m[1]; - const int strip = (v >> 4) & 7, level = v & 0x0F; - fb_->meter(strip, std::min(1.0, level / 12.0)); - break; - } - case 0xB0: { - if (m.size() < 3) break; - const int cc = m[1]; - if (cc >= VPOT_LED_CC && cc <= VPOT_LED_CC + 7) { - const int enc = cc - VPOT_LED_CC, val = m[2]; - const int pos = val & 0x0F, mode = (val >> 4) & 0x03; - if (pos == 0) fb_->ring_fill(enc, 0.0); - else if (mode == 2) fb_->ring_fill(enc, pos / 11.0); // wrap/fill - else fb_->ring_dot(enc, (pos - 1) / 10.0); // single dot - } - break; - } - case 0x90: - case 0x80: { - if (m.size() < 3) break; - const bool on = (type == 0x90 && m[2] > 0); - const int n = m[1]; - if (n >= N_REC && n <= N_REC + 7) fb_->recarm_led(n - N_REC, on); - else if (n >= N_SOLO && n <= N_SOLO + 7) fb_->solo_led(n - N_SOLO, on); - else if (n >= N_MUTE && n <= N_MUTE + 7) fb_->mute_led(n - N_MUTE, on); - else if (n >= N_SELECT && n <= N_SELECT + 7) { - const int c = n - N_SELECT; - { std::lock_guard lk(state_m_); - if (on) selected_.insert(c); else selected_.erase(c); } - fb_->select_led(c, on); - } else { - auto it = kMcuToLed.find(n); - if (it != kMcuToLed.end()) - fb_->strip_led(static_cast(it->second.first), - it->second.second, on); - } - break; - } - default: - break; - } +void MackieBackend::on_solo(int strip, bool pressed) { + on_button(NOTE_SOLO, static_cast(strip), pressed); } -void MackieBackend::lcd_sysex(const uint8_t* d, int len) { - // F0 00 00 66 12 F7 - if (len < 8 || d[1] != 0x00 || d[2] != 0x00 || d[3] != 0x66 || d[5] != 0x12) - return; - int offset = d[6] & 0x7F; - int n = len - 8; // strip F0..header(6) and trailing F7 - std::set> cells; - for (int i = 0; i < n; ++i) { - int p = offset + i; - if (p < 0 || p >= 112) continue; - uint8_t ch = d[7 + i]; - lcd_[p] = (ch >= 0x20 && ch <= 0x7E) ? ch : ' '; - cells.insert({p / 56, (p % 56) / 7}); - } - for (auto& [line, col] : cells) { - if (col > 7) continue; - int base = line * 56 + col * 7; - std::string s(reinterpret_cast(&lcd_[base]), 7); - if (line == 0) fb_->lcd_status(col, s); - else fb_->lcd_channel(col, s); - } +void MackieBackend::on_button(uint8_t note, uint8_t subid, bool pressed) { + uint8_t m[3]; + surface_bytes_for_button(note, subid, pressed, m); + feed_surface(m[0], m[1], m[2]); } } // namespace command8 diff --git a/src/mackie/mackie_backend.hpp b/src/mackie/mackie_backend.hpp index 5d61a83..d5f690b 100644 --- a/src/mackie/mackie_backend.hpp +++ b/src/mackie/mackie_backend.hpp @@ -1,21 +1,31 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Mackie Control (MCU) emulation backend. Presents the Command|8 to the DAW as // a Mackie Control on a MIDI port: on Linux typically a snd-virmidi "Virtual -// Raw MIDI" kernel port, on Windows a pair of loopMIDI cables. Built on -// libcommand8 with a MidiPort for the MCU side, so the translation logic is -// transport-agnostic. +// Raw MIDI" kernel port, on Windows a Windows MIDI Services loopback pair. // -// Translation ported from the original Python driver's mackie profile. +// This class owns no translation logic of its own. It is an adapter around +// src/mcu/c8_mcu.c -- the same freestanding C module the dongle firmware runs, +// and the one the unit tests cover. Keeping a second copy here would mean the +// host bridge and the dongle could drift apart silently, each correct against +// its own tests and different on the wire. +// +// The adapter costs one re-encode. Surface decodes device bytes into Events, +// Controller hands those to the on_* methods below, and they rebuild the +// original bytes for the C module. That round trip is exact -- every field +// survives, and a test pins it -- but it is a round trip, and it exists only +// to keep the Backend abstraction intact. A byte-level path from Surface +// straight into the translator would be cleaner and is the obvious next +// refactor; it was not done here because it touches every Surface backend. #pragma once -#include +#include #include #include -#include -#include #include #include "backend.hpp" +#include "protocol.hpp" +#include "mcu/c8_mcu.h" #include "midi_port.hpp" namespace command8 { @@ -49,6 +59,39 @@ inline constexpr const char* kDefaultMcuRecvMatch = "VirMIDI"; inline constexpr const char* kDefaultMcuSendMatch = "VirMIDI"; #endif +// --- Event -> wire bytes --------------------------------------------------- +// +// Controller hands back-ends decoded Events, but the translator works in +// Command|8 wire bytes, so these rebuild what the device originally sent. The +// round trip must be lossless or control values would shift, so they are inline +// and free rather than private members: tests/test_mackie_roundtrip.cpp feeds +// every possible input through decode_* and back and asserts the bytes match. + +// Fader: the 10-bit position is split across the CC number (low 3 bits) and its +// data byte (upper 7), which is how the surface transmits it. +inline void surface_bytes_for_fader(int strip, double value01, uint8_t out[3]) { + int v10 = static_cast(value01 * 1023.0 + 0.5); + if (v10 < 0) v10 = 0; + if (v10 > 1023) v10 = 1023; + out[0] = 0xB0; + out[1] = static_cast(((v10 & 0x07) << 3) | (strip & STRIP_MASK)); + out[2] = static_cast((v10 >> 3) & 0x7F); +} + +inline void surface_bytes_for_encoder(int strip, int delta, uint8_t out[3]) { + out[0] = 0xB0; + out[1] = static_cast(ENCODER_CC_BASE + (strip & STRIP_MASK)); + out[2] = delta > 0 ? ENC_RIGHT : ENC_LEFT; +} + +// Buttons: velocity carries the sub-id in the low 6 bits, bit 6 = pressed. +inline void surface_bytes_for_button(uint8_t note, uint8_t subid, bool pressed, + uint8_t out[3]) { + out[0] = 0x90; + out[1] = note; + out[2] = static_cast((pressed ? VEL_ON : 0) | (subid & SUBID_MASK)); +} + class MackieBackend : public Backend { public: explicit MackieBackend(std::string recv_match = kDefaultMcuRecvMatch, @@ -67,21 +110,21 @@ class MackieBackend : public Backend { void on_solo(int strip, bool pressed) override; void on_button(uint8_t note, uint8_t subid, bool pressed) override; + // Drives meter ballistics inside the translator (~10 Hz from Controller). + void tick() override; + private: - void send_note(int note, bool on); - void send_cc(int cc, int value); - void send_pitch(int channel, int value); - void handle_mcu(const std::vector& m); - void lcd_sysex(const uint8_t* data, int len); - void set_nav_mode(int mode); // Bank/Nudge/Zoom radio group - void paint_nav_leds(); - - int nav_mode_ = 0; // 0=Bank,1=Nudge,2=Zoom (input thread only) - std::unique_ptr port_; + // Rebuild the Command|8 wire bytes an Event came from and hand them to the + // translator. See the class comment on why this round trip exists. + void feed_surface(uint8_t status, uint8_t d1, uint8_t d2); - std::mutex state_m_; // guards selected_ - std::set selected_; - std::array lcd_{}; // Mackie 2x56 LCD buffer + // Translator output. Static trampolines because the C module takes plain + // function pointers; `user` is always `this`. + static void to_daw(void* user, const uint8_t* msg, size_t len); + static void to_surface(void* user, const uint8_t* msg, size_t len); + + c8_mcu_t mcu_{}; + std::unique_ptr port_; }; } // namespace command8 diff --git a/tests/test_mackie_roundtrip.cpp b/tests/test_mackie_roundtrip.cpp new file mode 100644 index 0000000..a59db63 --- /dev/null +++ b/tests/test_mackie_roundtrip.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// MackieBackend delegates all translation to the shared C module in src/mcu/, +// which works in Command|8 wire bytes. But Controller hands back-ends decoded +// Events, so the adapter has to rebuild the bytes the device originally sent. +// +// That round trip must be lossless. If it is not, control values shift by a +// least-significant bit or two -- a fader that never quite reaches unity, an +// encoder that stops responding -- which is the kind of fault that gets blamed +// on hardware. So every possible input is driven through decode_* and back here, +// exhaustively rather than by sampling: the domains are small enough that there +// is no reason to guess. +#include +#include + +#include "mackie/mackie_backend.hpp" +#include "protocol.hpp" + +using namespace command8; + +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) + +// Every fader CC the device can send: 8 strips x 8 low-bit groups x 128 values. +// Decode to the normalized value Controller would pass, rebuild the bytes, and +// require them identical. +static void test_fader_roundtrip_is_exact() { + int checked = 0; + for (int strip = 0; strip < 8; ++strip) { + for (int hi = 0; hi < 8; ++hi) { + const uint8_t cc = static_cast((hi << 3) | strip); + for (int val = 0; val < 128; ++val) { + const Event ev = decode_cc(cc, static_cast(val)); + const auto* f = std::get_if(&ev); + if (!f) { CHECK(f != nullptr); return; } + + // Exactly what Controller::dispatch computes. + const double v01 = f->value10 / 1023.0; + + uint8_t out[3]; + surface_bytes_for_fader(f->fader, v01, out); + + if (out[0] != 0xB0 || out[1] != cc || out[2] != val) { + std::printf("FAIL %s:%d fader strip=%d hi=%d val=%d: " + "value10=%u -> %02X %02X %02X, wanted B0 %02X %02X\n", + __FILE__, __LINE__, strip, hi, val, + f->value10, out[0], out[1], out[2], cc, val); + ++g_fail; + return; + } + ++checked; + } + } + } + CHECK(checked == 8 * 8 * 128); +} + +// The encoder's two detent values must survive, on every strip. +static void test_encoder_roundtrip_is_exact() { + for (int strip = 0; strip < 8; ++strip) { + for (const uint8_t detent : {ENC_RIGHT, ENC_LEFT}) { + const uint8_t cc = static_cast(ENCODER_CC_BASE + strip); + const Event ev = decode_cc(cc, detent); + const auto* e = std::get_if(&ev); + if (!e) { CHECK(e != nullptr); return; } + + uint8_t out[3]; + surface_bytes_for_encoder(e->encoder, e->delta, out); + CHECK(out[0] == 0xB0); + CHECK(out[1] == cc); + CHECK(out[2] == detent); + } + } +} + +// Every note/velocity pair the surface can produce as a button. Heartbeats and +// fader-touch never reach a Backend (Controller routes them elsewhere), so they +// are excluded -- but note 0 at other velocities IS Select and must survive. +static void test_button_roundtrip_is_exact() { + int checked = 0; + for (int note = 0; note < 128; ++note) { + for (int vel = 0; vel < 128; ++vel) { + if (note == HEARTBEAT_NOTE && vel == HEARTBEAT_VEL) continue; + + const Event ev = decode_note_on(static_cast(note), + static_cast(vel)); + + uint8_t out[3]; + if (const auto* b = std::get_if(&ev)) { + // Select/mute/solo go through on_select/on_mute/on_solo, which + // forward to on_button with the same (note, subid) -- so one + // encoder covers every button path. + surface_bytes_for_button(b->note, b->subid, b->pressed, out); + } else if (std::get_if(&ev)) { + continue; // never forwarded to the translator + } else { + continue; // fader-touch or unrecognised + } + + if (out[0] != 0x90 || out[1] != note || out[2] != vel) { + std::printf("FAIL %s:%d button note=%d vel=%d -> " + "%02X %02X %02X, wanted 90 %02X %02X\n", + __FILE__, __LINE__, note, vel, + out[0], out[1], out[2], note, vel); + ++g_fail; + return; + } + ++checked; + } + } + // Sanity: the loop must actually have exercised a large number of pairs, + // otherwise a decode change could silently empty this test. + CHECK(checked > 15000); +} + +// The strip-button helpers must agree with the generic one, since the adapter +// implements them by delegation. +static void test_strip_helpers_match_generic() { + for (int strip = 0; strip < 8; ++strip) { + for (const bool pressed : {true, false}) { + for (const uint8_t note : {NOTE_SELECT, NOTE_MUTE, NOTE_SOLO}) { + uint8_t a[3]; + surface_bytes_for_button(note, static_cast(strip), pressed, a); + const Event ev = decode_note_on(a[1], a[2]); + const auto* b = std::get_if(&ev); + if (!b) { CHECK(b != nullptr); return; } + CHECK(b->subid == strip); + CHECK(b->pressed == pressed); + CHECK(b->note == note); + } + } + } +} + +int main() { + test_fader_roundtrip_is_exact(); + test_encoder_roundtrip_is_exact(); + test_button_roundtrip_is_exact(); + test_strip_helpers_match_generic(); + + if (g_fail == 0) std::printf("test_mackie_roundtrip: all checks passed\n"); + return g_fail == 0 ? 0 : 1; +}