Skip to content

midi: bridge the Paula serial port to host MIDI (CoreMIDI/ALSA/WinMM)#105

Open
hobbo91 wants to merge 3 commits into
LinuxJedi:mainfrom
hobbo91:feature/midi-serial
Open

midi: bridge the Paula serial port to host MIDI (CoreMIDI/ALSA/WinMM)#105
hobbo91 wants to merge 3 commits into
LinuxJedi:mainfrom
hobbo91:feature/midi-serial

Conversation

@hobbo91

@hobbo91 hobbo91 commented Jul 3, 2026

Copy link
Copy Markdown

Love the work you are doing with this project!

I actually think Copperline is a much better candidate for this use case, especially for folks who are still using Amiga's for making (mostly Jungle) music with OctaMED and real samplers or wanting to integrate a virtual Amiga into a software DAW. I did something similar with vAmiga a while back.

Summary

Adds an optional midi build feature that bridges Paula's serial port to the host's MIDI system, so an Amiga sequencer, tracker, or game can drives real (or virtual) instruments -- or is played from a host MIDI keyboard -- over the emulated serial line ([serial] mode = "midi", or --serial midi). A default build compiles no MIDI code: the whole midi module is behind #[cfg(feature = "midi")]. Three native backends, one per OS, all raw FFI with no wrapper crate.

Serial timing is treated as seriously as everything else: each byte is delivered at the host instant it left the emulated wire, not whenever a frame's worth of bytes happens to flush.

The bridge (platform-agnostic)

The emulator core only ever sees the existing SerialSink. MidiSerialSink implements it and owns the two things every backend shares:

  • The scheduled-send contract. Emulator publishes a SerialTimeAnchor (the host instant of emulated colour-clock 0 on the audio/video-paced timeline); Paula stamps each transmitted byte with the cck it finished shifting out; the sink maps that to a host Instant and hands it to backend.send(msg, at). The guest's byte timing survives to the wire instead of collapsing to the flush moment.
  • A message framer. Amiga serial is a byte stream; MIDI receivers reject lone data bytes. MidiFramer reassembles whole messages -- running status, SysEx accumulation, interleaved real-time bytes passed straight through -- before anything is sent.

Active Sensing (0xFE) is forwarded by default (a real Amiga passes it down the line) and stripped only under COPPERLINE_MIDI_STRIP_ACTIVE_SENSE=1. COPPERLINE_MIDI_DEBUG=1|2 traces byte-flow counts / decoded messages per direction.

Backends (raw FFI, cfg(target_os))

Each talks to the platform API directly and honours the contract with that platform's own scheduling primitive:

  • macOS -- CoreMIDI. Output rides a CoreMIDI packet timestamp (mach_absolute_time); input arrives on a CoreMIDI callback thread into a lock-free SPSC ring. The MIDIPacketList mirror is #[repr(C, packed(4))] -- CoreMIDI packs to 4, and natural alignment mis-locates every received packet.
  • Linux -- ALSA sequencer. Output rides a real-time queue event; input runs a poll-based thread decoding events to raw bytes. The snd_seq_event_t schedule fields are written by hand (the snd_seq_ev_* helpers are header-only inlines) against a mirror pinned with compile-time offset_of! asserts.
  • Windows -- WinMM. midiOut* carries no timestamp, so a scheduler thread holds a priority queue keyed by host Instant and calls midiOutShortMsg/midiOutLongMsg only when a message comes due (timeBeginPeriod(1) for ~1 ms wait granularity); input follows the CoreMIDI callback model. Every import and the callback are extern "system", and MIDIHDR is #[repr(C, packed(4))] (112 bytes on 64-bit, lpNext at offset 28 -- verified against <mmeapi.h>, not just self-consistent asserts).

Other targets keep the existing stub

Selection and UI

--list-midi prints host endpoints; --midi-out NAME / --midi-in NAME (case-insensitive substring, either optional) imply --serial midi; the same three keys live under [serial]. Devices are also picked in a new launcher Serial tab (present only in a midi build) and swapped live from the in-window MIDI In / MIDI Out menu (shown only when serial is in MIDI mode). --help lists the flags in a midi build.

Built in by default (opt-out is zero-cost)

  • Built by default (default = ["midi"]). cargo build --no-default-features compiles a MIDI-free binary: 0 MIDI symbols (nm clean), no MIDI framework linked.
  • The feature adds no crates (midi = []; the input ring's ringbuf was already a dependency of the audio path).
  • Purely additive: the emulator core and every existing serial path are untouched.
  • No STATE_VERSION change -- all MIDI state is transient host-side handles; nothing enters a save state.

Packaging

Nothing here touches the Flatpak/AppImage/Homebrew plumbing. Two decisions are yours if you want to enable the feature in a release build: the ALSA sequencer needs /dev/snd, which the current Flatpak finish-args (--socket=pulseaudio, --device=dri/input) don't grant -- it would want --device=all; and no CI job builds --features midi yet. Happy to follow up with a Flatpak-permissions patch or a feature-build CI matrix if you'd like them.

Verification / Real world testing

  • Gates, both feature states: cargo build / cargo build --features midi, cargo clippy --all-targets -- -D warnings (and with --features midi), cargo fmt --check -- all clean. Tests: 1269 default, 1277 with --features midi, 0 failures; the MIDI-only tests cover MidiFramer reassembly (running status, SysEx, interleaved real-time, stray-byte drop) and [serial] mode parsing.
  • Timing fidelity, all three backends: an ignored midi_through_loopback integration test per backend round-trips a note scheduled 300 ms out through a loopback port and asserts arrival >= 250 ms -- the schedule was honoured, not collapsed to "now". cargo test --features midi -- --ignored. Verified passing: macOS (IAC Driver bus), Linux (snd-seq-dummy "Midi Through"), Windows (loopMIDI, x64).
  • On real hardware, all three OSes. Bidirectional through a USB MIDI interface: a Yamaha SY22 as MIDI input (keyboard) and an Akai S950 as MIDI output (sampler), exercised on Windows, Linux, and macOS.
  • Virtual routing (macOS). Sent and received both directions over an IAC Driver bus to and from Ableton Live.
  • Amiga games with MIDI. Tested King's Quest V through the Munt Roland MT-32 emulator via the AmigaOS mt32.drv driver.
  • Docs: a MIDI (optional) README section and a MIDI serial bridge section in docs/internals/peripherals.md; the Paula-serial row of the emulation table updated (it described stdout only).

Screenshots

Screenshot 2026-07-03 at 22 16 20 Screenshot 2026-07-03 at 22 54 47 Screenshot 2026-07-04 at 10 09 27 Screenshot 2026-07-03 at 22 08 33 Screenshot 2026-07-03 at 23 06 29 Screenshot 2026-07-03 at 23 03 44

Add an optional `midi` build feature that bridges Paula's serial port to
the host MIDI system, with three native backends -- CoreMIDI (macOS), the
ALSA sequencer (Linux), and WinMM (Windows) -- all raw FFI, no wrapper
crate. A default build compiles no MIDI code: the whole module is behind
#[cfg(feature = "midi")].

The emulator core only sees the existing SerialSink. MidiSerialSink adds
the two things every backend shares: the scheduled-send contract (Emulator
publishes a SerialTimeAnchor, Paula stamps each byte with the cck it left
the wire on, and the sink maps that to a host Instant so each message is
delivered at its emitted time rather than when a frame flushes) and a
MidiFramer that reassembles the serial byte stream into whole MIDI messages
(running status, SysEx, real-time passthrough). Active Sensing is forwarded
by default and stripped only under COPPERLINE_MIDI_STRIP_ACTIVE_SENSE.

Each backend honours the contract with its own scheduling primitive: a
CoreMIDI packet timestamp, an ALSA real-time queue event, and -- since
WinMM has no timestamp -- a scheduler thread that fires midiOutShortMsg /
midiOutLongMsg when a message comes due. Layout-sensitive FFI structs are
pinned with compile-time size/offset asserts (CoreMIDI's packed(4) packet
list, snd_seq_event_t, and WinMM's packed MIDIHDR).

Selection: --list-midi, --midi-out / --midi-in (imply --serial midi), the
[serial] config block, a launcher Serial tab, and a live in-window MIDI
In / MIDI Out menu. Off by default the binary carries zero MIDI symbols and
the feature adds no crates. No STATE_VERSION change.

Verified: clippy -D warnings and fmt clean in both feature states; 1277
tests with --features midi (1269 default), 0 failures; an ignored
midi_through_loopback timing test per backend, verified passing on macOS
(IAC Driver bus), Linux (snd-seq-dummy "Midi Through") and Windows
(loopMIDI), plus real-synth output and live MIDI-in checks on each OS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional midi Cargo feature that bridges Paula’s emulated serial port to the host MIDI stack (CoreMIDI / ALSA sequencer / WinMM), preserving guest byte timing by scheduling host delivery against a published serial time anchor. This integrates into configuration, CLI, and UI while keeping default builds MIDI-free.

Changes:

  • Introduces src/midi/* with a shared MidiSerialSink + framer and native OS backends (plus stub for unsupported targets).
  • Extends the serial plumbing (SerialSink timestamping + SerialTimeAnchor) and wires Paula/Bus/Emulator to publish the time anchor.
  • Adds config/CLI/UI selection for serial mode and MIDI endpoints (launcher Serial tab + runtime menu items in MIDI mode).

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/video/window.rs Adds MIDI-aware runtime menu wiring and device cycling hooks.
src/video/ui.rs Makes the runtime menu dynamic (conditionally includes MIDI items) and updates hit-testing/drawing accordingly.
src/video/launcher.rs Adds a Serial launcher tab (MIDI builds) and config editing for serial/MIDI endpoint selection.
src/serial.rs Adds SerialTimeAnchor and timestamps serial writes so timing-sensitive sinks can schedule output.
src/midi/mod.rs Implements MidiSerialSink, message framing, endpoint cycling, and the backend trait.
src/midi/coremidi.rs macOS CoreMIDI backend via raw FFI with timestamped packet scheduling.
src/midi/alsa.rs Linux ALSA sequencer backend via raw FFI with real-time queue scheduling and input thread.
src/midi/winmm.rs Windows WinMM backend via raw FFI with a scheduler thread to honor send instants.
src/midi/stub.rs Stub backend for unsupported targets (enumerates nothing, refuses to open).
src/main.rs Adds --list-midi, --serial, --midi-in/out parsing and feature-gated help output.
src/lib.rs Exposes the midi module behind #[cfg(feature = "midi")].
src/emulator.rs Builds the serial sink from config and republishes the serial time anchor after re-anchoring.
src/config.rs Adds [serial] config (mode + endpoints), CLI overrides, parsing, and tests.
src/chipset/paula.rs Threads emit-time color-clock stamping through serial TX and updates serial tests accordingly.
src/bus.rs Publishes the serial time anchor to Paula and exposes the MIDI sink for runtime device switching.
src/bus/tests.rs Updates test serial sink impl for the new write_byte signature.
README.md Documents the optional MIDI feature, selection, and usage examples.
docs/internals/peripherals.md Documents the MIDI serial bridge design and backend model.
copperline.example.toml Adds commented [serial] configuration examples and CLI notes.
Cargo.toml Adds the midi feature flag (off by default).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/internals/peripherals.md Outdated
Comment thread src/video/window.rs Outdated
Comment thread src/video/ui.rs Outdated

@LinuxJedi LinuxJedi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent feature. Happy to have it compiled in by default. Please do that and address the issues Copilot flagged

Make the MIDI bridge part of the default build (`default = ["midi"]`), as
requested on the PR; `--no-default-features` still compiles a MIDI-free
build (0 MIDI symbols, no MIDI framework linked). Also address the review
comments:

- Only query the runtime menu's device labels when the menu is open, rather
  than allocating two Strings and touching the bus on every frame.
- Pre-size the runtime menu's item Vec so appending the MIDI and trailing
  items never reallocates.
- Fix the peripherals internals doc, which still described Windows as
  falling back to the stub: WinMM is a native backend now, and the stub is
  for other targets only. Note WinMM's scheduler thread and packed MIDIHDR
  alongside the CoreMIDI/ALSA layout notes.
- Reword the README MIDI section for the built-in-by-default change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hobbo91

hobbo91 commented Jul 4, 2026

Copy link
Copy Markdown
Author

Excellent feature. Happy to have it compiled in by default. Please do that and address the issues Copilot flagged

Thanks! Compiled in by default now (default = ["midi"], opt out with --no-default-features), and addressed all three Copilot comments — stale Windows/stub doc, per-frame menu-label work, and the menu-items Vec sizing. clippy, fmt and the tests all pass with and without the feature.

@hobbo91 hobbo91 requested a review from LinuxJedi July 4, 2026 08:38
Move the MIDI In / MIDI Out entries above Pixel Aspect (and below Joystick
input) so the device switches sit with the other input-related items rather
than between Pixel Aspect and Warp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

3 participants