Skip to content

Translate Mackie Control in the dongle: a freestanding C module, shared with the firmware - #5

Merged
alphonsom merged 1 commit into
mainfrom
mcu-translator
Aug 11, 2026
Merged

Translate Mackie Control in the dongle: a freestanding C module, shared with the firmware#5
alphonsom merged 1 commit into
mainfrom
mcu-translator

Conversation

@alphonsom

Copy link
Copy Markdown
Owner

Moves the Command|8 → Mackie Control translation out of the host and into a
freestanding C module the dongle firmware compiles directly. A DAW then sees a
Mackie Control surface with no host software at all — no bridge process, no
loopback pair, no OSC pattern file, on any operating system.

What's here

src/mcu/c8_mcu.c is the same logic MackieBackend implements, with every
dependency stripped: no allocation, no floating point, no threads, no C++
runtime, no MIDI backend. Bytes in, bytes out through two callbacks.

1674 bytes text, 0 data, 0 bss on Cortex-M0+
external symbols: memset, __aeabi_idiv, __aeabi_idivmod, __aeabi_uidiv

All state is caller-owned, so tests run independent instances and the firmware
needs no allocator. Fixed point rather than double is not an optimisation — the
M0+ has no FPU, but more importantly integer arithmetic is exactly reproducible,
so the tests pin real values instead of tolerances. Each conversion is written
as (2N + d) / (2d) to round identically to the lround() it replaces, and a
test walks all 1024 fader positions asserting the two agree exactly.

The firmware builds this file rather than a vendored copy, so tests/test_mcu.cpp
covers the code that actually ships. 19 groups over the paths that are expensive
to debug on hardware: heartbeat echoes swallowed rather than decoded as a Select
press on a nonexistent strip, ring bits spilling into the SysEx address byte,
LCD writes repainting only touched cells, meter ballistics, truncated input
emitting nothing. One group cross-checks the module's private protocol constants
against protocol.hpp, so drift fails the build.

Also

Renames the Windows MCU loopback pair from A/B to Bridge/DAW. The old
names gave no clue which end was which, and choosing wrong in the DAW fails
silently in a way that looks like broken hardware. The names deliberately stay
free of a bar: Command|8 is the surface matcher's prefix, so a barred loopback
could be matched as the device itself.

Verified on hardware

Both directions, 2026-08-11, on a bare Pico and an Adafruit Feather RP2040 USB
Host. Fader to pitchbend (numerically exact against hand-worked arithmetic),
encoders both ways, transport and strip buttons, fader touch correctly dropped,
heartbeats swallowed; inbound LCD both rows, LEDs, motor fader, and encoder
rings including the 11-bit split across two bytes.

A clean 288-message burst translated 1:1 with nothing lost:

219 fader CCs   -> 219 pitchbends
 69 encoder CCs ->  69 V-pot CCs
  8 fader-touch ->   0  (correctly dropped)
 20 heartbeats  ->   0  (correctly swallowed)

End to end with Reaper on Windows driving the surface with nothing else running.

Not in this PR

MackieBackend still implements this logic separately, so it exists twice. The
C version is the tested one. Collapsing the C++ backend into a wrapper over it
is the obvious follow-up, kept separate because it touches the path that just
started working on Windows.

The Command|8 needed a host bridge on every platform because nothing else
could speak its protocol. Moving that translation into the dongle removes
the host side entirely: a DAW sees a Mackie Control surface and drives it
with no software installed at all.

src/mcu/c8_mcu.c is the same logic MackieBackend implements, with every
dependency stripped -- no allocation, no floating point, no threads, no C++
runtime, no MIDI backend. Bytes in, bytes out through two callbacks. It
compiles to 1674 bytes on a Cortex-M0+ with zero data and zero bss (all
state is caller-owned), and its only external symbols are memset and the
integer-division helpers.

Fixed point rather than double is not an optimisation. The M0+ has no FPU,
but more importantly integer arithmetic is exactly reproducible, so tests
can pin real values instead of tolerances. Each conversion is written as
(2N + d) / (2d) to round identically to the lround() it replaces; a test
walks all 1024 fader positions and asserts the two agree exactly.

The firmware compiles this file directly rather than a vendored copy, so
the desktop tests cover the code that actually ships. tests/test_mcu.cpp
adds 19 groups over the paths that are expensive to debug on hardware:
heartbeat echoes swallowed rather than decoded as a Select press on a
nonexistent strip, ring bits spilling into the SysEx address byte, LCD
writes repainting only the cells touched, meter ballistics, and truncated
input emitting nothing. One group cross-checks the module's private
protocol constants against protocol.hpp so drift fails the build.

Verified on hardware 2026-08-11 in both directions, and end to end with
Reaper on Windows driving the surface with no host software running.

Also rename the Windows MCU loopback pair from "A"/"B" to "Bridge"/"DAW".
The old names gave no clue which end was which, and choosing wrong in the
DAW fails silently in a way that looks like broken hardware. The names stay
free of a bar deliberately: "Command|8" is the surface matcher's prefix, so
a barred loopback could be matched as the device itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alphonsom
alphonsom merged commit 9dd2bf0 into main Aug 11, 2026
4 checks passed
@alphonsom
alphonsom deleted the mcu-translator branch August 11, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant