Skip to content

macOS support (libusb Surface + virtual CoreMIDI), and meter falloff on all platforms - #1

Merged
alphonsom merged 2 commits into
mainfrom
macos-support
Aug 8, 2026
Merged

macOS support (libusb Surface + virtual CoreMIDI), and meter falloff on all platforms#1
alphonsom merged 2 commits into
mainfrom
macos-support

Conversation

@alphonsom

Copy link
Copy Markdown
Owner

Brings the engine to macOS, and fixes two meter bugs that turned out to affect every platform.

Verified on hardware: a Command|8 driven from Reaper's native Mackie Control on macOS — faders bidirectional (10-bit in, motorised out), pan encoders, encoder rings, meters, LCD, buttons and LEDs.

macOS backend

The malformed MIDIStreaming input descriptor needs a different workaround per platform:

getting the inputMCU bridge needs
Linuxsnd-usb-audio quirksnd-virmidi
WindowsDigidesign/Avid's drivera loopback pair
macOSclaim the interface (libusb)nothing

CoreMIDI has no quirk mechanism, so the device's ports enumerate but stay inert and no MIDI-API backend can reach the input. MacosSurface bypasses CoreMIDI on the device side and speaks USB-MIDI packets over libusb, matching VID/PID 0dba:8000 instead of a port name.

MacosMidiPort goes the other way. macOS is the only supported platform where an application can create MIDI endpoints, so the MCU side publishes its own virtual source and destination — no loopback utility. Both endpoints matter: with only a source, a DAW sees an input with no matching output and control-surface support reports it cannot find a MIDI output.

src/protocol, src/feedback, src/controller and both front-ends are untouched.

Root is required

Claiming the interface takes it from CoreMIDI's class driver, and CoreMIDI reclaims it the moment anything releases it. Unprivileged processes cannot even enumerate the device, so "absent" and "not permitted" are indistinguishable — open() reports both possibilities rather than guessing. The README covers this and why the alternatives (unloading the system driver, a codeless kext, DriverKit) are worse for a self-hosted tool.

Meter fixes (all platforms)

Meters never fell.meter() sent the host value and nothing more, so the display froze on the last value and stayed lit after playback stopped. Hosts transmit meter values sparsely — Reaper only when the quantised 0-12 level changes, ~1 Hz measured — and expect the surface to supply ballistics, as real MCU hardware does in firmware. The value is now a peak: instant rise, decay from Feedback::tick(). Default 1200 ms, chosen against captured Reaper traffic; set_meter_decay_ms(0) disables it.

Levels were mis-scaled.static_cast<int>(v * METER_ROWS) truncates, so 11/12 of full scale lit 5 of 6 LEDs and anything below 1/6 read as silence. Now rounds, with any non-zero signal lighting at least one LED.

Unchanged rows are no longer re-sent.

Testing

tests/test_feedback.cpp (new, wired into CTest) covers both fixes against a recording fake Surface, including the original symptom: full scale, then ticks with no host updates, asserting it reaches zero. Both suites pass; clean build, no new warnings.

Reviewer notes

  • Not built or run on Linux or Windows. The meter change is cross-platform and deserves a check on at least one of them. The macOS files are APPLE-guarded, but feedback.{hpp,cpp} and controller.cpp are shared.
  • With falloff active, a peak landing exactly on a half-row boundary renders one row low — decay elapses before the tick reads it. Sub-LED, at exact boundaries only; the rounding tests disable decay to isolate the behaviours.
  • The __APPLE__ branch for default MCU port names is a real fix, not cosmetic: the generic non-Windows default "VirMIDI" is a Linux virmidi name and wrong for ports we create ourselves.
  • docs/PROTOCOL.md is unchanged — no new protocol findings; this port was built against it and independently confirmed the wake handshake, 10-bit faders and (note, subid) button identity on hardware.

🤖 Generated with Claude Code

gibsonsand others added 2 commits August 7, 2026 15:18
The Command|8's malformed MIDIStreaming input descriptor needs a different
workaround on each platform. Linux patches snd-usb-audio; Windows relies on
Digidesign's driver. CoreMIDI has no quirk mechanism, so the device's ports
enumerate but stay inert and no MIDI-API backend can reach the input.
MacosSurface therefore bypasses CoreMIDI on the device side entirely: it
claims interface 1 over libusb and speaks USB-MIDI event packets directly,
matching on VID/PID (0dba:8000) rather than a port name. Raw MIDI from the
protocol encoders is packed into 4-byte packets on the way out and unpacked
on the way in; the wake/keepalive contract is unchanged.
MacosMidiPort goes the other way. macOS is the only supported platform that
lets an application create MIDI endpoints, so the MCU side needs no loopback
utility at all: it publishes a virtual source and destination via RtMidi and
the DAW connects straight to them. Both endpoints are required - with only a
source, a DAW sees an input with no matching output and control-surface
support reports it cannot find a MIDI output.
Claiming the interface takes it from CoreMIDI's class driver, so the binaries
need root. Unprivileged processes cannot even enumerate the device (macOS
hides USB devices a process may not touch), which makes "absent" and "not
permitted" indistinguishable - open() reports both possibilities rather than
guessing.
Also add an __APPLE__ branch for the default MCU port names: the generic
non-Windows default is "VirMIDI", which is a Linux virmidi name and wrong for
ports we create ourselves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems, both visible on hardware and both affecting every platform.
Meters never fell. Feedback::meter() sent the host's value and nothing more,
so the display froze on whatever arrived last and stayed lit indefinitely once
playback stopped. That is not a host bug: hosts transmit meter values sparsely
(Reaper only when the quantised 0-12 level changes, roughly 1 Hz in practice)
and expect the surface to supply the ballistics in between, the way real MCU
hardware does in firmware. The host value is now treated as a peak - instant
rise, then decay driven from Feedback::tick(), which Controller runs on every
Surface tick. Default 1200 ms full-scale falloff, measured against captured
Reaper meter traffic; set_meter_decay_ms(0) restores exact host-following.
Levels were also mis-scaled. static_cast<int>(v * METER_ROWS) truncates, so
11/12 of full scale lit 5 of 6 LEDs and anything below 1/6 read as silence.
Now rounds, with any non-zero signal guaranteed at least one lit LED so quiet
material is distinguishable from nothing.
Unchanged rows are no longer re-sent, so a steady level costs one write rather
than one per tick.
tests/test_feedback.cpp covers both fixes against a recording fake Surface,
including the original symptom: full scale, then ticks with no host updates,
asserting the meter reaches zero.
Note that with falloff active a peak landing exactly on a half-row boundary
renders one row low, because some decay elapses before the tick reads it. It
is a sub-LED artifact at exact boundaries only, which is why the rounding
tests disable decay to isolate the two behaviours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alphonsom
alphonsom merged commit 0145d99 into mainAug 8, 2026
4 checks passed
alphonsom added a commit that referenced this pull request Aug 10, 2026
Brings in the macOS work and meter ballistics merged via PRs #1-#3. Clean
merge, no conflicts; builds with no warnings and all three tests pass.
README: Windows no longer requires Digidesign/Avid's driver. The dongle makes
the surface enumerate as an ordinary class-compliant USB-MIDI device with no
driver at all, and the ports look the same to this engine either way.
Also spells out the exclusive-access trap, which is easy to hit and gives no
useful error: a DAW holding the Command|8 ports stops the engine opening the
device. The DAW talks to the engine over OSC or the MCU loopback pair, never
to the surface directly -- the Command|8 speaks a proprietary protocol, so a
DAW sending it generic MIDI just twitches the faders and leaves the display
Offline, which is exactly what a first Windows attempt looks like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto 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

@alphonsom