From 2a1ef5e880b86c7d4e2088e9c9672cbbf97f4957 Mon Sep 17 00:00:00 2001 From: Lee Hobson Date: Fri, 3 Jul 2026 22:18:37 +0100 Subject: [PATCH 1/3] midi: bridge Paula's serial port to host MIDI (optional feature) 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 --- Cargo.toml | 5 + README.md | 42 +- copperline.example.toml | 16 + docs/internals/peripherals.md | 44 ++ src/bus.rs | 19 +- src/bus/tests.rs | 2 +- src/chipset/paula.rs | 120 +++-- src/config.rs | 151 ++++++ src/emulator.rs | 33 +- src/lib.rs | 2 + src/main.rs | 69 ++- src/midi/alsa.rs | 764 ++++++++++++++++++++++++++++++ src/midi/coremidi.rs | 568 ++++++++++++++++++++++ src/midi/mod.rs | 505 ++++++++++++++++++++ src/midi/stub.rs | 23 + src/midi/winmm.rs | 862 ++++++++++++++++++++++++++++++++++ src/serial.rs | 52 +- src/video/launcher.rs | 138 +++++- src/video/ui.rs | 487 +++++++++++-------- src/video/window.rs | 81 +++- 20 files changed, 3746 insertions(+), 237 deletions(-) create mode 100644 src/midi/alsa.rs create mode 100644 src/midi/coremidi.rs create mode 100644 src/midi/mod.rs create mode 100644 src/midi/stub.rs create mode 100644 src/midi/winmm.rs diff --git a/Cargo.toml b/Cargo.toml index e9f6357..6b55e88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,11 @@ display-plan-trace = [] # Normal builds keep these environment-variable overrides inert so release # behavior is hardware-derived and reproducible. internal-diagnostics = [] +# Host MIDI bridge for Paula's serial port (`[serial] mode = "midi"`). Off by +# default: a plain build is unchanged and pulls no MIDI code. On macOS this +# links CoreMIDI directly (no new crate); other platforms compile a stub until +# their backend lands. See src/midi/. +midi = [] [dependencies] # Path-only dependency on the in-tree fork (crates/m68k). No version requirement diff --git a/README.md b/README.md index 8e8d67f..0bdb3d8 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,46 @@ The full reference -- every key, machine profiles, Zorro boards, CD/HDD images, validation rules, and audio options -- is in the [configuration guide](docs/guide/configuration.md). +## MIDI (optional) + +Copperline can bridge Paula's serial port to the host's MIDI system, so an +Amiga sequencer or tracker plays real synths -- or is itself played from a +host MIDI keyboard -- over the emulated serial line. It is an opt-in build +feature; a default build compiles no MIDI code and is unchanged. + +```sh +cargo build --release --features midi +``` + +The backend is selected at compile time and talks to each platform's native +API directly, with no wrapper crate: **CoreMIDI** on macOS, the **ALSA +sequencer** on Linux, and **WinMM** on Windows. Outgoing bytes are scheduled +so each one is delivered at the host instant it left the emulated wire, +keeping the guest's MIDI timing intact rather than collapsing it to whenever a +frame's worth of bytes is flushed. + +List the host endpoints, then select them by name (a case-insensitive +substring is enough); `--midi-out`/`--midi-in` imply `--serial midi`: + +```sh +./target/release/copperline --list-midi +./target/release/copperline --midi-out "FluidSynth" --midi-in "Keystation" +``` + +Devices can also be chosen in the launcher's **Serial** tab, swapped live from +the in-window **MIDI In / MIDI Out** menu, or set in the config file: + +```toml +[serial] +mode = "midi" # off, stdout, or midi +midi_out = "FluidSynth" # host destination; substring match +midi_in = "Keystation" # host source +``` + +The ALSA development headers the Linux backend links against are already a +build requirement (see Requirements); macOS and Windows need nothing beyond +the OS. + ## Documentation User and developer documentation -- getting started, the UI and shortcuts, @@ -217,7 +257,7 @@ release needs resolved first. Release steps for every channel are in | ROM | Kickstart at $F80000 (512 KiB); optional extended ROM for CD32 ($E00000) and CDTV ($F00000). | | Battery RTC | Read-only MSM6242-compatible register view at $DC0000; guest writes affect only emulated latch/control state. | | CIA-A / CIA-B | I/O ports, /OVL, timers, TOD, keyboard SDR/ICR, disk control/status lines, and CIA-B FLAG disk index pulses. | -| Paula serial | SERDAT -> stdout through a one-word transmit buffer and timed shift register; SERDATR reports TBE/TSRE/RBF. | +| Paula serial | SERDAT through a one-word transmit buffer and timed shift register, out to stdout or -- with the optional `midi` feature -- bridged to host MIDI in/out; SERDATR reports TBE/TSRE/RBF, and serial receive is fed from the selected MIDI input. | | Paula audio | 4-channel DMA/sample playback, stereo mix, LED filter. | | Paula DMACON / INTENA / INTREQ | IRQ bits are stored and delivered through manual M68K autovectors with modelled 68000 interrupt-recognition latency; audio and disk DMA raise completion IRQs. | | Floppy / ADF / DMS / SCP | DF0-DF3 standard DD ADF read/write, read-only ADZ/DMS, UAE extended ADF, initial read-only SCP flux import, track-timed disk DMA, CIA drive lines, index FLAG, DSKLEN/DSKBYTR/DSKSYNC/DSKDAT, per-drive multi-disk playlists with a swap key. | diff --git a/copperline.example.toml b/copperline.example.toml index 802da0a..4337fc8 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -262,6 +262,22 @@ floppy_sounds_volume = 100 # joystick = "auto" +# Serial port wiring. The Amiga serial port doubles as the MIDI port. +# [serial] +# +# mode selects where Paula's serial in/out is connected: +# "stdout" (default) serial output prints to the host terminal, matching +# the historical behaviour (DiagROM and similar tools log here). +# "off" serial output is discarded and there is no serial input. +# "midi" serial in/out is bridged to host MIDI endpoints. Needs a build +# with the `midi` feature; midi_out/midi_in name the endpoints +# (substring match, e.g. a USB interface or a virtual port). +# --serial MODE overrides this per run; --midi-out/--midi-in NAME imply "midi". +# mode = "stdout" +# midi_out = "USB MIDI Interface" +# midi_in = "USB MIDI Interface" + + # Optional floppy drives. DF0 is always connected; set [floppy] drives # to wire empty external mechanisms as well (1-4 total drives). Missing # [floppy.dfN] tables mean no disk in that drive. Supported images: diff --git a/docs/internals/peripherals.md b/docs/internals/peripherals.md index cbc3834..681d408 100644 --- a/docs/internals/peripherals.md +++ b/docs/internals/peripherals.md @@ -200,3 +200,47 @@ A `SerialSink` that can *produce* input (none of the built-in sinks do) must override `has_pending_input` alongside `read_byte`/`read_word`: Paula's per-tick UART step takes an idle fast path that skips the receiver entirely while it reports false. + +## MIDI serial bridge (`midi/`) + +`[serial] mode = "midi"` (or `--midi-out`/`--midi-in`) bridges Paula's +serial port to host MIDI, behind the optional `midi` cargo feature -- a +plain build compiles none of it and the mode falls back with a clear +message. The whole thing hangs off one `SerialSink`, `MidiSerialSink`, so +the emulator core is unchanged from any other serial target. + +The load-bearing detail is that byte timing survives to the wire. Paula +stamps each transmitted byte with the emulated colour clock it left on +(`SerialTimeAnchor`); `MidiSerialSink` maps that to a host `Instant` and +asks the backend to *schedule* the message for that instant rather than +send it now, so a frame's worth of bytes flushed together still leaves at +the original spacing. Two host-agnostic pieces sit above the backend: a +`MidiFramer` reassembles the single-byte serial stream into whole MIDI +messages (a receiver rejects lone data bytes), tracking running status and +SysEx and passing interleaved real-time bytes straight through; and Active +Sensing (`0xFE`) is forwarded by default -- a real Amiga passes it down the +wire -- and only dropped under `COPPERLINE_MIDI_STRIP_ACTIVE_SENSE=1`. +Input arrives on a lock-free SPSC ring the receiver drains on its idle +fast path, so the poll never locks. + +The host connection lives behind the `MidiBackend` trait, chosen by +`cfg(target_os)`: macOS drives CoreMIDI (`coremidi.rs`), Linux the ALSA +sequencer (`alsa.rs`), and other targets (Windows for now) get `stub.rs`, +which enumerates nothing and refuses to open. Each backend links its +platform library directly with no wrapper crate, and each maps the +scheduled send onto that platform's timed-delivery primitive: a CoreMIDI +packet timestamp, an ALSA real-time queue event. A new backend implements +`send`/`set_output`/`set_input`/`current_output`/`current_input` plus free +`enumerate`/`open`; nothing else changes. The raw FFI is layout-sensitive +-- CoreMIDI packs its packet list to 4 bytes, and the ALSA `snd_seq_event_t` +scheduling helpers are header-only inlines whose field writes are +replicated by hand -- so both mirrors are pinned with compile-time layout +assertions and want checking against live MIDI, not just review. + +Two debug knobs help tell a dead path from a routing one: +`COPPERLINE_MIDI_DEBUG=1` reports per-second tx/rx byte counts and the +first bytes sent (no tx while a song plays means the guest is not driving +serial, i.e. the fault is upstream of the bridge); `=2` decodes every +message in each direction. `COPPERLINE_MIDI_IMMEDIATE=1` bypasses +scheduling and sends each message for immediate delivery, to separate a +timing problem from a connection one. diff --git a/src/bus.rs b/src/bus.rs index fcc7569..e9ae7e5 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -2714,6 +2714,20 @@ impl Bus { self.emulated_cck } + /// Publish the emulated-to-host time mapping to the serial sink so a + /// timing-sensitive backend (MIDI) can schedule output. See + /// [`crate::serial::SerialTimeAnchor`]. + pub fn set_serial_time_anchor(&mut self, anchor: crate::serial::SerialTimeAnchor) { + self.paula.set_serial_time_anchor(anchor); + } + + /// The live MIDI sink, when the serial port is in MIDI mode, for switching + /// devices from the runtime menu. + #[cfg(feature = "midi")] + pub fn midi_serial_mut(&mut self) -> Option<&mut crate::midi::MidiSerialSink> { + self.paula.serial.as_midi() + } + pub fn live_audio_output_lead_seconds(&self) -> f64 { self.paula.live_audio_output_lead_seconds() } @@ -3659,7 +3673,10 @@ impl Bus { self.slice_preempted = true; } - self.paula.intreq |= self.paula.tick_serial(cck); + // emulated_cck already covers this span, so it is the color clock at + // the span's end. Paula uses it to stamp any serial byte that finishes + // here; passing it avoids arithmetic on the common (no byte) path. + self.paula.intreq |= self.paula.tick_serial(cck, self.emulated_cck); self.paula.tick_pots(cck); let dmacon = self.agnus.dmacon; self.flush_audio(); diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 87120af..85307eb 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -180,7 +180,7 @@ fn render_color_write_x(hpos: u32) -> usize { struct NoopSerial; impl SerialSink for NoopSerial { - fn write_byte(&mut self, _b: u8) {} + fn write_byte(&mut self, _b: u8, _at_cck: u64) {} fn flush(&mut self) {} } diff --git a/src/chipset/paula.rs b/src/chipset/paula.rs index 6021ca0..09ba6de 100644 --- a/src/chipset/paula.rs +++ b/src/chipset/paula.rs @@ -747,6 +747,11 @@ impl Paula { self.led_filter_enabled = enabled; } + /// Publish the emulated-to-host time mapping to the serial sink. + pub fn set_serial_time_anchor(&mut self, anchor: crate::serial::SerialTimeAnchor) { + self.serial.set_time_anchor(anchor); + } + #[cfg_attr(not(test), allow(dead_code))] pub fn led_filter_enabled(&self) -> bool { self.led_filter_enabled @@ -990,7 +995,10 @@ impl Paula { Some(POT_COUNTER_CCK.saturating_sub(self.pot_acc_cck).max(1)) } - pub fn tick_serial(&mut self, cck: u32) -> u16 { + /// Advance the serial port by `cck` color clocks. `end_cck` is the power-on + /// color-clock count at the end of this span; a byte that finishes mid-span + /// is stamped with its emit time from it for a timing-sensitive sink. + pub fn tick_serial(&mut self, cck: u32, end_cck: u64) -> u16 { // Idle fast path: nothing shifting, nothing queued in either // direction. Equivalent to the full path, which would only advance // the RX synchronizer (the pin level cannot change while idle). @@ -1002,10 +1010,10 @@ impl Paula { self.advance_serial_rx_synchronizer(cck); return 0; } - self.tick_serial_tx(cck) | self.tick_serial_rx(cck) + self.tick_serial_tx(cck, end_cck) | self.tick_serial_rx(cck) } - fn tick_serial_tx(&mut self, cck: u32) -> u16 { + fn tick_serial_tx(&mut self, cck: u32, end_cck: u64) -> u16 { let mut irq = 0; let mut remaining = cck; while remaining > 0 { @@ -1022,8 +1030,13 @@ impl Paula { shift.bit_index += 1; if shift.bit_index >= shift.total_bits { if !shift.break_seen && !self.uart_break_active() { - self.serial - .write_word(Self::serial_tx_data_word(&shift), shift.long); + // Stop bit done: this many clocks are left of the span. + let at_cck = end_cck.saturating_sub(u64::from(remaining)); + self.serial.write_word( + Self::serial_tx_data_word(&shift), + shift.long, + at_cck, + ); } self.serial_tx_pin_high = true; irq |= self.load_serial_shift_if_idle(); @@ -1876,7 +1889,7 @@ mod tests { struct NoopSerial; impl SerialSink for NoopSerial { - fn write_byte(&mut self, _b: u8) {} + fn write_byte(&mut self, _b: u8, _at_cck: u64) {} fn flush(&mut self) {} } @@ -1886,7 +1899,7 @@ mod tests { } impl SerialSink for CollectSerial { - fn write_byte(&mut self, b: u8) { + fn write_byte(&mut self, b: u8, _at_cck: u64) { self.written.lock().unwrap().push(b); } @@ -1912,11 +1925,11 @@ mod tests { } impl SerialSink for CollectSerialWords { - fn write_byte(&mut self, b: u8) { + fn write_byte(&mut self, b: u8, _at_cck: u64) { self.written.lock().unwrap().push((u16::from(b), false)); } - fn write_word(&mut self, word: u16, long: bool) { + fn write_word(&mut self, word: u16, long: bool, _at_cck: u64) { self.written.lock().unwrap().push((word, long)); } @@ -1936,6 +1949,24 @@ mod tests { fn flush(&mut self) {} } + /// Records each transmitted word alongside the emit-time color clock the + /// UART stamped it with, so the emit-time plumbing can be asserted. + struct TimedSerial { + events: Arc>>, + } + + impl SerialSink for TimedSerial { + fn write_byte(&mut self, b: u8, at_cck: u64) { + self.events.lock().unwrap().push((u16::from(b), at_cck)); + } + + fn write_word(&mut self, word: u16, _long: bool, at_cck: u64) { + self.events.lock().unwrap().push((word, at_cck)); + } + + fn flush(&mut self) {} + } + type WordSerialFixture = (Paula, Arc>>, Arc>>); struct CollectSink { @@ -2872,31 +2903,60 @@ mod tests { assert_eq!(paula.read_serdatr() & (1 << 12), 0); assert!(written.lock().unwrap().is_empty()); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert!(paula.serial_txd_pin_high()); - assert_eq!(paula.tick_serial(8), 0); + assert_eq!(paula.tick_serial(8, 0), 0); assert_eq!(paula.next_serial_event_cck(), Some(1)); assert!(paula.serial_txd_pin_high()); assert!(written.lock().unwrap().is_empty()); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_eq!(paula.next_serial_event_cck(), None); assert_eq!(&*written.lock().unwrap(), &[0x41]); assert_ne!(paula.read_serdatr() & (1 << 12), 0); } + #[test] + fn serial_tx_stamps_byte_with_emit_color_clock() { + // Transmit one byte in a single span and read back the emit-time stamp. + // Two spans ending 5000 clocks apart must stamp the byte 5000 clocks + // apart, without hard-coding the framing length. + fn transmit(end_cck: u64) -> (u16, u64) { + let events = Arc::new(Mutex::new(Vec::new())); + let mut paula = Paula::new( + Box::new(TimedSerial { + events: Arc::clone(&events), + }), + Box::new(NullAudio), + ); + assert_eq!(paula.write_serdat(0x0141), INT_TBE); + // A span longer than the framing completes the byte in one call. + paula.tick_serial(100, end_cck); + let ev = events.lock().unwrap(); + assert_eq!(ev.len(), 1, "exactly one byte should be emitted"); + ev[0] + } + + let (word0, at0) = transmit(1000); + let (word_b, at_b) = transmit(6000); + assert_eq!(word0, 0x41); + assert_eq!(word_b, 0x41); + assert!(at0 > 0, "emit time should be before the span end"); + assert_eq!(at_b, at0 + 5000, "the span end offsets the emit time"); + } + #[test] fn serdat_masks_stop_bit_and_preserves_long_data_bit_for_word_sinks() { let (mut paula, written, _) = paula_with_collect_serial_words(); assert_eq!(paula.write_serdat(0x0141), INT_TBE); - assert_eq!(paula.tick_serial(10), 0); + assert_eq!(paula.tick_serial(10, 0), 0); assert_eq!(&*written.lock().unwrap(), &[(0x0041, false)]); paula.serper = SERPER_LONG; assert_eq!(paula.write_serdat(0x0342), INT_TBE); - assert_eq!(paula.tick_serial(10), 0); + assert_eq!(paula.tick_serial(10, 0), 0); assert_eq!(&*written.lock().unwrap(), &[(0x0041, false)]); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_eq!( &*written.lock().unwrap(), &[(0x0041, false), (0x0142, true)] @@ -2909,9 +2969,9 @@ mod tests { paula.serper = SERPER_LONG; assert_eq!(paula.write_serdat(0x0341), INT_TBE); - assert_eq!(paula.tick_serial(10), 0); + assert_eq!(paula.tick_serial(10, 0), 0); assert!(written.lock().unwrap().is_empty()); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_eq!(&*written.lock().unwrap(), &[0x41]); } @@ -2921,12 +2981,12 @@ mod tests { assert_eq!(paula.write_serdat(0x01FF), INT_TBE); assert!(!paula.serial_txd_pin_high()); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert!(paula.serial_txd_pin_high()); paula.write_adkcon(0x8000 | ADKCON_UARTBRK); assert!(!paula.serial_txd_pin_high()); - assert_eq!(paula.tick_serial(9), 0); + assert_eq!(paula.tick_serial(9, 0), 0); assert!(written.lock().unwrap().is_empty()); paula.write_adkcon(ADKCON_UARTBRK); @@ -2938,8 +2998,8 @@ mod tests { let (mut paula, _, read) = paula_with_collect_serial(); read.lock().unwrap().extend_from_slice(&[0x55, 0x66]); - assert_eq!(paula.tick_serial(9) & INT_RBF, 0); - let irq = paula.tick_serial(1); + assert_eq!(paula.tick_serial(9, 0) & INT_RBF, 0); + let irq = paula.tick_serial(1, 0); assert_eq!(irq & INT_RBF, INT_RBF); paula.latch_interrupt_sources(irq); let serdatr = paula.read_serdatr(); @@ -2948,7 +3008,7 @@ mod tests { assert_eq!(serdatr & 0x00FF, 0x55); assert_eq!(serdatr & 0x0300, 0x0300); - assert_eq!(paula.tick_serial(10) & INT_RBF, 0); + assert_eq!(paula.tick_serial(10, 0) & INT_RBF, 0); assert_ne!(paula.read_serdatr() & (1 << 15), 0); paula.write_intreq(INT_RBF); @@ -2961,8 +3021,8 @@ mod tests { paula.serper = SERPER_LONG; read.lock().unwrap().push(0x0155); - assert_eq!(paula.tick_serial(10) & INT_RBF, 0); - let irq = paula.tick_serial(1); + assert_eq!(paula.tick_serial(10, 0) & INT_RBF, 0); + let irq = paula.tick_serial(1, 0); assert_eq!(irq & INT_RBF, INT_RBF); paula.latch_interrupt_sources(irq); let serdatr = paula.read_serdatr(); @@ -2976,18 +3036,18 @@ mod tests { paula.serper = 3; read.lock().unwrap().push(0x01); - assert_eq!(paula.tick_serial(0), 0); + assert_eq!(paula.tick_serial(0, 0), 0); assert_ne!(paula.read_serdatr() & (1 << 11), 0); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_ne!(paula.read_serdatr() & (1 << 11), 0); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_eq!(paula.read_serdatr() & (1 << 11), 0); - assert_eq!(paula.tick_serial(2), 0); + assert_eq!(paula.tick_serial(2, 0), 0); assert_eq!(paula.read_serdatr() & (1 << 11), 0); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_eq!(paula.read_serdatr() & (1 << 11), 0); - assert_eq!(paula.tick_serial(1), 0); + assert_eq!(paula.tick_serial(1, 0), 0); assert_ne!(paula.read_serdatr() & (1 << 11), 0); } diff --git a/src/config.rs b/src/config.rs index ddb2053..10138ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -149,6 +149,10 @@ pub struct Config { /// `Alt+J`, and the menu's Joystick Input item flip it live without /// affecting this start-up value. pub joystick_input_mode: JoystickInputMode, + /// Host wiring for Paula's serial port (`[serial]` / `--serial`). + /// Defaults to [`SerialMode::Stdout`], preserving the historical + /// terminal-diagnostics behaviour. + pub serial: SerialConfig, } /// How much of the overscan field the window presents. The @@ -222,6 +226,45 @@ impl JoystickInputMode { } } +/// Where Paula's serial port is wired on the host (`[serial] mode` / +/// `--serial`). The Amiga serial port is also the MIDI port, so the MIDI +/// backend is one of these modes rather than a separate device. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SerialMode { + /// Serial output is discarded and there is no serial input. + Off, + /// Serial output is written to the host terminal. The historical + /// default (DiagROM and other tools print diagnostics here), kept as + /// the default so an unconfigured machine behaves exactly as before. + #[default] + Stdout, + /// Serial in/out is bridged to host MIDI endpoints. Requires a build + /// with the `midi` feature; without it, resolving this mode is an error. + Midi, +} + +impl SerialMode { + /// Config-string label (round-trips through [`parse_serial_mode`]). + pub fn label(self) -> &'static str { + match self { + Self::Off => "off", + Self::Stdout => "stdout", + Self::Midi => "midi", + } + } +} + +/// Resolved `[serial]` settings. `midi_out`/`midi_in` name the host MIDI +/// endpoints (substring match) and are only consulted when `mode` is +/// [`SerialMode::Midi`]; they are carried through in the other modes so the +/// configuration screen round-trips them unchanged. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SerialConfig { + pub mode: SerialMode, + pub midi_out: Option, + pub midi_in: Option, +} + /// A configured hard-drive image: the host path plus an optional volume-name /// override. The override only changes a host *directory* mounted as an /// in-memory FFS volume -- it sets the FFS volume label instead of deriving it @@ -789,6 +832,7 @@ impl Default for Config { pixel_aspect: PixelAspect::Tv, phosphor: 0.0, joystick_input_mode: JoystickInputMode::Gamepad, + serial: SerialConfig::default(), } } } @@ -908,6 +952,13 @@ pub struct ConfigOverrides { /// ("auto" still accepted as a compatibility alias). Validated by the same /// parser as `[input] joystick`. pub joystick: Option, + /// Serial port wiring (`--serial`): "off", "stdout", or "midi". Same + /// parser as `[serial] mode`. + pub serial: Option, + /// Host MIDI output endpoint (`--midi-out`), implying `--serial midi`. + pub midi_out: Option, + /// Host MIDI input endpoint (`--midi-in`), implying `--serial midi`. + pub midi_in: Option, } impl ConfigOverrides { @@ -923,6 +974,9 @@ impl ConfigOverrides { && self.slow.is_none() && self.floppy_drives.is_none() && self.joystick.is_none() + && self.serial.is_none() + && self.midi_out.is_none() + && self.midi_in.is_none() } /// Inject the set overrides into the raw config, replacing the values @@ -958,6 +1012,20 @@ impl ConfigOverrides { if let Some(joystick) = &self.joystick { raw.input.joystick = Some(joystick.clone()); } + if let Some(mode) = &self.serial { + raw.serial.mode = Some(mode.clone()); + } + if let Some(out) = &self.midi_out { + raw.serial.midi_out = Some(out.clone()); + } + if let Some(input) = &self.midi_in { + raw.serial.midi_in = Some(input.clone()); + } + // Naming a MIDI endpoint on the command line selects MIDI mode unless + // `--serial` said otherwise. + if self.serial.is_none() && (self.midi_out.is_some() || self.midi_in.is_some()) { + raw.serial.mode = Some(SerialMode::Midi.label().to_string()); + } } } @@ -1010,6 +1078,8 @@ pub struct RawConfig { pub(crate) display: RawDisplay, #[serde(default, skip_serializing_if = "is_default")] pub(crate) input: RawInput, + #[serde(default, skip_serializing_if = "is_default")] + pub(crate) serial: RawSerial, /// `[[zorro]]` board entries, configured in file order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) zorro: Vec, @@ -1052,6 +1122,21 @@ pub(crate) struct RawInput { pub(crate) joystick: Option, } +/// `[serial]` host wiring for Paula's serial (a.k.a. MIDI) port. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RawSerial { + /// "stdout" (default), "off", or "midi". + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) mode: Option, + /// Host MIDI output endpoint name (substring match); MIDI mode only. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) midi_out: Option, + /// Host MIDI input endpoint name (substring match); MIDI mode only. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) midi_in: Option, +} + /// A drive image entry in `[ide]`/`[scsi]`. Accepts either a bare path string /// (`master = "disk.hdf"`) or a table carrying an explicit volume-name override /// (`master = { path = "games/", name = "Games" }`). It serializes back to the @@ -1575,6 +1660,14 @@ impl TryFrom for Config { None => defaults.joystick_input_mode, Some(s) => parse_joystick_input_mode(s)?, }; + let serial = SerialConfig { + mode: match raw.serial.mode.as_deref() { + None => defaults.serial.mode, + Some(s) => parse_serial_mode(s)?, + }, + midi_out: raw.serial.midi_out.clone(), + midi_in: raw.serial.midi_in.clone(), + }; let ide = IdeConfig { master: raw.ide.master.map(drive_image).transpose()?, @@ -1740,6 +1833,7 @@ impl TryFrom for Config { pixel_aspect, phosphor, joystick_input_mode, + serial, }) } } @@ -1774,6 +1868,18 @@ pub(crate) fn parse_joystick_input_mode(s: &str) -> Result { } } +pub(crate) fn parse_serial_mode(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "off" | "none" => Ok(SerialMode::Off), + "stdout" | "terminal" => Ok(SerialMode::Stdout), + "midi" => Ok(SerialMode::Midi), + _ => Err(anyhow!( + "unknown [serial] mode {:?}: expected \"off\", \"stdout\", or \"midi\"", + s + )), + } +} + fn parse_pacing_budget(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "cycles" | "m68k-cycles" => Ok(PacingBudget::Cycles), @@ -3849,6 +3955,51 @@ mod tests { Ok(()) } + #[test] + fn serial_defaults_to_stdout() -> Result<()> { + // An unconfigured machine keeps the historical terminal output. + let cfg = parse_config("")?; + assert_eq!(cfg.serial.mode, SerialMode::Stdout); + assert_eq!(Config::default().serial.mode, SerialMode::Stdout); + Ok(()) + } + + #[test] + fn serial_section_selects_mode_and_midi_endpoints() -> Result<()> { + let cfg = parse_config( + "[serial]\nmode = \"midi\"\nmidi_out = \"USB MIDI\"\nmidi_in = \"USB MIDI\"\n", + )?; + assert_eq!(cfg.serial.mode, SerialMode::Midi); + assert_eq!(cfg.serial.midi_out.as_deref(), Some("USB MIDI")); + assert_eq!(cfg.serial.midi_in.as_deref(), Some("USB MIDI")); + + let err = parse_config("[serial]\nmode = \"rs232\"\n").unwrap_err(); + assert!(err.to_string().contains("unknown [serial] mode"), "{err:#}"); + Ok(()) + } + + #[test] + fn cli_midi_endpoint_implies_midi_mode() -> Result<()> { + // Naming an endpoint is enough to switch the serial port to MIDI. + let overrides = ConfigOverrides { + midi_out: Some("Deluge".to_string()), + ..Default::default() + }; + let cfg = load_overrides(&overrides)?; + assert_eq!(cfg.serial.mode, SerialMode::Midi); + assert_eq!(cfg.serial.midi_out.as_deref(), Some("Deluge")); + + // An explicit --serial still wins over the implication. + let overrides = ConfigOverrides { + serial: Some("stdout".to_string()), + midi_in: Some("Deluge".to_string()), + ..Default::default() + }; + let cfg = load_overrides(&overrides)?; + assert_eq!(cfg.serial.mode, SerialMode::Stdout); + Ok(()) + } + #[test] fn cli_overrides_are_validated_like_config_fields() { // A 68000 cannot carry an FPU; the override hits the same check as diff --git a/src/emulator.rs b/src/emulator.rs index cfd6c62..b557312 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -591,6 +591,16 @@ impl Emulator { // Saturated below the epoch (extreme target); fall back to now. self.stats.started_at = Some(now); } + // Republish the serial time base: `started_at` is the host instant of + // emulated time 0 on the same (audio-lead-adjusted) timeline audio and + // video are paced against, so a MIDI sink schedules in sync with them. + if let Some(host_epoch) = self.stats.started_at { + self.bus_mut() + .set_serial_time_anchor(crate::serial::SerialTimeAnchor { + host_epoch, + cck_per_second: f64::from(crate::chipset::paula::PAULA_CLOCK_HZ), + }); + } } // ---- Reverse debugging (time travel) ------------------------------ @@ -1714,6 +1724,27 @@ fn realtime_catchup_limit(live_output_lead_seconds: f64) -> Duration { /// the command-line boot path in `main` and the configuration screen's Run /// button, so a machine built either way is identical. `rom_optional` allows /// a missing ROM file when a save state will supply the image. +/// Build the serial sink Paula writes SERDAT bytes through, per `[serial]`. +/// `Off` discards, `Stdout` is the historical terminal output, and `Midi` +/// bridges to host MIDI endpoints. `Midi` needs a `--features midi` build, so +/// without it a `midi` config is a clear error rather than a silent no-op. +fn build_serial_sink(cfg: &Config) -> Result> { + use crate::config::SerialMode; + match cfg.serial.mode { + SerialMode::Off => Ok(Box::new(crate::serial::NullSerialSink)), + SerialMode::Stdout => Ok(Box::new(StdoutSink::new())), + #[cfg(feature = "midi")] + SerialMode::Midi => Ok(Box::new(crate::midi::MidiSerialSink::open( + cfg.serial.midi_out.as_deref(), + cfg.serial.midi_in.as_deref(), + )?)), + #[cfg(not(feature = "midi"))] + SerialMode::Midi => Err(anyhow!( + "[serial] mode = \"midi\" needs a build with --features midi" + )), + } +} + pub fn build_machine( cfg: &Config, audio: Box, @@ -1820,7 +1851,7 @@ pub fn build_machine( }; let mut floppy = FloppyController::from_config(&cfg.floppy)?; floppy.set_connected_drives(cfg.floppy_connected); - let serial = Box::new(StdoutSink::new()); + let serial = build_serial_sink(cfg)?; let mut paula = Paula::new(serial, audio); paula .drive_sounds_mut() diff --git a/src/lib.rs b/src/lib.rs index fe7b8df..8761e76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ pub mod harddrive; pub mod inputrec; pub mod inputsched; pub mod memory; +#[cfg(feature = "midi")] +pub mod midi; pub mod net; pub mod priority; pub mod recorder; diff --git a/src/main.rs b/src/main.rs index 3eca251..d94c311 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,6 +89,8 @@ pub struct CliArgs { /// `--calibrate-gamepad`: run the interactive gamepad calibration and /// exit, without starting the emulator. pub calibrate_gamepad: bool, + /// `--list-midi`: print the host MIDI endpoints and exit. + pub list_midi: bool, /// Command-line machine overrides (`--model`, `--chipset`, `--cpu`, /// `--fpu`/`--no-fpu`, `--cpu-clock`, `--chip`, `--fast`, `--slow`, /// `--floppy-drives`). @@ -243,6 +245,7 @@ where let mut audio_wav: Option = None; let mut live_audio_profile_secs: Option = None; let mut calibrate_gamepad = false; + let mut list_midi = false; let mut overrides = ConfigOverrides::default(); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -250,6 +253,9 @@ where "--calibrate-gamepad" => { calibrate_gamepad = true; } + "--list-midi" => { + list_midi = true; + } "--config" | "-c" => { let v = args .next() @@ -317,6 +323,24 @@ where .ok_or_else(|| anyhow!("--joystick requires a mode (gamepad/keyboard)"))?, ); } + "--serial" => { + overrides.serial = Some( + args.next() + .ok_or_else(|| anyhow!("--serial requires a mode (off/stdout/midi)"))?, + ); + } + "--midi-out" => { + overrides.midi_out = Some( + args.next() + .ok_or_else(|| anyhow!("--midi-out requires a device name"))?, + ); + } + "--midi-in" => { + overrides.midi_in = Some( + args.next() + .ok_or_else(|| anyhow!("--midi-in requires a device name"))?, + ); + } "--click-after" => { const USAGE: &str = "--click-after requires SECS BUTTON DURATION_MS"; let secs: f32 = next_arg(&mut args, USAGE, "--click-after SECS must be a number")?; @@ -569,12 +593,22 @@ where audio_wav, live_audio_profile_secs, calibrate_gamepad, + list_midi, overrides, }) } fn print_help() { let shortcut = HOST_SHORTCUT_MODIFIER_LABEL; + // The MIDI endpoint options only do anything in a `midi`-feature build, so + // list them only there. `--serial` itself is always shown: off/stdout work + // in every build, and it names midi as a mode. + #[cfg(feature = "midi")] + let midi = "--midi-out NAME host MIDI destination (implies --serial midi)\n \ + --midi-in NAME host MIDI source (implies --serial midi)\n \ + --list-midi list host MIDI endpoints and exit\n "; + #[cfg(not(feature = "midi"))] + let midi = ""; eprintln!( "copperline - Amiga emulator\n\ \n\ @@ -628,7 +662,8 @@ fn print_help() { \x20 instead of live output\n \ --profile-live-audio SECS run a no-window Paula-to-cpal profile workload;\n \ \x20 combine with COPPERLINE_AUDIO_PROFILE=1 for counters\n \ - --calibrate-gamepad interactively bind a USB gamepad to the port-2\n \ + --serial MODE Paula serial port: off, stdout, or midi\n \ + {midi}--calibrate-gamepad interactively bind a USB gamepad to the port-2\n \ \x20 joystick, save the calibration, then exit\n \ -h, --help show this help and exit\n \ -V, --version print the version and exit\n\ @@ -840,6 +875,35 @@ fn run_headless_benchmark(mut emu: Emulator, target_secs: f32) -> Result<()> { Ok(()) } +/// Print the host MIDI endpoints for `--list-midi`. This is how a user finds the +/// names `--midi-out`/`--midi-in` and `[serial]` expect. Without the `midi` +/// feature it says how to get MIDI support rather than printing nothing. +#[cfg(feature = "midi")] +fn list_midi_endpoints() -> Result<()> { + let endpoints = copperline::midi::enumerate(); + println!("MIDI inputs (sources, for --midi-in):"); + if endpoints.inputs.is_empty() { + println!(" (none)"); + } + for e in &endpoints.inputs { + println!(" {}", e.name); + } + println!("MIDI outputs (destinations, for --midi-out):"); + if endpoints.outputs.is_empty() { + println!(" (none)"); + } + for e in &endpoints.outputs { + println!(" {}", e.name); + } + Ok(()) +} + +#[cfg(not(feature = "midi"))] +fn list_midi_endpoints() -> Result<()> { + println!("This build has no MIDI support; rebuild with --features midi."); + Ok(()) +} + fn main() -> Result<()> { let mut log_builder = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")); @@ -867,6 +931,9 @@ fn main() -> Result<()> { if cli.calibrate_gamepad { return gamepad::run_calibration(); } + if cli.list_midi { + return list_midi_endpoints(); + } let (cfg, mut raw_cfg) = load_config(cli.config_path.as_deref(), &cli.overrides)?; if let Some(p) = &cli.rom_path { raw_cfg.rom = Some(p.to_string_lossy().into_owned()); diff --git a/src/midi/alsa.rs b/src/midi/alsa.rs new file mode 100644 index 0000000..532507e --- /dev/null +++ b/src/midi/alsa.rs @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Linux ALSA sequencer backend, linked straight against `libasound` with no +//! wrapper crate. Output rides a real-time queue: each message is scheduled at a +//! host instant so the ALSA server dispatches it then, no matter when the +//! emulation thread called `snd_seq_event_output`. That is what keeps the guest's +//! byte timing intact -- the direct analogue of a CoreMIDI packet timestamp. +//! Input runs on a dedicated thread that decodes incoming events to raw MIDI +//! bytes and pushes them to the lock-free ring Paula's receiver drains. +//! +//! Two sequencer clients are used, each touched by a single thread: `out_seq` +//! (output + the queue + subscription changes) belongs to the emulation thread; +//! the input handle belongs to its own thread. libasound is not safe to drive +//! from two threads at once, so nothing crosses that line -- an input retarget is +//! posted to the input thread rather than applied on the caller's. +//! +//! The `snd_seq_ev_*` scheduling helpers are `static inline` in the ALSA headers, +//! so they are not in the shared library: their field writes are replicated by +//! hand against a Rust mirror of `snd_seq_event_t` (see [`SeqEvent`]). + +use std::ffi::{c_char, c_int, c_long, c_short, c_uint, CStr, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Result}; +use ringbuf::traits::Producer; + +use super::{InputProducer, MidiBackend, MidiEndpoint, MidiEndpoints}; + +// --- ALSA sequencer FFI ------------------------------------------------- + +// Opaque library handles; only ever held behind pointers. +enum SndSeq {} +enum SndSeqClientInfo {} +enum SndSeqPortInfo {} +enum SndMidiEvent {} + +// `snd_seq_addr_t`: a (client, port) pair. The library stores each as a byte. +#[repr(C)] +#[derive(Clone, Copy)] +struct SeqAddr { + client: u8, + port: u8, +} + +// `snd_seq_real_time_t`. Also stands in for the 8-byte `snd_seq_timestamp_t` +// union in the event below: both variants are two 32-bit words, and we only ever +// schedule in real time, so there is no need to model the union. +#[repr(C)] +#[derive(Clone, Copy)] +struct SeqRealTime { + tv_sec: c_uint, + tv_nsec: c_uint, +} + +// Mirror of `snd_seq_event_t` (28 bytes, 4-aligned). The encoder fills `type_`, +// `flags`, and the 12-byte `data` union; we set the schedule fields by hand. The +// `data` union is opaque here -- we never read it -- so a byte array of the right +// size suffices, and keeping the `time` field 4-aligned keeps the whole struct's +// layout identical to the C one. +#[repr(C)] +struct SeqEvent { + type_: u8, + flags: u8, + tag: u8, + queue: u8, + time: SeqRealTime, + source: SeqAddr, + dest: SeqAddr, + data: [u8; 12], +} + +impl SeqEvent { + /// An all-zero event is a valid `SND_SEQ_EVENT_NONE` with no schedule flags. + fn blank() -> Self { + // SAFETY: the struct is plain old data; all-zero is a valid instance. + unsafe { std::mem::zeroed() } + } +} + +// Lock the mirror to the C layout the headers define (verified by offsetof). A +// mismatch would mis-place the schedule fields the encoder does not set, the way +// natural alignment mis-located CoreMIDI's received packets. +const _: () = { + assert!(std::mem::size_of::() == 2); + assert!(std::mem::size_of::() == 8); + assert!(std::mem::size_of::() == 28); + assert!(std::mem::offset_of!(SeqEvent, time) == 4); + assert!(std::mem::offset_of!(SeqEvent, source) == 12); + assert!(std::mem::offset_of!(SeqEvent, dest) == 14); + assert!(std::mem::offset_of!(SeqEvent, data) == 16); +}; + +const SND_SEQ_OPEN_DUPLEX: c_int = 3; + +const SND_SEQ_PORT_CAP_READ: c_uint = 1 << 0; +const SND_SEQ_PORT_CAP_WRITE: c_uint = 1 << 1; +const SND_SEQ_PORT_CAP_SUBS_READ: c_uint = 1 << 5; +const SND_SEQ_PORT_CAP_SUBS_WRITE: c_uint = 1 << 6; + +const SND_SEQ_PORT_TYPE_MIDI_GENERIC: c_uint = 1 << 1; +const SND_SEQ_PORT_TYPE_APPLICATION: c_uint = 1 << 20; + +// The sequencer's own system client (Timer/Announce ports). Always present and +// never a MIDI device, so it is left out of the device list, as MIDI apps do. +const SND_SEQ_CLIENT_SYSTEM: c_int = 0; + +const SND_SEQ_ADDRESS_UNKNOWN: u8 = 253; +const SND_SEQ_ADDRESS_SUBSCRIBERS: u8 = 254; +const SND_SEQ_QUEUE_DIRECT: u8 = 253; + +// Event mode flag bits (see snd_seq_ev_schedule_real / _set_direct). +const SND_SEQ_TIME_STAMP_REAL: u8 = 1 << 0; +const SND_SEQ_TIME_STAMP_MASK: u8 = 1 << 0; +const SND_SEQ_TIME_MODE_REL: u8 = 1 << 1; +const SND_SEQ_TIME_MODE_MASK: u8 = 1 << 1; + +const SND_SEQ_EVENT_START: c_int = 30; +const SND_SEQ_EVENT_NONE: u8 = 255; + +// A destination we can send to is writable and takes write-subscriptions; a +// source we can receive from is readable and takes read-subscriptions. +const DEST_CAPS: c_uint = SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE; +const SOURCE_CAPS: c_uint = SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ; + +// Encoder/decoder scratch, and the ceiling on a single SysEx message. The macOS +// backend's one-packet scratch caps SysEx at roughly the same size. +const MIDI_BUF: usize = 4096; + +#[link(name = "asound")] +extern "C" { + fn snd_seq_open( + handle: *mut *mut SndSeq, + name: *const c_char, + streams: c_int, + mode: c_int, + ) -> c_int; + fn snd_seq_close(handle: *mut SndSeq) -> c_int; + fn snd_seq_set_client_name(seq: *mut SndSeq, name: *const c_char) -> c_int; + fn snd_seq_client_id(seq: *mut SndSeq) -> c_int; + fn snd_seq_nonblock(seq: *mut SndSeq, nonblock: c_int) -> c_int; + fn snd_seq_create_simple_port( + seq: *mut SndSeq, + name: *const c_char, + caps: c_uint, + type_: c_uint, + ) -> c_int; + fn snd_seq_alloc_queue(seq: *mut SndSeq) -> c_int; + fn snd_seq_control_queue( + seq: *mut SndSeq, + q: c_int, + type_: c_int, + value: c_int, + ev: *mut SeqEvent, + ) -> c_int; + fn snd_seq_connect_to(seq: *mut SndSeq, my_port: c_int, client: c_int, port: c_int) -> c_int; + fn snd_seq_disconnect_to(seq: *mut SndSeq, my_port: c_int, client: c_int, port: c_int) + -> c_int; + fn snd_seq_connect_from(seq: *mut SndSeq, my_port: c_int, client: c_int, port: c_int) -> c_int; + fn snd_seq_disconnect_from( + seq: *mut SndSeq, + my_port: c_int, + client: c_int, + port: c_int, + ) -> c_int; + fn snd_seq_event_output(seq: *mut SndSeq, ev: *mut SeqEvent) -> c_int; + fn snd_seq_drain_output(seq: *mut SndSeq) -> c_int; + fn snd_seq_event_input(seq: *mut SndSeq, ev: *mut *mut SeqEvent) -> c_int; + fn snd_seq_poll_descriptors_count(seq: *mut SndSeq, events: c_short) -> c_int; + fn snd_seq_poll_descriptors( + seq: *mut SndSeq, + pfds: *mut libc::pollfd, + space: c_uint, + events: c_short, + ) -> c_int; + + fn snd_seq_client_info_malloc(ptr: *mut *mut SndSeqClientInfo) -> c_int; + fn snd_seq_client_info_free(ptr: *mut SndSeqClientInfo); + fn snd_seq_client_info_set_client(info: *mut SndSeqClientInfo, client: c_int); + fn snd_seq_client_info_get_client(info: *const SndSeqClientInfo) -> c_int; + fn snd_seq_client_info_get_name(info: *mut SndSeqClientInfo) -> *const c_char; + fn snd_seq_query_next_client(seq: *mut SndSeq, info: *mut SndSeqClientInfo) -> c_int; + + fn snd_seq_port_info_malloc(ptr: *mut *mut SndSeqPortInfo) -> c_int; + fn snd_seq_port_info_free(ptr: *mut SndSeqPortInfo); + fn snd_seq_port_info_set_client(info: *mut SndSeqPortInfo, client: c_int); + fn snd_seq_port_info_set_port(info: *mut SndSeqPortInfo, port: c_int); + fn snd_seq_port_info_get_port(info: *const SndSeqPortInfo) -> c_int; + fn snd_seq_port_info_get_name(info: *const SndSeqPortInfo) -> *const c_char; + fn snd_seq_port_info_get_capability(info: *const SndSeqPortInfo) -> c_uint; + fn snd_seq_query_next_port(seq: *mut SndSeq, info: *mut SndSeqPortInfo) -> c_int; + + fn snd_midi_event_new(bufsize: usize, rdev: *mut *mut SndMidiEvent) -> c_int; + fn snd_midi_event_free(dev: *mut SndMidiEvent); + fn snd_midi_event_no_status(dev: *mut SndMidiEvent, on: c_int); + fn snd_midi_event_reset_encode(dev: *mut SndMidiEvent); + fn snd_midi_event_encode( + dev: *mut SndMidiEvent, + buf: *const u8, + count: c_long, + ev: *mut SeqEvent, + ) -> c_long; + fn snd_midi_event_decode( + dev: *mut SndMidiEvent, + buf: *mut u8, + count: c_long, + ev: *const SeqEvent, + ) -> c_long; +} + +// --- helpers ------------------------------------------------------------ + +/// A resolved host endpoint: where to connect, plus the display name to show. +#[derive(Clone)] +struct Endpoint { + client: c_int, + port: c_int, + name: String, +} + +/// Copy a C string into a Rust `String`. +fn cstr_to_string(p: *const c_char) -> Option { + if p.is_null() { + return None; + } + unsafe { CStr::from_ptr(p).to_str().ok().map(String::from) } +} + +/// Open a sequencer client on the default device. Duplex because even the input +/// handle sends subscription commands, and the output handle drives the queue. +fn open_seq() -> Result<*mut SndSeq> { + let name = CString::new("default").unwrap(); + let mut seq: *mut SndSeq = std::ptr::null_mut(); + let rc = unsafe { snd_seq_open(&mut seq, name.as_ptr(), SND_SEQ_OPEN_DUPLEX, 0) }; + if rc < 0 || seq.is_null() { + bail!("snd_seq_open failed ({rc})"); + } + Ok(seq) +} + +/// Our own client ids, so [`query_ports`] hides Copperline's ports from the +/// device list (they would otherwise appear as a source and a destination). +/// Reset each time a backend opens; only one is live at a time. +fn own_clients() -> &'static Mutex> { + static OWN: OnceLock>> = OnceLock::new(); + OWN.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Every port on the system whose capabilities include `caps`, skipping our own +/// client. Opens a short-lived handle, so it is safe from any thread and always +/// reflects the current device set (freshly connected devices included). +fn query_ports(caps: c_uint) -> Vec { + let mut out = Vec::new(); + let Ok(seq) = open_seq() else { + return out; + }; + let own = own_clients().lock().unwrap().clone(); + unsafe { + let mut cinfo: *mut SndSeqClientInfo = std::ptr::null_mut(); + let mut pinfo: *mut SndSeqPortInfo = std::ptr::null_mut(); + if snd_seq_client_info_malloc(&mut cinfo) >= 0 && snd_seq_port_info_malloc(&mut pinfo) >= 0 + { + snd_seq_client_info_set_client(cinfo, -1); + while snd_seq_query_next_client(seq, cinfo) >= 0 { + let client = snd_seq_client_info_get_client(cinfo); + if client == SND_SEQ_CLIENT_SYSTEM || own.contains(&client) { + continue; + } + let cname = cstr_to_string(snd_seq_client_info_get_name(cinfo)).unwrap_or_default(); + snd_seq_port_info_set_client(pinfo, client); + snd_seq_port_info_set_port(pinfo, -1); + while snd_seq_query_next_port(seq, pinfo) >= 0 { + if snd_seq_port_info_get_capability(pinfo) & caps != caps { + continue; + } + let port = snd_seq_port_info_get_port(pinfo); + let pname = + cstr_to_string(snd_seq_port_info_get_name(pinfo)).unwrap_or_default(); + out.push(Endpoint { + client, + port, + name: format!("{cname}:{pname}"), + }); + } + } + } + if !cinfo.is_null() { + snd_seq_client_info_free(cinfo); + } + if !pinfo.is_null() { + snd_seq_port_info_free(pinfo); + } + snd_seq_close(seq); + } + out +} + +/// First endpoint whose display name contains `want` (case-insensitive), the +/// same match the CoreMIDI backend uses. +fn find_named(ports: Vec, want: &str) -> Option { + let needle = want.to_lowercase(); + ports + .into_iter() + .find(|e| e.name.to_lowercase().contains(&needle)) +} + +/// Log the first input event so a silent MIDI-in path (nothing arriving) is +/// distinguishable from a consumption problem (arriving but never drained). +fn log_first_input(n: usize) { + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + log::info!("midi: input decoded {n} byte(s) from host"); + } +} + +/// Log a send failure once (errors typically repeat every message). +fn log_send_error(msg: &str) { + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + log::warn!("midi: {msg}"); + } +} + +/// Hand decoded input bytes to the guest, honouring the Active Sensing opt-out. +fn push_input(producer: &mut InputProducer, data: &[u8]) { + if super::strip_active_sense() { + for &b in data { + if b != super::ACTIVE_SENSE { + producer.push_slice(std::slice::from_ref(&b)); + } + } + } else { + producer.push_slice(data); + } +} + +// --- input thread ------------------------------------------------------- + +/// Retarget request handed from the emulation thread to the input thread, which +/// owns the input handle. `None` outer = nothing pending; `Some(None)` = detach; +/// `Some(Some(ep))` = attach to `ep`. +struct InputControl { + request: Mutex>>, + current: Mutex>, + stop: AtomicBool, +} + +/// Move a raw handle across the thread boundary. Only the input thread touches +/// it once handed over, so single-owner access still holds. +struct SeqPtr(*mut SndSeq); +unsafe impl Send for SeqPtr {} + +/// Apply a pending retarget on the input thread. Disconnects the old source and +/// connects the new one, keeping `current` in step for `current_input`. +fn apply_input_request(seq: *mut SndSeq, in_port: c_int, control: &InputControl) { + let Some(target) = control.request.lock().unwrap().take() else { + return; + }; + let mut current = control.current.lock().unwrap(); + if let Some(old) = current.take() { + unsafe { snd_seq_disconnect_from(seq, in_port, old.client, old.port) }; + } + if let Some(ep) = target { + let rc = unsafe { snd_seq_connect_from(seq, in_port, ep.client, ep.port) }; + if rc < 0 { + log::warn!("midi: could not connect input from {:?} ({rc})", ep.name); + } else { + *current = Some(ep); + } + } +} + +/// Input loop: wait for events, decode them to raw MIDI, push to the ring. Polls +/// with a timeout rather than blocking outright so retargets and shutdown are +/// picked up promptly. Owns and closes the input handle. +fn input_loop( + in_seq: SeqPtr, + in_port: c_int, + control: Arc, + mut producer: InputProducer, +) { + let seq = in_seq.0; + let mut parser: *mut SndMidiEvent = std::ptr::null_mut(); + if unsafe { snd_midi_event_new(MIDI_BUF, &mut parser) } < 0 { + log::warn!("midi: could not create ALSA decoder; input disabled"); + unsafe { snd_seq_close(seq) }; + return; + } + // Emit a status byte per message; the guest's framer tracks running status. + unsafe { snd_midi_event_no_status(parser, 1) }; + + let events = libc::POLLIN as c_short; + let nfds = unsafe { snd_seq_poll_descriptors_count(seq, events) }.max(0) as usize; + let mut pfds = vec![ + libc::pollfd { + fd: 0, + events: 0, + revents: 0 + }; + nfds + ]; + if nfds > 0 { + unsafe { snd_seq_poll_descriptors(seq, pfds.as_mut_ptr(), nfds as c_uint, events) }; + } + + let mut buf = [0u8; MIDI_BUF]; + while !control.stop.load(Ordering::Relaxed) { + apply_input_request(seq, in_port, &control); + // Wake at least every 100ms to re-check the stop flag and any retarget. + if nfds > 0 { + unsafe { libc::poll(pfds.as_mut_ptr(), nfds as libc::nfds_t, 100) }; + } else { + std::thread::sleep(Duration::from_millis(5)); + } + // Drain everything the poll made ready; the handle is non-blocking, so + // the loop ends with -EAGAIN once the buffer empties. + loop { + let mut ev: *mut SeqEvent = std::ptr::null_mut(); + if unsafe { snd_seq_event_input(seq, &mut ev) } < 0 || ev.is_null() { + break; + } + let len = + unsafe { snd_midi_event_decode(parser, buf.as_mut_ptr(), buf.len() as c_long, ev) }; + // Non-MIDI events (subscription notices, and the like) decode to a + // negative error and are skipped. + if len > 0 { + let data = &buf[..len as usize]; + log_first_input(data.len()); + push_input(&mut producer, data); + } + } + } + unsafe { + snd_midi_event_free(parser); + snd_seq_close(seq); + } +} + +// --- backend ------------------------------------------------------------ + +pub struct AlsaBackend { + out_seq: *mut SndSeq, + out_port: c_int, + queue: c_int, + // MIDI-byte encoder, touched only by the emulation thread via `send`. + parser: *mut SndMidiEvent, + dest: Option, + // `COPPERLINE_MIDI_IMMEDIATE=1`: dispatch directly ("now") instead of on the + // queue, to isolate a scheduling problem from a routing one. + immediate: bool, + input: Arc, + input_thread: Option>, +} + +// The handles are only ever touched by the thread that owns the backend; the +// input handle lives on its own thread behind `SeqPtr`. +unsafe impl Send for AlsaBackend {} + +impl MidiBackend for AlsaBackend { + fn send(&mut self, data: &[u8], at: Instant) { + if self.dest.is_none() || data.is_empty() { + return; + } + let mut ev = SeqEvent::blank(); + unsafe { snd_midi_event_reset_encode(self.parser) }; + let n = unsafe { + snd_midi_event_encode(self.parser, data.as_ptr(), data.len() as c_long, &mut ev) + }; + if n <= 0 || ev.type_ == SND_SEQ_EVENT_NONE { + log_send_error("snd_midi_event_encode produced no event"); + return; + } + // Route from our port to whoever we are connected to. The encoder may + // have set the length bits in `flags` (SysEx), so only the schedule bits + // are touched below. + ev.source.port = self.out_port as u8; + ev.dest.client = SND_SEQ_ADDRESS_SUBSCRIBERS; + ev.dest.port = SND_SEQ_ADDRESS_UNKNOWN; + if self.immediate { + ev.queue = SND_SEQ_QUEUE_DIRECT; + } else { + // Schedule in real time, relative to the queue's now (== host now): + // a past instant collapses to a zero delay, i.e. as soon as possible. + let delay = at.saturating_duration_since(Instant::now()); + ev.flags = (ev.flags & !(SND_SEQ_TIME_STAMP_MASK | SND_SEQ_TIME_MODE_MASK)) + | SND_SEQ_TIME_STAMP_REAL + | SND_SEQ_TIME_MODE_REL; + ev.time = SeqRealTime { + tv_sec: delay.as_secs() as c_uint, + tv_nsec: delay.subsec_nanos() as c_uint, + }; + ev.queue = self.queue as u8; + } + if unsafe { snd_seq_event_output(self.out_seq, &mut ev) } < 0 { + log_send_error("snd_seq_event_output failed"); + return; + } + if unsafe { snd_seq_drain_output(self.out_seq) } < 0 { + log_send_error("snd_seq_drain_output failed"); + } + } + + fn set_output(&mut self, endpoint: Option<&str>) { + // Drop the current connection first so a retarget never leaves two. + if let Some(old) = self.dest.take() { + unsafe { snd_seq_disconnect_to(self.out_seq, self.out_port, old.client, old.port) }; + } + if let Some(name) = endpoint { + if let Some(ep) = find_named(query_ports(DEST_CAPS), name) { + let rc = + unsafe { snd_seq_connect_to(self.out_seq, self.out_port, ep.client, ep.port) }; + if rc < 0 { + log::warn!("midi: could not connect output to {:?} ({rc})", ep.name); + } else { + self.dest = Some(ep); + } + } + } + } + + fn set_input(&mut self, endpoint: Option<&str>) { + // Resolve on this thread, hand the address to the thread that owns the + // input handle. + let target = endpoint.and_then(|name| find_named(query_ports(SOURCE_CAPS), name)); + *self.input.request.lock().unwrap() = Some(target); + } + + fn current_output(&self) -> Option { + self.dest.as_ref().map(|e| e.name.clone()) + } + + fn current_input(&self) -> Option { + self.input + .current + .lock() + .unwrap() + .as_ref() + .map(|e| e.name.clone()) + } +} + +impl Drop for AlsaBackend { + fn drop(&mut self) { + // Stop and join the input thread (it closes its own handle) before the + // output handle goes. + self.input.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.input_thread.take() { + let _ = t.join(); + } + unsafe { + if !self.parser.is_null() { + snd_midi_event_free(self.parser); + } + if !self.out_seq.is_null() { + snd_seq_close(self.out_seq); + } + } + } +} + +pub fn enumerate() -> MidiEndpoints { + MidiEndpoints { + inputs: query_ports(SOURCE_CAPS) + .into_iter() + .map(|e| MidiEndpoint { name: e.name }) + .collect(), + outputs: query_ports(DEST_CAPS) + .into_iter() + .map(|e| MidiEndpoint { name: e.name }) + .collect(), + } +} + +pub fn open( + midi_out: Option<&str>, + midi_in: Option<&str>, + input: InputProducer, +) -> Result> { + // Fresh own-client set for this backend so enumerate() hides our ports. + own_clients().lock().unwrap().clear(); + + let immediate = crate::envcfg::flag("COPPERLINE_MIDI_IMMEDIATE"); + if immediate { + log::info!("midi: immediate send mode (COPPERLINE_MIDI_IMMEDIATE), scheduling bypassed"); + } + + // Output client: our source port plus the real-time queue scheduled sends + // ride. The ports are named so users can spot us in `aconnect`. + let out_seq = open_seq()?; + let client_name = CString::new("Copperline").unwrap(); + unsafe { snd_seq_set_client_name(out_seq, client_name.as_ptr()) }; + own_clients() + .lock() + .unwrap() + .push(unsafe { snd_seq_client_id(out_seq) }); + let out_port = create_port( + out_seq, + "Copperline out", + SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ, + )?; + let queue = unsafe { snd_seq_alloc_queue(out_seq) }; + if queue < 0 { + unsafe { snd_seq_close(out_seq) }; + bail!("snd_seq_alloc_queue failed ({queue})"); + } + // Start the queue clock so scheduled events are actually dispatched. + unsafe { + snd_seq_control_queue(out_seq, queue, SND_SEQ_EVENT_START, 0, std::ptr::null_mut()); + snd_seq_drain_output(out_seq); + } + + let mut parser: *mut SndMidiEvent = std::ptr::null_mut(); + if unsafe { snd_midi_event_new(MIDI_BUF, &mut parser) } < 0 { + unsafe { snd_seq_close(out_seq) }; + bail!("snd_midi_event_new failed"); + } + unsafe { snd_midi_event_no_status(parser, 1) }; + + // Input client, driven on its own thread. Non-blocking so a burst drains + // without the thread ever parking inside the library. + let in_seq = open_seq()?; + unsafe { + snd_seq_set_client_name(in_seq, client_name.as_ptr()); + snd_seq_nonblock(in_seq, 1); + } + own_clients() + .lock() + .unwrap() + .push(unsafe { snd_seq_client_id(in_seq) }); + let in_port = create_port( + in_seq, + "Copperline in", + SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE, + )?; + + let dest = match midi_out { + Some(want) => { + let ep = find_named(query_ports(DEST_CAPS), want) + .ok_or_else(|| anyhow!("no MIDI output endpoint matches {want:?}"))?; + let rc = unsafe { snd_seq_connect_to(out_seq, out_port, ep.client, ep.port) }; + if rc < 0 { + bail!("could not connect MIDI output to {:?} ({rc})", ep.name); + } + log::info!("midi: output connected to {:?}", ep.name); + Some(ep) + } + None => None, + }; + + let control = Arc::new(InputControl { + request: Mutex::new(None), + current: Mutex::new(None), + stop: AtomicBool::new(false), + }); + if let Some(want) = midi_in { + let ep = find_named(query_ports(SOURCE_CAPS), want) + .ok_or_else(|| anyhow!("no MIDI input endpoint matches {want:?}"))?; + let rc = unsafe { snd_seq_connect_from(in_seq, in_port, ep.client, ep.port) }; + if rc < 0 { + bail!("could not connect MIDI input from {:?} ({rc})", ep.name); + } + log::info!("midi: input connected from {:?}", ep.name); + *control.current.lock().unwrap() = Some(ep); + } + if dest.is_none() && control.current.lock().unwrap().is_none() { + log::warn!("[serial] mode = midi but no midi_out/midi_in endpoint selected; MIDI is inert"); + } + + let input_thread = { + let control = Arc::clone(&control); + let in_ptr = SeqPtr(in_seq); + std::thread::Builder::new() + .name("copperline-midi-in".into()) + .spawn(move || input_loop(in_ptr, in_port, control, input)) + .map_err(|e| anyhow!("could not spawn MIDI input thread: {e}"))? + }; + + Ok(Box::new(AlsaBackend { + out_seq, + out_port, + queue, + parser, + dest, + immediate, + input: control, + input_thread: Some(input_thread), + })) +} + +/// Create one of our sequencer ports, generic MIDI plus application type. +fn create_port(seq: *mut SndSeq, name: &str, caps: c_uint) -> Result { + let cname = CString::new(name).unwrap(); + let port = unsafe { + snd_seq_create_simple_port( + seq, + cname.as_ptr(), + caps, + SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION, + ) + }; + if port < 0 { + unsafe { snd_seq_close(seq) }; + bail!("snd_seq_create_simple_port {name:?} failed ({port})"); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + use ringbuf::traits::{Consumer, Split}; + use ringbuf::HeapRb; + + /// Round-trip a scheduled message through the kernel "Midi Through" port, + /// which echoes what it is sent back to its readers. Proves the whole live + /// path at once: queue scheduling, the output subscription, input decode, and + /// the ring push -- and that the schedule is honoured rather than collapsed to + /// "now". Ignored by default: it needs a running sequencer with the + /// snd-seq-dummy "Midi Through" client, so run it explicitly with + /// `cargo test --features midi -- --ignored`. + #[test] + #[ignore] + fn midi_through_loopback() { + const THROUGH: &str = "Midi Through"; + let (producer, mut consumer) = HeapRb::::new(64).split(); + let mut backend = match open(Some(THROUGH), Some(THROUGH), producer) { + Ok(b) => b, + Err(e) => { + eprintln!("skipping midi_through_loopback: {e}"); + return; + } + }; + + // Schedule far enough ahead to clear the input thread's poll interval, so + // a broken schedule (delivered immediately) is distinguishable from a + // honoured one purely by arrival time. + let note = [0x90u8, 0x3C, 0x64]; + let lead = Duration::from_millis(300); + let sent = Instant::now(); + backend.send(¬e, sent + lead); + + let mut got = Vec::new(); + let mut arrived = None; + let deadline = Instant::now() + Duration::from_secs(2); + while got.len() < note.len() && Instant::now() < deadline { + match consumer.try_pop() { + Some(b) => { + arrived.get_or_insert_with(Instant::now); + got.push(b); + } + None => std::thread::sleep(Duration::from_millis(1)), + } + } + + assert_eq!(got, note, "looped-back bytes should match what was sent"); + let delay = arrived.unwrap().duration_since(sent); + assert!( + delay >= Duration::from_millis(250), + "scheduled send arrived too early ({delay:?}); timing was not honoured" + ); + } +} diff --git a/src/midi/coremidi.rs b/src/midi/coremidi.rs new file mode 100644 index 0000000..da613e6 --- /dev/null +++ b/src/midi/coremidi.rs @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! macOS CoreMIDI backend, linked straight against the CoreMIDI and +//! CoreFoundation frameworks with no wrapper crate. Output carries a CoreMIDI +//! packet timestamp, so the MIDIServer delivers each byte at its scheduled +//! instant no matter when the emulation thread called `MIDISend`. That is what +//! keeps the guest's byte timing intact. Input arrives on a CoreMIDI thread and +//! is pushed to the lock-free ring Paula's receiver drains. + +use std::ffi::{c_char, c_void, CStr, CString}; +use std::sync::OnceLock; +use std::time::Instant; + +use anyhow::{anyhow, bail, Result}; +use ringbuf::traits::Producer; + +use super::{InputProducer, MidiBackend, MidiEndpoint, MidiEndpoints}; + +// --- CoreMIDI / CoreFoundation FFI types -------------------------------- + +type OSStatus = i32; +type MidiObjectRef = u32; +type MidiClientRef = MidiObjectRef; +type MidiPortRef = MidiObjectRef; +type MidiEndpointRef = MidiObjectRef; +type MidiTimeStamp = u64; +type ItemCount = usize; +type ByteCount = usize; +type CFStringRef = *const c_void; +type CFAllocatorRef = *const c_void; +type CFTypeRef = *const c_void; +type CFIndex = isize; +type Boolean = u8; + +const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + +// MIDIPacket / MIDIPacketList mirror the C layout. The `data` array is nominal: +// a real packet carries `length` bytes contiguously from `data`, which may be +// more or fewer than 256. Packet construction goes through the SDK's +// Init/Add helpers, so the only manual layout use is walking received packets. +// +// CoreMIDI packs these to 4 bytes (`#pragma pack(4)`), so `packet` follows +// `num_packets` with no 8-byte alignment padding: the first packet is at +// offset 4, not 8. Natural alignment here would mis-locate every received +// packet and read zeros. +#[repr(C, packed(4))] +struct MidiPacket { + time_stamp: MidiTimeStamp, + length: u16, + data: [u8; 256], +} + +#[repr(C, packed(4))] +struct MidiPacketList { + num_packets: u32, + packet: [MidiPacket; 1], +} + +#[repr(C)] +struct MachTimebaseInfo { + numer: u32, + denom: u32, +} + +type MidiReadProc = + extern "C" fn(pkt_list: *const MidiPacketList, read_ref: *mut c_void, src_ref: *mut c_void); +type MidiNotifyProc = extern "C" fn(message: *const c_void, ref_con: *mut c_void); + +#[link(name = "CoreMIDI", kind = "framework")] +extern "C" { + static kMIDIPropertyDisplayName: CFStringRef; + + fn MIDIClientCreate( + name: CFStringRef, + notify_proc: Option, + notify_ref_con: *mut c_void, + out_client: *mut MidiClientRef, + ) -> OSStatus; + fn MIDIClientDispose(client: MidiClientRef) -> OSStatus; + fn MIDIOutputPortCreate( + client: MidiClientRef, + port_name: CFStringRef, + out_port: *mut MidiPortRef, + ) -> OSStatus; + fn MIDIInputPortCreate( + client: MidiClientRef, + port_name: CFStringRef, + read_proc: MidiReadProc, + ref_con: *mut c_void, + out_port: *mut MidiPortRef, + ) -> OSStatus; + fn MIDIPortConnectSource( + port: MidiPortRef, + source: MidiEndpointRef, + conn_ref_con: *mut c_void, + ) -> OSStatus; + fn MIDIPortDisconnectSource(port: MidiPortRef, source: MidiEndpointRef) -> OSStatus; + fn MIDIGetNumberOfSources() -> ItemCount; + fn MIDIGetSource(index: ItemCount) -> MidiEndpointRef; + fn MIDIGetNumberOfDestinations() -> ItemCount; + fn MIDIGetDestination(index: ItemCount) -> MidiEndpointRef; + fn MIDIObjectGetStringProperty( + obj: MidiObjectRef, + property_id: CFStringRef, + out_str: *mut CFStringRef, + ) -> OSStatus; + fn MIDISend( + port: MidiPortRef, + dest: MidiEndpointRef, + pkt_list: *const MidiPacketList, + ) -> OSStatus; + fn MIDIPacketListInit(pkt_list: *mut MidiPacketList) -> *mut MidiPacket; + fn MIDIPacketListAdd( + pkt_list: *mut MidiPacketList, + list_size: ByteCount, + cur_packet: *mut MidiPacket, + time: MidiTimeStamp, + n_data: ByteCount, + data: *const u8, + ) -> *mut MidiPacket; +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFStringCreateWithCString( + alloc: CFAllocatorRef, + c_str: *const c_char, + encoding: u32, + ) -> CFStringRef; + fn CFStringGetCStringPtr(the_string: CFStringRef, encoding: u32) -> *const c_char; + fn CFStringGetCString( + the_string: CFStringRef, + buffer: *mut c_char, + buffer_size: CFIndex, + encoding: u32, + ) -> Boolean; + fn CFRelease(cf: CFTypeRef); +} + +// mach timebase lives in libSystem, which is always linked. +extern "C" { + fn mach_absolute_time() -> u64; + fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32; +} + +// --- helpers ------------------------------------------------------------ + +/// Wrap a Rust string as a CFString for the CoreMIDI name arguments. The caller +/// must `CFRelease` the result. +fn cfstring(s: &str) -> CFStringRef { + let c = CString::new(s).unwrap_or_default(); + unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), K_CF_STRING_ENCODING_UTF8) } +} + +/// Copy a CFString into a Rust String. +fn cfstring_to_string(s: CFStringRef) -> Option { + if s.is_null() { + return None; + } + unsafe { + let ptr = CFStringGetCStringPtr(s, K_CF_STRING_ENCODING_UTF8); + if !ptr.is_null() { + return CStr::from_ptr(ptr).to_str().ok().map(String::from); + } + // No direct pointer available (the common case): copy into a buffer. + let mut buf = [0 as c_char; 512]; + if CFStringGetCString( + s, + buf.as_mut_ptr(), + buf.len() as CFIndex, + K_CF_STRING_ENCODING_UTF8, + ) != 0 + { + return CStr::from_ptr(buf.as_ptr()).to_str().ok().map(String::from); + } + } + None +} + +/// Read an endpoint's display name. +fn endpoint_name(endpoint: MidiEndpointRef) -> Option { + if endpoint == 0 { + return None; + } + let mut cf: CFStringRef = std::ptr::null(); + let status = + unsafe { MIDIObjectGetStringProperty(endpoint, kMIDIPropertyDisplayName, &mut cf) }; + if status != 0 || cf.is_null() { + return None; + } + let name = cfstring_to_string(cf); + unsafe { CFRelease(cf) }; + name +} + +fn sources() -> impl Iterator { + (0..unsafe { MIDIGetNumberOfSources() }) + .map(|i| unsafe { MIDIGetSource(i) }) + .filter_map(|e| endpoint_name(e).map(|n| (e, n))) +} + +fn destinations() -> impl Iterator { + (0..unsafe { MIDIGetNumberOfDestinations() }) + .map(|i| unsafe { MIDIGetDestination(i) }) + .filter_map(|e| endpoint_name(e).map(|n| (e, n))) +} + +fn find_endpoint( + it: impl Iterator, + want: &str, +) -> Option { + let needle = want.to_lowercase(); + it.filter(|(_, name)| name.to_lowercase().contains(&needle)) + .map(|(e, _)| e) + .next() +} + +/// Cached mach timebase: mach ticks -> nanoseconds is `ticks * numer / denom`. +fn timebase() -> (u64, u64) { + static TB: OnceLock<(u64, u64)> = OnceLock::new(); + *TB.get_or_init(|| { + let mut info = MachTimebaseInfo { numer: 0, denom: 0 }; + unsafe { mach_timebase_info(&mut info) }; + let numer = if info.numer == 0 { + 1 + } else { + info.numer as u64 + }; + let denom = if info.denom == 0 { + 1 + } else { + info.denom as u64 + }; + (numer, denom) + }) +} + +/// Convert a host [`Instant`] into a CoreMIDI timestamp (mach absolute time). +/// A timestamp of 0 means "now"; an instant already in the past collapses to it. +fn host_timestamp(at: Instant) -> MidiTimeStamp { + match at.checked_duration_since(Instant::now()) { + None => 0, + Some(delta) => { + let (numer, denom) = timebase(); + // nanoseconds -> mach ticks is the inverse of ticks -> ns. + let ns = delta.as_nanos(); + let ticks = ns.saturating_mul(denom as u128) / (numer as u128); + unsafe { mach_absolute_time() }.saturating_add(ticks as u64) + } + } +} + +// --- input callback ----------------------------------------------------- + +/// Kept alive for the port's lifetime; its address is the read-callback refCon. +/// Only the CoreMIDI callback thread touches the producer, so the single-writer +/// invariant of the SPSC ring holds. +struct InputState { + producer: InputProducer, +} + +/// Address of the next packet after one of `length` data bytes. Packets are +/// 4-byte aligned (pack(4)), so the end of the data is rounded up to 4. +unsafe fn next_packet(pkt: *const MidiPacket, length: usize) -> *const MidiPacket { + // offsetof(MIDIPacket, data) = 8 (timeStamp) + 2 (length). + let offset = (10 + length + 3) & !3; + (pkt as *const u8).add(offset) as *const MidiPacket +} + +extern "C" fn read_proc(pkt_list: *const MidiPacketList, read_ref: *mut c_void, _src: *mut c_void) { + if pkt_list.is_null() || read_ref.is_null() { + return; + } + // Sole writer: CoreMIDI serializes a port's read callback. Read the packed + // fields through raw pointers -- a reference to a misaligned field is UB. + let state = unsafe { &mut *(read_ref as *mut InputState) }; + let num_packets = unsafe { std::ptr::addr_of!((*pkt_list).num_packets).read_unaligned() }; + let mut pkt = unsafe { std::ptr::addr_of!((*pkt_list).packet) as *const MidiPacket }; + for _ in 0..num_packets { + let length = unsafe { std::ptr::addr_of!((*pkt).length).read_unaligned() } as usize; + let data_ptr = unsafe { std::ptr::addr_of!((*pkt).data) as *const u8 }; + let data = unsafe { std::slice::from_raw_parts(data_ptr, length) }; + log_first_input(length); + // Overflow (guest not draining fast enough) drops the tail, mirroring a + // real UART receive overrun rather than blocking the MIDI thread. + // Active Sensing is forwarded by default; only dropped when opted in + // (see strip_active_sense). + if super::strip_active_sense() { + for &b in data { + if b != super::ACTIVE_SENSE { + state.producer.push_slice(std::slice::from_ref(&b)); + } + } + } else { + state.producer.push_slice(data); + } + pkt = unsafe { next_packet(pkt, length) }; + } +} + +/// Log the first input callback so a silent MIDI-in path (nothing arriving from +/// the host) is distinguishable from a consumption problem (arriving but the +/// guest's serial receiver never runs). +fn log_first_input(n: usize) { + static LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if !LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::info!("midi: input callback received {n} byte(s) from host"); + } +} + +/// Log a send failure once (errors typically repeat every byte, so only the +/// first is worth surfacing). +fn log_send_error(msg: &str) { + static LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if !LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!("midi: {msg}"); + } +} + +// --- backend ------------------------------------------------------------ + +pub struct CoreMidiBackend { + client: MidiClientRef, + // Both ports are created up front so the device can be switched at runtime + // without making ports mid-session. `dest`/`source` hold the current + // endpoints (0 = none); switching just retargets them. + out_port: MidiPortRef, + dest: MidiEndpointRef, + in_port: MidiPortRef, + source: MidiEndpointRef, + // `COPPERLINE_MIDI_IMMEDIATE=1`: send with timestamp 0 ("now") instead of a + // scheduled host time, to isolate a scheduling problem from a routing one. + immediate: bool, + // Kept alive so the read-callback refCon stays valid; dropped after the + // client is disposed (see Drop). + _input_state: Box, +} + +// The stored handles are integer references and a ring producer; the raw pointer +// handed to CoreMIDI is derived from the boxed state, which outlives the port. +// Safe to move between threads. +unsafe impl Send for CoreMidiBackend {} + +impl MidiBackend for CoreMidiBackend { + fn send(&mut self, data: &[u8], at: Instant) { + if self.dest == 0 || data.is_empty() { + return; + } + let ts = if self.immediate { + 0 + } else { + host_timestamp(at) + }; + // 8-aligned scratch large enough for one short packet. + let mut storage = [0u64; 128]; + let list = storage.as_mut_ptr() as *mut MidiPacketList; + unsafe { + let cur = MIDIPacketListInit(list); + let cur = MIDIPacketListAdd( + list, + std::mem::size_of_val(&storage), + cur, + ts, + data.len(), + data.as_ptr(), + ); + if cur.is_null() { + log_send_error("MIDIPacketListAdd overflow"); + return; + } + let status = MIDISend(self.out_port, self.dest, list); + if status != 0 { + log_send_error(&format!("MIDISend failed (OSStatus {status})")); + } + } + } + + fn set_output(&mut self, endpoint: Option<&str>) { + self.dest = endpoint + .and_then(|name| find_endpoint(destinations(), name)) + .unwrap_or(0); + } + + fn set_input(&mut self, endpoint: Option<&str>) { + if self.source != 0 { + unsafe { MIDIPortDisconnectSource(self.in_port, self.source) }; + self.source = 0; + } + if let Some(src) = endpoint.and_then(|name| find_endpoint(sources(), name)) { + if unsafe { MIDIPortConnectSource(self.in_port, src, std::ptr::null_mut()) } == 0 { + self.source = src; + } + } + } + + fn current_output(&self) -> Option { + (self.dest != 0).then(|| endpoint_name(self.dest)).flatten() + } + + fn current_input(&self) -> Option { + (self.source != 0) + .then(|| endpoint_name(self.source)) + .flatten() + } +} + +impl Drop for CoreMidiBackend { + fn drop(&mut self) { + if self.client != 0 { + // Disposes the client's ports and stops the read callback before the + // boxed input state is freed. + unsafe { MIDIClientDispose(self.client) }; + } + } +} + +pub fn enumerate() -> MidiEndpoints { + MidiEndpoints { + inputs: sources().map(|(_, name)| MidiEndpoint { name }).collect(), + outputs: destinations() + .map(|(_, name)| MidiEndpoint { name }) + .collect(), + } +} + +pub fn open( + midi_out: Option<&str>, + midi_in: Option<&str>, + input: InputProducer, +) -> Result> { + let client_name = cfstring("Copperline"); + let mut client: MidiClientRef = 0; + let status = unsafe { MIDIClientCreate(client_name, None, std::ptr::null_mut(), &mut client) }; + unsafe { CFRelease(client_name) }; + if status != 0 { + bail!("MIDIClientCreate failed (OSStatus {status})"); + } + + let immediate = crate::envcfg::flag("COPPERLINE_MIDI_IMMEDIATE"); + if immediate { + log::info!("midi: immediate send mode (COPPERLINE_MIDI_IMMEDIATE), scheduling bypassed"); + } + + // Create both ports now so the runtime menu can retarget devices without + // creating ports mid-session. The input state (and its ring producer) is + // kept even when no source is connected yet. + let out_port = create_port(client, "Copperline out", |c, n, p| unsafe { + MIDIOutputPortCreate(c, n, p) + })?; + let state = Box::new(InputState { producer: input }); + let ref_con = &*state as *const InputState as *mut c_void; + let in_port = create_port(client, "Copperline in", |c, n, p| unsafe { + MIDIInputPortCreate(c, n, read_proc, ref_con, p) + })?; + + let mut backend = CoreMidiBackend { + client, + out_port, + dest: 0, + in_port, + source: 0, + immediate, + _input_state: state, + }; + + if let Some(want) = midi_out { + backend.dest = find_endpoint(destinations(), want) + .ok_or_else(|| anyhow!("no MIDI output endpoint matches {want:?}"))?; + log::info!("midi: output connected to {:?}", backend.output_name()); + } + if let Some(want) = midi_in { + let src = find_endpoint(sources(), want) + .ok_or_else(|| anyhow!("no MIDI input endpoint matches {want:?}"))?; + if unsafe { MIDIPortConnectSource(in_port, src, std::ptr::null_mut()) } != 0 { + bail!("MIDIPortConnectSource failed"); + } + backend.source = src; + log::info!("midi: input connected to {:?}", backend.input_name()); + } + if backend.dest == 0 && backend.source == 0 { + log::warn!("[serial] mode = midi but no midi_out/midi_in endpoint selected; MIDI is inert"); + } + + Ok(Box::new(backend)) +} + +impl CoreMidiBackend { + fn output_name(&self) -> String { + endpoint_name(self.dest).unwrap_or_default() + } + fn input_name(&self) -> String { + endpoint_name(self.source).unwrap_or_default() + } +} + +/// Create a CoreMIDI port with a temporary CFString name. +fn create_port( + client: MidiClientRef, + name: &str, + make: impl Fn(MidiClientRef, CFStringRef, *mut MidiPortRef) -> OSStatus, +) -> Result { + let cf = cfstring(name); + let mut port: MidiPortRef = 0; + let status = make(client, cf, &mut port); + unsafe { CFRelease(cf) }; + if status != 0 { + bail!("MIDI port create failed for {name:?} (OSStatus {status})"); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + use ringbuf::traits::{Consumer, Split}; + use ringbuf::HeapRb; + use std::time::Duration; + + /// Round-trip a scheduled message through a loopback port (an IAC Driver bus, + /// or whatever `COPPERLINE_MIDI_TEST_PORT` names). Proves the whole live path + /// at once -- the packet timestamp, output to the destination, the input + /// callback, and the ring push -- and that the schedule is honoured rather + /// than collapsed to "now". Ignored by default: it needs a loopback port with + /// the same name visible as both a source and a destination (enable an IAC bus + /// in Audio MIDI Setup), so run it explicitly with + /// `cargo test --features midi -- --ignored`. + #[test] + #[ignore] + fn midi_through_loopback() { + let port = std::env::var("COPPERLINE_MIDI_TEST_PORT") + .unwrap_or_else(|_| "IAC Driver Bus 1".into()); + let (producer, mut consumer) = HeapRb::::new(64).split(); + let mut backend = match open(Some(&port), Some(&port), producer) { + Ok(b) => b, + Err(e) => { + eprintln!("skipping midi_through_loopback: {e}"); + return; + } + }; + + // Schedule far enough ahead that a broken schedule (delivered now) is + // distinguishable from an honoured one purely by arrival time. + let note = [0x90u8, 0x3C, 0x64]; + let lead = Duration::from_millis(300); + let sent = Instant::now(); + backend.send(¬e, sent + lead); + + let mut got = Vec::new(); + let mut arrived = None; + let deadline = Instant::now() + Duration::from_secs(2); + while got.len() < note.len() && Instant::now() < deadline { + match consumer.try_pop() { + Some(b) => { + arrived.get_or_insert_with(Instant::now); + got.push(b); + } + None => std::thread::sleep(Duration::from_millis(1)), + } + } + + assert_eq!(got, note, "looped-back bytes should match what was sent"); + let delay = arrived.unwrap().duration_since(sent); + assert!( + delay >= Duration::from_millis(250), + "scheduled send arrived too early ({delay:?}); timing was not honoured" + ); + } +} diff --git a/src/midi/mod.rs b/src/midi/mod.rs new file mode 100644 index 0000000..3e4120a --- /dev/null +++ b/src/midi/mod.rs @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Host MIDI bridge for Paula's serial port (`[serial] mode = "midi"`). +//! +//! On output, each serial byte is stamped with the emulated colour clock it left +//! the wire on (see [`crate::serial::SerialTimeAnchor`]) and scheduled at the +//! matching host time, so the guest's byte timing survives to the host instead of +//! collapsing to the moment a frame's worth of bytes happens to be flushed. On +//! input, the backend fills a ring the receiver drains at the emulated baud rate. +//! +//! The input ring is a lock-free `ringbuf` SPSC queue (producer on the CoreMIDI +//! thread, consumer on the emulation thread). Paula's serial idle fast path polls +//! `has_pending_input` on nearly every device tick, so that poll must not lock. +//! +//! The emulator core only sees the [`SerialSink`]. The platform connection lives +//! behind [`MidiBackend`], chosen by `cfg(target_os)`: macOS drives CoreMIDI, +//! Linux the ALSA sequencer, and other targets get a stub until their backend +//! exists. + +use std::time::Instant; + +use anyhow::Result; +use ringbuf::traits::{Consumer, Observer, Split}; +use ringbuf::HeapRb; + +use crate::serial::{SerialSink, SerialTimeAnchor}; + +#[cfg(target_os = "macos")] +mod coremidi; +#[cfg(target_os = "macos")] +use coremidi as backend; + +#[cfg(target_os = "linux")] +mod alsa; +#[cfg(target_os = "linux")] +use alsa as backend; + +#[cfg(target_os = "windows")] +mod winmm; +#[cfg(target_os = "windows")] +use winmm as backend; + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +mod stub; +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +use stub as backend; + +/// Capacity of the host-to-guest input ring. A MIDI stream is a few KB/s and +/// Paula drains it every serial tick, so this only absorbs a burst (a SysEx +/// dump, say) between drains. +const INPUT_RING_BYTES: usize = 8192; + +/// Lock-free producer/consumer halves of the input ring. +pub type InputProducer = ringbuf::HeapProd; +type InputConsumer = ringbuf::HeapCons; + +/// A host MIDI endpoint the user can select by name. +#[derive(Clone, Debug)] +pub struct MidiEndpoint { + pub name: String, +} + +/// Available host endpoints, split by direction. +#[derive(Clone, Debug, Default)] +pub struct MidiEndpoints { + /// Sources: endpoints that send MIDI to us (the Amiga's MIDI input). + pub inputs: Vec, + /// Destinations: endpoints we send MIDI to (the Amiga's MIDI output). + pub outputs: Vec, +} + +/// A live platform MIDI connection. Output goes through [`send`]; input is +/// delivered by the backend into the ring producer handed to it at open time. +/// +/// [`send`]: MidiBackend::send +pub trait MidiBackend: Send { + /// Schedule `data` for delivery at host instant `at`. Best effort: a past + /// instant is sent as soon as possible. + fn send(&mut self, data: &[u8], at: Instant); + + /// Retarget output to the named endpoint, or `None` to stop sending. + fn set_output(&mut self, endpoint: Option<&str>); + /// Retarget input to the named endpoint, or `None` to stop receiving. + fn set_input(&mut self, endpoint: Option<&str>); + /// Name of the current output endpoint, if any. + fn current_output(&self) -> Option; + /// Name of the current input endpoint, if any. + fn current_input(&self) -> Option; +} + +/// Step a device selection through "None" then the named endpoints, returning +/// the new choice. Shared by the launcher picker and the runtime menu. +pub(crate) fn next_endpoint( + current: Option<&str>, + names: &[String], + forward: bool, +) -> Option { + let here = current + .and_then(|c| names.iter().position(|n| n == c)) + .map_or(0, |i| i + 1); + let count = names.len() + 1; + let next = if forward { + (here + 1) % count + } else { + (here + count - 1) % count + }; + (next > 0).then(|| names[next - 1].clone()) +} + +/// Enumerate host MIDI endpoints for the device picker and `--list-midi`. +pub fn enumerate() -> MidiEndpoints { + backend::enumerate() +} + +/// The [`SerialSink`] used for `[serial] mode = "midi"`. Bridges Paula's serial +/// port to the selected host MIDI endpoints. +pub struct MidiSerialSink { + backend: Box, + anchor: Option, + input: InputConsumer, + framer: MidiFramer, + debug: Option, +} + +/// MIDI Active Sensing status byte. +pub(crate) const ACTIVE_SENSE: u8 = 0xFE; + +/// Whether to drop Active Sensing (0xFE) at the bridge. A real Amiga passes it +/// straight down the serial line, so the faithful default is to forward it. Some +/// host MIDI interfaces do strip it, so `COPPERLINE_MIDI_STRIP_ACTIVE_SENSE=1` +/// opts into that behaviour. +pub(crate) fn strip_active_sense() -> bool { + static STRIP: std::sync::OnceLock = std::sync::OnceLock::new(); + *STRIP.get_or_init(|| crate::envcfg::flag("COPPERLINE_MIDI_STRIP_ACTIVE_SENSE")) +} + +/// One-line description of a complete MIDI message for the debug trace. +fn describe(msg: &[u8]) -> String { + let Some(&status) = msg.first() else { + return "(empty)".to_string(); + }; + let d = |i: usize| { + msg.get(i) + .map_or_else(|| "?".to_string(), |v| v.to_string()) + }; + let ch = (status & 0x0F) + 1; + match status & 0xF0 { + 0x80 => format!("Note Off ch{ch} note {} vel {}", d(1), d(2)), + 0x90 if msg.get(2) == Some(&0) => format!("Note Off ch{ch} note {} (vel 0)", d(1)), + 0x90 => format!("Note On ch{ch} note {} vel {}", d(1), d(2)), + 0xA0 => format!("Poly AT ch{ch} note {} press {}", d(1), d(2)), + 0xB0 => format!("Control ch{ch} cc {} val {}", d(1), d(2)), + 0xC0 => format!("Program ch{ch} {}", d(1)), + 0xD0 => format!("Chan AT ch{ch} {}", d(1)), + 0xE0 => format!("Pitch Bend ch{ch} lsb {} msb {}", d(1), d(2)), + 0xF0 => match status { + 0xF0 => format!("SysEx ({} bytes)", msg.len()), + 0xF1 => format!("MTC Quarter Frame {}", d(1)), + 0xF2 => "Song Position".to_string(), + 0xF3 => format!("Song Select {}", d(1)), + 0xF6 => "Tune Request".to_string(), + 0xF8 => "Clock".to_string(), + 0xFA => "Start".to_string(), + 0xFB => "Continue".to_string(), + 0xFC => "Stop".to_string(), + 0xFE => "Active Sense".to_string(), + 0xFF => "Reset".to_string(), + _ => format!("System 0x{status:02x}"), + }, + _ => format!("data {}", hex_bytes(msg)), + } +} + +/// Space-separated hex of a byte slice, for the debug trace. +fn hex_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +/// Total length in bytes of a channel-voice or system message from its status +/// byte. SysEx (`0xF0`) is variable and handled separately; real-time bytes +/// (`>= 0xF8`) are always one byte. +fn message_len(status: u8) -> usize { + match status & 0xF0 { + 0x80 | 0x90 | 0xA0 | 0xB0 | 0xE0 => 3, // note off/on, poly AT, CC, pitch bend + 0xC0 | 0xD0 => 2, // program change, channel pressure + 0xF0 => match status { + 0xF2 => 3, // song position pointer + 0xF1 | 0xF3 => 2, // MTC quarter frame, song select + _ => 1, // tune request, undefined F-series + }, + _ => 1, + } +} + +/// Reassembles the guest's serial byte stream into complete MIDI messages, the +/// unit a receiver expects per packet. Sent a byte at a time, a multi-byte +/// message never forms and a receiver rejects each data byte as invalid. Tracks +/// running status and SysEx, and passes interleaved real-time bytes straight +/// through. +#[derive(Default)] +struct MidiFramer { + /// Bytes of the message being assembled. + buf: Vec, + /// Total bytes the current message needs (0 while idle). + expected: usize, + /// Last channel-voice status, for running status (0 = none). + status: u8, + in_sysex: bool, +} + +impl MidiFramer { + /// Feed one serial byte. `emit` is called with each complete message. The + /// buffer is reused across messages, so a settled stream never allocates. + fn push(&mut self, b: u8, at: Instant, mut emit: impl FnMut(&[u8], Instant)) { + // System real-time (0xF8..=0xFF) is a single byte that may appear + // between the bytes of another message without disturbing it. + if b >= 0xF8 { + emit(&[b], at); + return; + } + if self.in_sysex { + if b < 0x80 { + self.buf.push(b); + return; + } + // Any non-real-time status byte ends the running SysEx. + if b == 0xF7 { + self.buf.push(b); + } + emit(&self.buf, at); + self.buf.clear(); + self.in_sysex = false; + if b == 0xF7 { + return; + } + // Fall through to handle b as a fresh status byte. + } + if b >= 0x80 { + if b == 0xF0 { + self.in_sysex = true; + self.buf.clear(); + self.buf.push(b); + self.status = 0; + return; + } + // Channel-voice status arms running status; system common clears it. + self.status = if b < 0xF0 { b } else { 0 }; + self.expected = message_len(b); + self.buf.clear(); + self.buf.push(b); + self.emit_if_complete(at, &mut emit); + return; + } + // Data byte. An empty buffer with a live running status opens a new + // message from it; a data byte with no status is dropped. + if self.buf.is_empty() { + if self.status == 0 { + return; + } + self.buf.push(self.status); + self.expected = message_len(self.status); + } + self.buf.push(b); + self.emit_if_complete(at, &mut emit); + } + + fn emit_if_complete(&mut self, at: Instant, emit: &mut impl FnMut(&[u8], Instant)) { + if self.buf.len() >= self.expected { + emit(&self.buf, at); + self.buf.clear(); + } + } +} + +/// `COPPERLINE_MIDI_DEBUG=1` byte-flow tracing. Counts bytes taken from the +/// serial port (tx) and handed back to the guest (rx), reported about once a +/// second. No tx while a song plays means the guest is not driving the serial +/// port, so the fault is upstream of this sink. +struct MidiDebug { + tx_bytes: u64, + rx_bytes: u64, + last_report: Instant, + /// The most recent transmitted bytes (rolling), to check they read as MIDI + /// (a note-on is `9n nn vv`, e.g. `90 3c 64`). + sample: Vec, + /// `COPPERLINE_MIDI_DEBUG=2`: decode and log every message in each + /// direction, to inspect the actual stream (e.g. Active Sense handling). + verbose: bool, + /// Reassembles received bytes into messages for the verbose log (the guest + /// still gets the raw byte stream). + rx_framer: MidiFramer, +} + +impl MidiDebug { + /// Keep a rolling window of the last transmitted bytes. + fn record_sample(&mut self, msg: &[u8]) { + const KEEP: usize = 24; + for &b in msg { + if self.sample.len() >= KEEP { + self.sample.remove(0); + } + self.sample.push(b); + } + } +} + +impl MidiSerialSink { + /// Open the selected endpoints (case-insensitive substring match on their + /// display names). Both are optional: a machine can send only, receive only, + /// or both. + pub fn open(midi_out: Option<&str>, midi_in: Option<&str>) -> Result { + let (producer, input) = HeapRb::::new(INPUT_RING_BYTES).split(); + let backend = backend::open(midi_out, midi_in, producer)?; + let debug = crate::envcfg::flag("COPPERLINE_MIDI_DEBUG").then(|| { + // Announce the flag: with no traffic the per-byte report never + // fires, so this separates "no bytes" from "tracing off". + log::info!("midi: byte-flow tracing enabled (COPPERLINE_MIDI_DEBUG)"); + let verbose = crate::envcfg::var("COPPERLINE_MIDI_DEBUG").as_deref() == Some("2"); + MidiDebug { + tx_bytes: 0, + rx_bytes: 0, + last_report: Instant::now(), + sample: Vec::new(), + verbose, + rx_framer: MidiFramer::default(), + } + }); + Ok(Self { + backend, + anchor: None, + input, + framer: MidiFramer::default(), + debug, + }) + } + + fn debug_report(&mut self) { + if let Some(dbg) = &mut self.debug { + if dbg.last_report.elapsed().as_secs_f64() >= 1.0 { + eprintln!( + "midi: tx {} bytes, rx {} bytes, first tx: [{}]", + dbg.tx_bytes, + dbg.rx_bytes, + hex_bytes(&dbg.sample) + ); + dbg.last_report = Instant::now(); + } + } + } + + /// Switch the output endpoint to the next host device (used by the runtime + /// menu). The device list is re-read so freshly connected devices appear. + pub fn cycle_output(&mut self, forward: bool) { + let names: Vec = enumerate().outputs.into_iter().map(|e| e.name).collect(); + let next = next_endpoint(self.backend.current_output().as_deref(), &names, forward); + self.backend.set_output(next.as_deref()); + log::info!("midi: output -> {}", self.output_label()); + } + + /// Switch the input endpoint to the next host device. + pub fn cycle_input(&mut self, forward: bool) { + let names: Vec = enumerate().inputs.into_iter().map(|e| e.name).collect(); + let next = next_endpoint(self.backend.current_input().as_deref(), &names, forward); + self.backend.set_input(next.as_deref()); + log::info!("midi: input -> {}", self.input_label()); + } + + /// Current output device name, or "None". + pub fn output_label(&self) -> String { + self.backend + .current_output() + .unwrap_or_else(|| "None".to_string()) + } + + /// Current input device name, or "None". + pub fn input_label(&self) -> String { + self.backend + .current_input() + .unwrap_or_else(|| "None".to_string()) + } +} + +impl SerialSink for MidiSerialSink { + fn write_byte(&mut self, b: u8, at_cck: u64) { + // Faithful by default; only drops Active Sensing when opted in (see + // strip_active_sense). + if b == ACTIVE_SENSE && strip_active_sense() { + return; + } + // Map the emit clock onto host time so the byte is scheduled rather + // than sent now. Until the first anchor arrives, or in an unpaced run, + // deliver immediately. + let at = self + .anchor + .map(|anchor| anchor.host_time(at_cck)) + .unwrap_or_else(Instant::now); + if let Some(dbg) = &mut self.debug { + dbg.tx_bytes += 1; + } + // Assemble whole MIDI messages before sending; a stream of single-byte + // packets is rejected as invalid by receivers. + let backend = &mut self.backend; + let debug = &mut self.debug; + self.framer.push(b, at, |msg, at| { + backend.send(msg, at); + if let Some(dbg) = debug.as_mut() { + if dbg.verbose { + eprintln!("midi out: {}", describe(msg)); + } + dbg.record_sample(msg); + } + }); + self.debug_report(); + } + + fn read_byte(&mut self) -> Option { + let b = self.input.try_pop(); + if let Some(byte) = b { + if let Some(dbg) = &mut self.debug { + dbg.rx_bytes += 1; + if dbg.verbose { + dbg.rx_framer.push(byte, Instant::now(), |msg, _| { + eprintln!("midi in: {}", describe(msg)); + }); + } + } + } + b + } + + fn has_pending_input(&self) -> bool { + !self.input.is_empty() + } + + fn set_time_anchor(&mut self, anchor: SerialTimeAnchor) { + self.anchor = Some(anchor); + } + + fn as_midi(&mut self) -> Option<&mut MidiSerialSink> { + Some(self) + } + + fn flush(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Feed a byte stream through the framer, collecting the complete messages. + fn frame(bytes: &[u8]) -> Vec> { + let mut framer = MidiFramer::default(); + let now = Instant::now(); + let mut out = Vec::new(); + for &b in bytes { + framer.push(b, now, |msg, _| out.push(msg.to_vec())); + } + out + } + + #[test] + fn frames_note_on_from_separate_bytes() { + // The core bug: three serial bytes must become one 3-byte message. + assert_eq!(frame(&[0x90, 0x3C, 0x64]), vec![vec![0x90, 0x3C, 0x64]]); + } + + #[test] + fn running_status_reuses_last_status() { + assert_eq!( + frame(&[0x90, 0x3C, 0x64, 0x40, 0x50]), + vec![vec![0x90, 0x3C, 0x64], vec![0x90, 0x40, 0x50]] + ); + } + + #[test] + fn realtime_byte_interleaves_without_breaking_message() { + // Active Sense (0xFE) between the data bytes of a note-on. + assert_eq!( + frame(&[0x90, 0x3C, 0xFE, 0x64]), + vec![vec![0xFE], vec![0x90, 0x3C, 0x64]] + ); + } + + #[test] + fn program_change_is_two_bytes() { + assert_eq!(frame(&[0xC0, 0x05]), vec![vec![0xC0, 0x05]]); + } + + #[test] + fn sysex_accumulates_until_eox() { + assert_eq!( + frame(&[0xF0, 0x7E, 0x00, 0x06, 0x01, 0xF7]), + vec![vec![0xF0, 0x7E, 0x00, 0x06, 0x01, 0xF7]] + ); + } + + #[test] + fn stray_data_byte_without_status_is_dropped() { + assert!(frame(&[0x3C, 0x64]).is_empty()); + } +} diff --git a/src/midi/stub.rs b/src/midi/stub.rs new file mode 100644 index 0000000..b1b494e --- /dev/null +++ b/src/midi/stub.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Fallback MIDI backend for platforms without a native implementation yet. +//! Enumerates nothing and refuses to open, so `[serial] mode = "midi"` fails +//! with a clear message rather than silently doing nothing. macOS (CoreMIDI), +//! Linux (ALSA sequencer), and Windows (WinMM) have native backends; any other +//! target lands here. + +use anyhow::{bail, Result}; + +use super::{InputProducer, MidiBackend, MidiEndpoints}; + +pub fn enumerate() -> MidiEndpoints { + MidiEndpoints::default() +} + +pub fn open( + _midi_out: Option<&str>, + _midi_in: Option<&str>, + _input: InputProducer, +) -> Result> { + bail!("MIDI is not supported on this platform yet"); +} diff --git a/src/midi/winmm.rs b/src/midi/winmm.rs new file mode 100644 index 0000000..670f955 --- /dev/null +++ b/src/midi/winmm.rs @@ -0,0 +1,862 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Windows WinMM backend, linked straight against `winmm.dll` with no wrapper +//! crate. WinMM's short/long message calls carry no timestamp -- they play a +//! message the instant they are called -- so the scheduled-send contract is +//! honoured by a dedicated host scheduler thread: `send` pushes each message +//! onto a priority queue keyed by its host `Instant` and wakes the thread, which +//! sleeps until the next message is due and only then calls `midiOutShortMsg` / +//! `midiOutLongMsg`. That thread is the direct analogue of a CoreMIDI packet +//! timestamp or an ALSA real-time queue event, and it keeps `send` non-blocking +//! on the emulation thread. `timeBeginPeriod(1)` raises the system timer to ~1 ms +//! so the thread's timed wait is fine-grained rather than the default ~15 ms. +//! +//! Input runs the CoreMIDI model, not the ALSA one: `midiInOpen` with +//! `CALLBACK_FUNCTION` delivers messages on a system-owned thread, and that +//! callback pushes decoded bytes straight into the lock-free SPSC ring Paula's +//! receiver drains. Short messages arrive as `MIM_DATA` (the message packed into +//! a dword); SysEx as `MIM_LONGDATA` into pre-posted buffers that are recycled +//! with `midiInAddBuffer` on each callback. +//! +//! The raw FFI is layout-sensitive: `MIDIHDR`/`MIDIOUTCAPSW`/`MIDIINCAPSW` are +//! pinned to the Win32 headers with compile-time size/offset assertions, the way +//! CoreMIDI's packet list and ALSA's `snd_seq_event_t` are. WinMM is stdcall +//! (`WINAPI`), so every import and the input callback are `extern "system"`, not +//! `extern "C"` -- the wrong convention corrupts the stack. + +use std::cmp::Reverse; +use std::collections::BinaryHeap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Result}; +use ringbuf::traits::Producer; + +use super::{InputProducer, MidiBackend, MidiEndpoint, MidiEndpoints}; + +// --- WinMM FFI ---------------------------------------------------------- + +type Mmresult = u32; +type Hmidiout = *mut c_void; +type Hmidiin = *mut c_void; +// DWORD_PTR: pointer-sized, carries handles/instances/packed callback params. +type DwordPtr = usize; + +const MMSYSERR_NOERROR: Mmresult = 0; + +const CALLBACK_NULL: u32 = 0x0000_0000; +const CALLBACK_FUNCTION: u32 = 0x0003_0000; + +// midiInProc `wMsg` values (mmsystem.h). Only the two data messages matter here. +const MIM_DATA: u32 = 0x03C3; +const MIM_LONGDATA: u32 = 0x03C4; + +// MIDIHDR.dwFlags: set by the driver when it has finished with a buffer. +const MHDR_DONE: u32 = 0x0000_0001; + +// MAXPNAMELEN: length of the szPname device-name field in the caps structs. +const MAXPNAMELEN: usize = 32; + +// The input callback: `void CALLBACK MidiInProc(HMIDIIN, UINT wMsg, +// DWORD_PTR dwInstance, DWORD_PTR dwParam1, DWORD_PTR dwParam2)`. +type MidiInProc = extern "system" fn(Hmidiin, u32, DwordPtr, DwordPtr, DwordPtr); + +// MIDIHDR (mmeapi.h). The multimedia headers pack this struct, so on 64-bit it is +// 112 bytes with lp_next at offset 28, not the 120/32 that natural alignment gives +// -- hence packed(4) and the offset asserts below. Only lp_data, the two length +// fields, and dw_flags are ever read or written; the rest is the driver's. +#[repr(C, packed(4))] +#[allow(dead_code)] // FFI layout mirror: several fields exist only for the driver. +struct MidiHdr { + lp_data: *mut u8, + dw_buffer_length: u32, + dw_bytes_recorded: u32, + dw_user: DwordPtr, + dw_flags: u32, + lp_next: *mut MidiHdr, + reserved: DwordPtr, + dw_offset: u32, + dw_reserved: [DwordPtr; 8], +} + +// MIDIOUTCAPSW (mmeapi.h): device caps in the wide (UTF-16) form. +#[repr(C)] +#[allow(dead_code)] // FFI layout mirror: only szPname is read. +struct MidiOutCapsW { + w_mid: u16, + w_pid: u16, + v_driver_version: u32, + sz_pname: [u16; MAXPNAMELEN], + w_technology: u16, + w_voices: u16, + w_notes: u16, + w_channel_mask: u16, + dw_support: u32, +} + +// MIDIINCAPSW (mmeapi.h). +#[repr(C)] +#[allow(dead_code)] // FFI layout mirror: only szPname is read. +struct MidiInCapsW { + w_mid: u16, + w_pid: u16, + v_driver_version: u32, + sz_pname: [u16; MAXPNAMELEN], + dw_support: u32, +} + +// Pin the mirrors to the SDK layout. The caps structs have no pointers, so their +// size is width-independent; MIDIHDR does, so it is checked per pointer width. +const _: () = { + assert!(std::mem::size_of::() == 84); + assert!(std::mem::offset_of!(MidiOutCapsW, sz_pname) == 8); + assert!(std::mem::size_of::() == 76); + assert!(std::mem::offset_of!(MidiInCapsW, sz_pname) == 8); +}; + +#[cfg(target_pointer_width = "64")] +const _: () = { + use std::mem::offset_of; + // From (64-bit). + assert!(std::mem::size_of::() == 112); + assert!(offset_of!(MidiHdr, lp_data) == 0); + assert!(offset_of!(MidiHdr, dw_buffer_length) == 8); + assert!(offset_of!(MidiHdr, dw_bytes_recorded) == 12); + assert!(offset_of!(MidiHdr, dw_user) == 16); + assert!(offset_of!(MidiHdr, dw_flags) == 24); + assert!(offset_of!(MidiHdr, lp_next) == 28); + assert!(offset_of!(MidiHdr, reserved) == 36); + assert!(offset_of!(MidiHdr, dw_offset) == 44); + assert!(offset_of!(MidiHdr, dw_reserved) == 48); +}; + +#[cfg(target_pointer_width = "32")] +const _: () = { + use std::mem::offset_of; + assert!(std::mem::size_of::() == 64); + assert!(offset_of!(MidiHdr, lp_data) == 0); + assert!(offset_of!(MidiHdr, dw_flags) == 16); + assert!(offset_of!(MidiHdr, lp_next) == 20); +}; + +#[link(name = "winmm")] +extern "system" { + fn midiOutGetNumDevs() -> u32; + fn midiOutGetDevCapsW(device_id: DwordPtr, caps: *mut MidiOutCapsW, size: u32) -> Mmresult; + fn midiOutOpen( + out: *mut Hmidiout, + device_id: u32, + callback: DwordPtr, + instance: DwordPtr, + flags: u32, + ) -> Mmresult; + fn midiOutClose(midi_out: Hmidiout) -> Mmresult; + fn midiOutReset(midi_out: Hmidiout) -> Mmresult; + fn midiOutShortMsg(midi_out: Hmidiout, msg: u32) -> Mmresult; + fn midiOutLongMsg(midi_out: Hmidiout, hdr: *mut MidiHdr, size: u32) -> Mmresult; + fn midiOutPrepareHeader(midi_out: Hmidiout, hdr: *mut MidiHdr, size: u32) -> Mmresult; + fn midiOutUnprepareHeader(midi_out: Hmidiout, hdr: *mut MidiHdr, size: u32) -> Mmresult; + + fn midiInGetNumDevs() -> u32; + fn midiInGetDevCapsW(device_id: DwordPtr, caps: *mut MidiInCapsW, size: u32) -> Mmresult; + fn midiInOpen( + out: *mut Hmidiin, + device_id: u32, + callback: DwordPtr, + instance: DwordPtr, + flags: u32, + ) -> Mmresult; + fn midiInClose(midi_in: Hmidiin) -> Mmresult; + fn midiInStart(midi_in: Hmidiin) -> Mmresult; + fn midiInStop(midi_in: Hmidiin) -> Mmresult; + fn midiInReset(midi_in: Hmidiin) -> Mmresult; + fn midiInPrepareHeader(midi_in: Hmidiin, hdr: *mut MidiHdr, size: u32) -> Mmresult; + fn midiInUnprepareHeader(midi_in: Hmidiin, hdr: *mut MidiHdr, size: u32) -> Mmresult; + fn midiInAddBuffer(midi_in: Hmidiin, hdr: *mut MidiHdr, size: u32) -> Mmresult; + + // Timer resolution, also in winmm: raise the scheduler clock to ~1 ms. + fn timeBeginPeriod(period: u32) -> Mmresult; + fn timeEndPeriod(period: u32) -> Mmresult; +} + +// Ceiling on a single received SysEx message, and how many receive buffers to +// keep posted so a burst is not dropped between callbacks. Matches the ALSA +// backend's decode-buffer size. +const SYSEX_BUF: usize = 4096; +const NUM_SYSEX_BUFFERS: usize = 4; + +// --- helpers ------------------------------------------------------------ + +/// Turn a fixed `szPname` field into a `String`, stopping at the NUL. +fn caps_name(sz: &[u16]) -> String { + let end = sz.iter().position(|&c| c == 0).unwrap_or(sz.len()); + String::from_utf16_lossy(&sz[..end]) +} + +/// Display name of output device `id`, if it can be queried. +fn output_name(id: u32) -> Option { + let mut caps: MidiOutCapsW = unsafe { std::mem::zeroed() }; + let rc = unsafe { + midiOutGetDevCapsW( + id as DwordPtr, + &mut caps, + std::mem::size_of::() as u32, + ) + }; + (rc == MMSYSERR_NOERROR).then(|| caps_name(&caps.sz_pname)) +} + +/// Display name of input device `id`, if it can be queried. +fn input_name(id: u32) -> Option { + let mut caps: MidiInCapsW = unsafe { std::mem::zeroed() }; + let rc = unsafe { + midiInGetDevCapsW( + id as DwordPtr, + &mut caps, + std::mem::size_of::() as u32, + ) + }; + (rc == MMSYSERR_NOERROR).then(|| caps_name(&caps.sz_pname)) +} + +/// First output device whose name contains `want` (case-insensitive), the same +/// match the CoreMIDI and ALSA backends use. Device ids are `0..numDevs`. +fn find_output(want: &str) -> Option<(u32, String)> { + let needle = want.to_lowercase(); + (0..unsafe { midiOutGetNumDevs() }) + .filter_map(|id| output_name(id).map(|n| (id, n))) + .find(|(_, name)| name.to_lowercase().contains(&needle)) +} + +/// First input device whose name contains `want` (case-insensitive). +fn find_input(want: &str) -> Option<(u32, String)> { + let needle = want.to_lowercase(); + (0..unsafe { midiInGetNumDevs() }) + .filter_map(|id| input_name(id).map(|n| (id, n))) + .find(|(_, name)| name.to_lowercase().contains(&needle)) +} + +/// Total bytes of a short (non-SysEx) message from its status byte, so the input +/// callback pushes exactly the valid bytes MIM_DATA packed into its dword and no +/// trailing zeros. Mirrors the shared bridge's `message_len`. +fn short_len(status: u8) -> usize { + match status & 0xF0 { + 0x80 | 0x90 | 0xA0 | 0xB0 | 0xE0 => 3, + 0xC0 | 0xD0 => 2, + 0xF0 => match status { + 0xF2 => 3, + 0xF1 | 0xF3 => 2, + _ => 1, + }, + _ => 1, + } +} + +/// Hand decoded input bytes to the guest, honouring the Active Sensing opt-out +/// exactly like the CoreMIDI and ALSA backends. +fn push_input(producer: &mut InputProducer, data: &[u8]) { + if super::strip_active_sense() { + for &b in data { + if b != super::ACTIVE_SENSE { + producer.push_slice(std::slice::from_ref(&b)); + } + } + } else { + producer.push_slice(data); + } +} + +/// Log the first input callback so a silent MIDI-in path (nothing arriving from +/// the host) is distinguishable from a consumption problem (arriving but the +/// guest's serial receiver never runs). +fn log_first_input(n: usize) { + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + log::info!("midi: input callback received {n} byte(s) from host"); + } +} + +/// Log a send failure once (errors typically repeat every message). +fn log_send_error(msg: &str) { + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + log::warn!("midi: {msg}"); + } +} + +// --- output delivery ---------------------------------------------------- + +/// The current output device. Shared (behind a `Mutex`) between the emulation +/// thread (which retargets it) and the scheduler thread (which sends through it). +/// The handle is stored as a `usize` so the shared state is plainly `Send`; it is +/// only ever a WinMM `HMIDIOUT`, cast back at the call sites below. +struct OutputState { + handle: usize, + name: Option, +} + +/// Play one message on the current output *now*. Called by the scheduler thread +/// when a message comes due, and directly by `send` under +/// `COPPERLINE_MIDI_IMMEDIATE`. Holding the lock across the call serialises with +/// a device switch, so the handle can never be closed mid-send. +fn deliver(out: &Mutex, data: &[u8]) { + if data.is_empty() { + return; + } + let state = out.lock().unwrap(); + if state.handle == 0 { + return; + } + let handle = state.handle as Hmidiout; + if data[0] == 0xF0 { + deliver_long(handle, data); + } else { + // Status in the low byte, then data1, data2, packed little-endian. + let mut msg = 0u32; + for (i, &b) in data.iter().take(3).enumerate() { + msg |= (b as u32) << (8 * i as u32); + } + let rc = unsafe { midiOutShortMsg(handle, msg) }; + if rc != MMSYSERR_NOERROR { + log_send_error(&format!("midiOutShortMsg failed ({rc})")); + } + } +} + +/// Send a SysEx message via `midiOutLongMsg`. The send is asynchronous and the +/// buffer must outlive it, so the buffer and header sit on this stack frame and we +/// wait for the driver to raise `MHDR_DONE` before unpreparing. SysEx is rare, so +/// the brief block does not disturb note timing. +fn deliver_long(handle: Hmidiout, data: &[u8]) { + let mut buf = data.to_vec(); + let mut hdr: MidiHdr = unsafe { std::mem::zeroed() }; + hdr.lp_data = buf.as_mut_ptr(); + hdr.dw_buffer_length = buf.len() as u32; + hdr.dw_bytes_recorded = buf.len() as u32; + let size = std::mem::size_of::() as u32; + unsafe { + if midiOutPrepareHeader(handle, &mut hdr, size) != MMSYSERR_NOERROR { + log_send_error("midiOutPrepareHeader failed"); + return; + } + let rc = midiOutLongMsg(handle, &mut hdr, size); + if rc != MMSYSERR_NOERROR { + log_send_error(&format!("midiOutLongMsg failed ({rc})")); + } else { + // Wait for the driver to finish with the buffer (bounded, ~1s). + let mut spins = 0; + while hdr.dw_flags & MHDR_DONE == 0 && spins < 1000 { + std::thread::sleep(Duration::from_millis(1)); + spins += 1; + } + } + midiOutUnprepareHeader(handle, &mut hdr, size); + } +} + +// --- scheduler thread --------------------------------------------------- + +/// A message due for delivery at `at`. `seq` breaks ties so equal instants keep +/// arrival order (FIFO) and the heap never has to compare payloads. +struct Scheduled { + at: Instant, + seq: u64, + data: Vec, +} + +impl PartialEq for Scheduled { + fn eq(&self, other: &Self) -> bool { + self.at == other.at && self.seq == other.seq + } +} +impl Eq for Scheduled {} +impl PartialOrd for Scheduled { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Scheduled { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.at.cmp(&other.at).then(self.seq.cmp(&other.seq)) + } +} + +/// The scheduler's queue plus its shutdown flag and tie-break counter. +struct SchedQueue { + // A min-heap by (at, seq): `Reverse` flips the max-heap so the soonest + // message is at the top. + heap: BinaryHeap>, + seq: u64, + stop: bool, +} + +/// Shared between the emulation thread (`send` pushes here) and the scheduler +/// thread (which pops due messages and delivers them through `out`). +struct SchedulerShared { + queue: Mutex, + wake: Condvar, + out: Arc>, +} + +/// The scheduler loop: sleep until the soonest message is due, deliver it, repeat. +/// A message already past its instant fires immediately (as soon as possible), +/// matching the CoreMIDI and ALSA backends. The timed wait relies on +/// `timeBeginPeriod(1)` for ~1 ms granularity rather than busy-spinning. +fn scheduler_loop(shared: Arc) { + let mut queue = shared.queue.lock().unwrap(); + loop { + if queue.stop { + return; + } + match queue.heap.peek() { + None => { + // Nothing scheduled: park until `send` or shutdown wakes us. + queue = shared.wake.wait(queue).unwrap(); + } + Some(Reverse(next)) => { + let now = Instant::now(); + if next.at <= now { + let due = queue.heap.pop().unwrap().0; + // Release the queue while delivering so `send` never blocks + // on a (possibly slow SysEx) send. + drop(queue); + deliver(&shared.out, &due.data); + queue = shared.queue.lock().unwrap(); + } else { + let wait = next.at - now; + let (g, _) = shared.wake.wait_timeout(queue, wait).unwrap(); + queue = g; + } + } + } + } +} + +// --- input -------------------------------------------------------------- + +/// Kept alive (boxed) for the input port's lifetime; its address is the +/// callback's `dwInstance`. Only the WinMM callback thread pushes to `producer`, +/// so the single-writer invariant of the SPSC ring holds. `closing` fences the +/// callback during teardown so a buffer returned by `midiInReset` is neither +/// pushed nor re-added to a port being closed. +struct InputState { + producer: InputProducer, + handle: Hmidiin, + closing: AtomicBool, + // Pre-posted SysEx receive buffers, kept alive while registered with the + // driver; their headers are recycled in the callback via `midiInAddBuffer`. + sysex: Vec>, +} + +/// A receive buffer and its header, boxed so the header's `lp_data` pointer and +/// the address handed to the driver stay put. +#[repr(C)] +struct SysexBuf { + hdr: MidiHdr, + data: [u8; SYSEX_BUF], +} + +/// The input callback. `extern "system"` (stdcall) to match WinMM's `CALLBACK`. +extern "system" fn midi_in_proc( + _midi_in: Hmidiin, + msg: u32, + instance: DwordPtr, + param1: DwordPtr, + _param2: DwordPtr, +) { + if instance == 0 { + return; + } + // Sole writer: WinMM serialises a port's callback. The instance pointer is + // the boxed InputState, live until midiInClose returns (see close_input). + let state = unsafe { &mut *(instance as *mut InputState) }; + if state.closing.load(Ordering::Acquire) { + return; + } + match msg { + MIM_DATA => { + // dwParam1 packs the short message: status, data1, data2 (low bytes). + let packed = param1 as u32; + let status = (packed & 0xFF) as u8; + let bytes = [status, (packed >> 8) as u8, (packed >> 16) as u8]; + let len = short_len(status); + log_first_input(len); + push_input(&mut state.producer, &bytes[..len]); + } + MIM_LONGDATA => { + // dwParam1 is the header the driver just filled with SysEx bytes. + let hdr = param1 as *mut MidiHdr; + if hdr.is_null() { + return; + } + let recorded = unsafe { (*hdr).dw_bytes_recorded } as usize; + // A zero-length return means the buffer is being handed back on + // reset/close, not fresh data: do not push it or re-post it. + if recorded == 0 { + return; + } + let data = unsafe { std::slice::from_raw_parts((*hdr).lp_data, recorded) }; + log_first_input(recorded); + push_input(&mut state.producer, data); + // Recycle the buffer so the next SysEx is not dropped. + unsafe { + (*hdr).dw_bytes_recorded = 0; + midiInAddBuffer(state.handle, hdr, std::mem::size_of::() as u32); + } + } + _ => {} + } +} + +// --- backend ------------------------------------------------------------ + +pub struct WinmmBackend { + out: Arc>, + sched: Arc, + sched_thread: Option>, + // `COPPERLINE_MIDI_IMMEDIATE=1`: deliver on the calling thread instead of via + // the scheduler queue, to isolate a scheduling problem from a routing one. + immediate: bool, + // Input port. The handle is null when no input is open; the ring producer is + // parked here while closed and moved into the InputState while open. + in_handle: Hmidiin, + in_state: Option>, + in_name: Option, + in_producer: Option, +} + +// The WinMM handles are only touched by the thread that owns the backend (the +// input handle also by the driver's own callback thread, which the backend +// outlives). The scheduler thread shares only the plainly-Send OutputState. +unsafe impl Send for WinmmBackend {} + +impl WinmmBackend { + fn open_output(&mut self, id: u32, name: String) -> Mmresult { + let mut handle: Hmidiout = std::ptr::null_mut(); + let rc = unsafe { midiOutOpen(&mut handle, id, 0, 0, CALLBACK_NULL) }; + if rc == MMSYSERR_NOERROR { + let mut state = self.out.lock().unwrap(); + state.handle = handle as usize; + state.name = Some(name); + } + rc + } + + fn close_output(&mut self) { + let mut state = self.out.lock().unwrap(); + if state.handle != 0 { + let handle = state.handle as Hmidiout; + unsafe { + midiOutReset(handle); + midiOutClose(handle); + } + state.handle = 0; + state.name = None; + } + } + + fn open_input(&mut self, id: u32, name: String) -> Result<()> { + let producer = self + .in_producer + .take() + .ok_or_else(|| anyhow!("MIDI input already open"))?; + let mut state = Box::new(InputState { + producer, + handle: std::ptr::null_mut(), + closing: AtomicBool::new(false), + sysex: Vec::with_capacity(NUM_SYSEX_BUFFERS), + }); + let instance = state.as_ref() as *const InputState as DwordPtr; + let mut handle: Hmidiin = std::ptr::null_mut(); + let rc = unsafe { + midiInOpen( + &mut handle, + id, + midi_in_proc as MidiInProc as DwordPtr, + instance, + CALLBACK_FUNCTION, + ) + }; + if rc != MMSYSERR_NOERROR { + // Reclaim the producer so a later open can retry. + self.in_producer = Some(state.producer); + bail!("midiInOpen failed ({rc}) for {name:?}"); + } + state.handle = handle; + + // Post the SysEx receive buffers before starting, so a dump arriving + // immediately has somewhere to land. + let size = std::mem::size_of::() as u32; + for _ in 0..NUM_SYSEX_BUFFERS { + let mut buf = Box::new(SysexBuf { + hdr: unsafe { std::mem::zeroed() }, + data: [0u8; SYSEX_BUF], + }); + buf.hdr.lp_data = buf.data.as_mut_ptr(); + buf.hdr.dw_buffer_length = SYSEX_BUF as u32; + let hptr = &mut buf.hdr as *mut MidiHdr; + unsafe { + if midiInPrepareHeader(handle, hptr, size) == MMSYSERR_NOERROR + && midiInAddBuffer(handle, hptr, size) == MMSYSERR_NOERROR + { + state.sysex.push(buf); + } + } + } + + unsafe { midiInStart(handle) }; + self.in_handle = handle; + self.in_name = Some(name); + self.in_state = Some(state); + Ok(()) + } + + fn close_input(&mut self) { + if self.in_handle.is_null() { + return; + } + let handle = self.in_handle; + // Fence the callback before resetting: midiInReset hands the posted + // buffers back through the callback, which must not push or re-post them. + if let Some(state) = self.in_state.as_ref() { + state.closing.store(true, Ordering::Release); + } + unsafe { + midiInStop(handle); + midiInReset(handle); + if let Some(state) = self.in_state.as_mut() { + let size = std::mem::size_of::() as u32; + for buf in &mut state.sysex { + midiInUnprepareHeader(handle, &mut buf.hdr, size); + } + } + // After close returns the driver raises no further callbacks, so the + // boxed InputState is safe to drop and its producer to reclaim. + midiInClose(handle); + } + self.in_handle = std::ptr::null_mut(); + self.in_name = None; + if let Some(state) = self.in_state.take() { + self.in_producer = Some(state.producer); + } + } +} + +impl MidiBackend for WinmmBackend { + fn send(&mut self, data: &[u8], at: Instant) { + if data.is_empty() { + return; + } + if self.immediate { + deliver(&self.out, data); + return; + } + // Push and wake: non-blocking on the emulation thread. If no output is + // selected the scheduler still pops the message at its due instant and + // drops it, so the queue stays bounded by the in-flight window. + let mut queue = self.sched.queue.lock().unwrap(); + queue.seq += 1; + let seq = queue.seq; + queue.heap.push(Reverse(Scheduled { + at, + seq, + data: data.to_vec(), + })); + drop(queue); + self.sched.wake.notify_one(); + } + + fn set_output(&mut self, endpoint: Option<&str>) { + self.close_output(); + if let Some(want) = endpoint { + match find_output(want) { + Some((id, name)) => { + let rc = self.open_output(id, name.clone()); + if rc != MMSYSERR_NOERROR { + log::warn!("midi: could not open output {name:?} ({rc})"); + } + } + None => log::warn!("midi: no MIDI output endpoint matches {want:?}"), + } + } + } + + fn set_input(&mut self, endpoint: Option<&str>) { + self.close_input(); + if let Some(want) = endpoint { + match find_input(want) { + Some((id, name)) => { + if let Err(e) = self.open_input(id, name) { + log::warn!("midi: could not open input: {e}"); + } + } + None => log::warn!("midi: no MIDI input endpoint matches {want:?}"), + } + } + } + + fn current_output(&self) -> Option { + self.out.lock().unwrap().name.clone() + } + + fn current_input(&self) -> Option { + self.in_name.clone() + } +} + +impl Drop for WinmmBackend { + fn drop(&mut self) { + // Stop the scheduler and join it before the output handle goes, so no + // delivery is in flight when the device is closed. + { + let mut queue = self.sched.queue.lock().unwrap(); + queue.stop = true; + } + self.sched.wake.notify_all(); + if let Some(t) = self.sched_thread.take() { + let _ = t.join(); + } + self.close_input(); + self.close_output(); + unsafe { timeEndPeriod(1) }; + } +} + +pub fn enumerate() -> MidiEndpoints { + MidiEndpoints { + inputs: (0..unsafe { midiInGetNumDevs() }) + .filter_map(input_name) + .map(|name| MidiEndpoint { name }) + .collect(), + outputs: (0..unsafe { midiOutGetNumDevs() }) + .filter_map(output_name) + .map(|name| MidiEndpoint { name }) + .collect(), + } +} + +pub fn open( + midi_out: Option<&str>, + midi_in: Option<&str>, + input: InputProducer, +) -> Result> { + let immediate = crate::envcfg::flag("COPPERLINE_MIDI_IMMEDIATE"); + if immediate { + log::info!("midi: immediate send mode (COPPERLINE_MIDI_IMMEDIATE), scheduling bypassed"); + } + + // Raise the timer resolution so the scheduler's timed wait is ~1 ms, not the + // default ~15 ms. Paired with timeEndPeriod in Drop. + unsafe { timeBeginPeriod(1) }; + + let out = Arc::new(Mutex::new(OutputState { + handle: 0, + name: None, + })); + let sched = Arc::new(SchedulerShared { + queue: Mutex::new(SchedQueue { + heap: BinaryHeap::new(), + seq: 0, + stop: false, + }), + wake: Condvar::new(), + out: Arc::clone(&out), + }); + let sched_thread = { + let shared = Arc::clone(&sched); + std::thread::Builder::new() + .name("copperline-midi-sched".into()) + .spawn(move || scheduler_loop(shared)) + .map_err(|e| anyhow!("could not spawn MIDI scheduler thread: {e}"))? + }; + + let mut backend = WinmmBackend { + out, + sched, + sched_thread: Some(sched_thread), + immediate, + in_handle: std::ptr::null_mut(), + in_state: None, + in_name: None, + in_producer: Some(input), + }; + + if let Some(want) = midi_out { + let (id, name) = + find_output(want).ok_or_else(|| anyhow!("no MIDI output endpoint matches {want:?}"))?; + let rc = backend.open_output(id, name.clone()); + if rc != MMSYSERR_NOERROR { + bail!("could not open MIDI output {name:?} ({rc})"); + } + log::info!("midi: output connected to {name:?}"); + } + if let Some(want) = midi_in { + let (id, name) = + find_input(want).ok_or_else(|| anyhow!("no MIDI input endpoint matches {want:?}"))?; + backend.open_input(id, name.clone())?; + log::info!("midi: input connected to {name:?}"); + } + if backend.out.lock().unwrap().handle == 0 && backend.in_handle.is_null() { + log::warn!("[serial] mode = midi but no midi_out/midi_in endpoint selected; MIDI is inert"); + } + + Ok(Box::new(backend)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ringbuf::traits::{Consumer, Split}; + use ringbuf::HeapRb; + + /// Round-trip a scheduled message through a virtual loopback port (loopMIDI, + /// or whatever `COPPERLINE_MIDI_TEST_PORT` names). Proves the whole live path + /// at once -- scheduler thread, output open, the input callback, and the ring + /// push -- and that the schedule is honoured rather than collapsed to "now". + /// Ignored by default: it needs a loopback port with the same name visible as + /// both an output and an input, so run it explicitly with + /// `cargo test --features midi -- --ignored`. + #[test] + #[ignore] + fn midi_through_loopback() { + let port = std::env::var("COPPERLINE_MIDI_TEST_PORT").unwrap_or_else(|_| "loopMIDI".into()); + let (producer, mut consumer) = HeapRb::::new(64).split(); + let mut backend = match open(Some(&port), Some(&port), producer) { + Ok(b) => b, + Err(e) => { + eprintln!("skipping midi_through_loopback: {e}"); + return; + } + }; + + // Schedule far enough ahead that a broken schedule (delivered now) is + // distinguishable from an honoured one purely by arrival time. + let note = [0x90u8, 0x3C, 0x64]; + let lead = Duration::from_millis(300); + let sent = Instant::now(); + backend.send(¬e, sent + lead); + + let mut got = Vec::new(); + let mut arrived = None; + let deadline = Instant::now() + Duration::from_secs(2); + while got.len() < note.len() && Instant::now() < deadline { + match consumer.try_pop() { + Some(b) => { + arrived.get_or_insert_with(Instant::now); + got.push(b); + } + None => std::thread::sleep(Duration::from_millis(1)), + } + } + + assert_eq!(got, note, "looped-back bytes should match what was sent"); + let delay = arrived.unwrap().duration_since(sent); + assert!( + delay >= Duration::from_millis(250), + "scheduled send arrived too early ({delay:?}); timing was not honoured" + ); + } +} diff --git a/src/serial.rs b/src/serial.rs index 1ff0d9b..bf37c76 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -3,12 +3,41 @@ //! Serial output sink. Paula's SERDAT writes are funneled through here. use std::io::{self, Write}; +use std::time::Instant; + +/// Maps the emulated serial timeline onto the host clock so a timing-sensitive +/// sink can schedule its output. `host_epoch` is the host instant of emulated +/// color clock 0 and `cck_per_second` is the color-clock rate, so a byte stamped +/// `at_cck` is due at `host_epoch + at_cck / cck_per_second`. The emulator +/// republishes it whenever it re-anchors the real-time clock, so it tracks +/// pauses and hitches. +/// +/// Only the MIDI sink reads this. Without that feature it is still published, +/// harmlessly, but nothing consumes it. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(not(feature = "midi"), allow(dead_code))] +pub struct SerialTimeAnchor { + pub host_epoch: Instant, + pub cck_per_second: f64, +} -pub trait SerialSink: Send { - fn write_byte(&mut self, b: u8); +#[cfg_attr(not(feature = "midi"), allow(dead_code))] +impl SerialTimeAnchor { + /// Host instant a byte stamped `at_cck` is due to leave the wire. + pub fn host_time(&self, at_cck: u64) -> Instant { + self.host_epoch + std::time::Duration::from_secs_f64(at_cck as f64 / self.cck_per_second) + } +} - fn write_word(&mut self, word: u16, _long: bool) { - self.write_byte((word & 0x00FF) as u8); +pub trait SerialSink: Send { + /// Transmit one byte. `at_cck` is the emulated color clock the byte finished + /// shifting out on, a monotonic power-on count. Sinks that only want the data + /// ignore it; a timing-sensitive sink (MIDI) maps it to a host clock to keep + /// the byte timing. + fn write_byte(&mut self, b: u8, at_cck: u64); + + fn write_word(&mut self, word: u16, _long: bool, at_cck: u64) { + self.write_byte((word & 0x00FF) as u8, at_cck); } fn read_byte(&mut self) -> Option { @@ -26,6 +55,17 @@ pub trait SerialSink: Send { false } + /// Update the emulated-to-host time mapping (see [`SerialTimeAnchor`]). + /// Sinks that schedule output store it; others ignore it. + fn set_time_anchor(&mut self, _anchor: SerialTimeAnchor) {} + + /// The MIDI sink, when this is one, for runtime device switching. `None` + /// for every other sink. + #[cfg(feature = "midi")] + fn as_midi(&mut self) -> Option<&mut crate::midi::MidiSerialSink> { + None + } + fn flush(&mut self); } @@ -35,7 +75,7 @@ pub trait SerialSink: Send { pub struct NullSerialSink; impl SerialSink for NullSerialSink { - fn write_byte(&mut self, _b: u8) {} + fn write_byte(&mut self, _b: u8, _at_cck: u64) {} fn flush(&mut self) {} } @@ -59,7 +99,7 @@ impl StdoutSink { } impl SerialSink for StdoutSink { - fn write_byte(&mut self, b: u8) { + fn write_byte(&mut self, b: u8, _at_cck: u64) { if b == 0 { return; } diff --git a/src/video/launcher.rs b/src/video/launcher.rs index fd46040..d67c3b4 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -24,7 +24,7 @@ use crate::chipset::denise::DeniseRevision; use crate::config::{ format_size, machine_profile_defaults, Chipset, Config, CpuModel, JoystickInputMode, MachineModel, Overscan, PacingBudget, PixelAspect, RawConfig, RawDrive, RawFloppyDrive, - RawZorroBoard, WarpSpeed, + RawZorroBoard, SerialMode, WarpSpeed, }; use crate::zorro::{ConfigOption, ConfigOptionKind, LoadedZorroBoard}; use anyhow::Result; @@ -167,12 +167,17 @@ pub enum LauncherTab { Floppy, Storage, Cd, + // Only reached in a `midi` build, where it is added to TABS. + #[cfg_attr(not(feature = "midi"), allow(dead_code))] + Serial, Zorro, AvEmulation, } -/// Tab strip, left to right. -pub const TABS: [LauncherTab; 9] = [ +/// Tabs shown top to bottom. The Serial tab only holds MIDI settings for now, so +/// it is present only in a `midi` build; drop the `cfg` if the tab gains other +/// serial options. +pub const TABS: &[LauncherTab] = &[ LauncherTab::System, LauncherTab::Cpu, LauncherTab::Memory, @@ -180,6 +185,8 @@ pub const TABS: [LauncherTab; 9] = [ LauncherTab::Floppy, LauncherTab::Storage, LauncherTab::Cd, + #[cfg(feature = "midi")] + LauncherTab::Serial, LauncherTab::Zorro, LauncherTab::AvEmulation, ]; @@ -194,6 +201,7 @@ impl LauncherTab { LauncherTab::Floppy => "Floppy", LauncherTab::Storage => "Hard Disk", LauncherTab::Cd => "CD", + LauncherTab::Serial => "Serial", LauncherTab::Zorro => "Zorro", LauncherTab::AvEmulation => "A/V & Emu", } @@ -253,6 +261,13 @@ pub enum LauncherField { CdImage, CdInsertDelay, Cd32Nvram, + // Serial (MIDI). Present only with the `midi` feature. + #[cfg(feature = "midi")] + SerialMode, + #[cfg(feature = "midi")] + MidiOut, + #[cfg(feature = "midi")] + MidiIn, // A/V and emulation Overscan, PixelAspect, @@ -352,6 +367,12 @@ const CD_ROWS: [Row; 3] = [ row(F::CdInsertDelay, "Insert delay", Cycle), row(F::Cd32Nvram, "CD32 NVRAM", PathRow), ]; +#[cfg(feature = "midi")] +const SERIAL_ROWS: [Row; 3] = [ + row(F::SerialMode, "Mode", Cycle), + row(F::MidiIn, "MIDI input", Cycle), + row(F::MidiOut, "MIDI output", Cycle), +]; const AV_EMULATION_ROWS: [Row; 10] = [ row(F::Overscan, "Overscan", Cycle), row(F::PixelAspect, "Pixel aspect", Cycle), @@ -376,11 +397,25 @@ pub fn rows(tab: LauncherTab) -> &'static [Row] { LauncherTab::Floppy => &FLOPPY_ROWS, LauncherTab::Storage => &STORAGE_ROWS, LauncherTab::Cd => &CD_ROWS, + LauncherTab::Serial => serial_rows(), LauncherTab::Zorro => &[], LauncherTab::AvEmulation => &AV_EMULATION_ROWS, } } +/// Serial-tab rows. Only the `midi` build has any; without it the tab is absent +/// from [`TABS`] and this is never reached. +fn serial_rows() -> &'static [Row] { + #[cfg(feature = "midi")] + { + &SERIAL_ROWS + } + #[cfg(not(feature = "midi"))] + { + &[] + } +} + /// Machine models offered in the selector strip, roughly chronological. pub const MODELS: [MachineModel; 8] = [ MachineModel::A1000, @@ -455,6 +490,8 @@ const WARPS: [WarpSpeed; 5] = [ // The stepper flips the two explicit modes, matching the runtime toggle. const JOYSTICK_MODES: [JoystickInputMode; 2] = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; +#[cfg(feature = "midi")] +const SERIAL_MODES: [SerialMode; 3] = [SerialMode::Off, SerialMode::Stdout, SerialMode::Midi]; /// A fully-typed, editable mirror of a configurable machine. See the module /// docs for how it round-trips through [`RawConfig`]. @@ -506,6 +543,15 @@ pub struct MachineSetup { cd_image: Option, cd_insert_delay: f64, cd32_nvram: Option, + // Serial port. Carried in every build so a config's `[serial]` block + // round-trips; only edited on the Serial tab, which is a `midi` build only. + serial_mode: SerialMode, + midi_out: Option, + midi_in: Option, + /// Host endpoints for the device pickers, read once when this setup is + /// built so a fresh config screen sees currently-connected devices. + #[cfg(feature = "midi")] + midi_endpoints: crate::midi::MidiEndpoints, // A/V and emulation overscan: Overscan, pixel_aspect: PixelAspect, @@ -582,6 +628,13 @@ impl MachineSetup { // Use the raw NVRAM path: Config defaults it to "cd32-nvram.bin" // on CD32, which we do not want to persist as an explicit setting. cd32_nvram: raw.cd.nvram.as_deref().map(PathBuf::from), + serial_mode: cfg.serial.mode, + midi_out: cfg.serial.midi_out.clone(), + midi_in: cfg.serial.midi_in.clone(), + // Left empty here so config construction stays side-effect free; the + // config screen fills it via refresh_midi_endpoints on open. + #[cfg(feature = "midi")] + midi_endpoints: crate::midi::MidiEndpoints::default(), overscan: cfg.overscan, pixel_aspect: cfg.pixel_aspect, phosphor: cfg.phosphor, @@ -615,6 +668,12 @@ impl MachineSetup { Self::from_raw(&crate::config::raw_from_path(path)?) } + /// Re-read the host MIDI endpoints for the device pickers. + #[cfg(feature = "midi")] + pub fn refresh_midi_endpoints(&mut self) { + self.midi_endpoints = crate::midi::enumerate(); + } + /// The bare-profile config this setup is compared against when emitting /// minimal TOML: the machine the selected profile produces with no /// overrides, resolved through the same `TryFrom` as a real boot so the @@ -772,6 +831,11 @@ impl MachineSetup { if self.joystick_input_mode != base.joystick_input_mode { raw.input.joystick = Some(self.joystick_input_mode.label().to_string()); } + if self.serial_mode != base.serial.mode { + raw.serial.mode = Some(self.serial_mode.label().to_string()); + } + raw.serial.midi_out = self.midi_out.clone(); + raw.serial.midi_in = self.midi_in.clone(); // Zorro boards: emit the metadata path plus any per-board overrides // (typed per the option schema), only when the user changed something. raw.zorro = self @@ -914,6 +978,8 @@ impl MachineSetup { F::Df1Image | F::Df1WriteProtect => reason(self.floppy_drives >= 2, "drive off"), F::Df2Image | F::Df2WriteProtect => reason(self.floppy_drives >= 3, "drive off"), F::Df3Image | F::Df3WriteProtect => reason(self.floppy_drives >= 4, "drive off"), + #[cfg(feature = "midi")] + F::MidiOut | F::MidiIn => reason(self.serial_mode == SerialMode::Midi, "MIDI off"), _ => None, } } @@ -1072,6 +1138,16 @@ impl MachineSetup { JoystickInputMode::Keyboard => "Keyboard".to_string(), JoystickInputMode::Gamepad => "Gamepad".to_string(), }, + #[cfg(feature = "midi")] + F::SerialMode => match self.serial_mode { + SerialMode::Off => "Off".to_string(), + SerialMode::Stdout => "Stdout".to_string(), + SerialMode::Midi => "MIDI".to_string(), + }, + #[cfg(feature = "midi")] + F::MidiOut => self.midi_out.clone().unwrap_or_else(|| "None".to_string()), + #[cfg(feature = "midi")] + F::MidiIn => self.midi_in.clone().unwrap_or_else(|| "None".to_string()), // Path/drive fields: the file name, or a placeholder. F::Rom => self.path_label(field, "(bundled AROS)"), _ if rows_contains_kind(field, RowKind::Path) @@ -1150,6 +1226,14 @@ impl MachineSetup { self.joystick_input_mode = cycle_slice(&JOYSTICK_MODES, self.joystick_input_mode, forward) } + #[cfg(feature = "midi")] + F::SerialMode => { + self.serial_mode = cycle_slice(&SERIAL_MODES, self.serial_mode, forward) + } + #[cfg(feature = "midi")] + F::MidiOut => cycle_endpoint(&mut self.midi_out, &self.midi_endpoints.outputs, forward), + #[cfg(feature = "midi")] + F::MidiIn => cycle_endpoint(&mut self.midi_in, &self.midi_endpoints.inputs, forward), _ => {} } } @@ -1325,6 +1409,12 @@ pub struct LauncherState { impl LauncherState { pub fn new(setup: MachineSetup) -> Self { + #[cfg_attr(not(feature = "midi"), allow(unused_mut))] + let mut setup = setup; + // Read the host MIDI devices once, as the screen opens, so the pickers + // show what is connected now. + #[cfg(feature = "midi")] + setup.refresh_midi_endpoints(); Self { setup, tab: LauncherTab::System, @@ -1410,6 +1500,18 @@ fn cpu_is_32bit(cpu: CpuModel) -> bool { ) } +/// Step a MIDI endpoint selection through "None" then the available endpoints, +/// storing the chosen device's exact name. +#[cfg(feature = "midi")] +fn cycle_endpoint( + current: &mut Option, + endpoints: &[crate::midi::MidiEndpoint], + forward: bool, +) { + let names: Vec = endpoints.iter().map(|e| e.name.clone()).collect(); + *current = crate::midi::next_endpoint(current.as_deref(), &names, forward); +} + /// Whether `field` appears anywhere with the given row kind. Used to classify a /// field (toggle vs path) without threading the tab through every call. fn rows_contains_kind(field: LauncherField, kind: RowKind) -> bool { @@ -1848,6 +1950,36 @@ mod tests { assert!(err.contains("Zorro III"), "{err}"); } + #[cfg(feature = "midi")] + #[test] + fn serial_midi_settings_round_trip_through_raw() { + let mut s = MachineSetup::default(); + // Default serial mode writes nothing. + assert!(s.to_raw().serial.mode.is_none()); + + s.cycle(LauncherField::SerialMode, true); // Stdout -> MIDI + assert_eq!(s.serial_mode, SerialMode::Midi); + // The device rows are live only in MIDI mode. + assert_eq!(s.disabled_reason(LauncherField::MidiOut), None); + s.midi_out = Some("USB MIDI".to_string()); + + let raw = s.to_raw(); + assert_eq!(raw.serial.mode.as_deref(), Some("midi")); + assert_eq!(raw.serial.midi_out.as_deref(), Some("USB MIDI")); + + let back = MachineSetup::from_raw(&raw).unwrap(); + assert_eq!(back.serial_mode, SerialMode::Midi); + assert_eq!(back.midi_out.as_deref(), Some("USB MIDI")); + } + + #[cfg(feature = "midi")] + #[test] + fn midi_device_rows_are_disabled_off_midi_mode() { + let s = MachineSetup::default(); // Stdout by default + assert!(s.disabled_reason(LauncherField::MidiOut).is_some()); + assert!(s.disabled_reason(LauncherField::MidiIn).is_some()); + } + #[test] fn setting_a_floppy_path_round_trips_and_wires_the_drive() { let mut s = MachineSetup::default(); diff --git a/src/video/ui.rs b/src/video/ui.rs index 8c30f22..53a5bc0 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -79,6 +79,10 @@ pub enum MenuItem { Debugger, Console, JoystickInput, + #[cfg(feature = "midi")] + MidiInput, + #[cfg(feature = "midi")] + MidiOutput, PixelAspect, Warp, WarpLimit, @@ -90,34 +94,55 @@ pub enum MenuItem { MachineConfig, } -pub const MENU_ITEMS: [MenuItem; 16] = [ - MenuItem::MachineConfig, - MenuItem::FrameAnalyzer, - MenuItem::Debugger, - MenuItem::Console, - MenuItem::Calibration, - MenuItem::JoystickInput, - MenuItem::PixelAspect, - MenuItem::Warp, - MenuItem::WarpLimit, - MenuItem::Record, - MenuItem::RecordInput, - MenuItem::SaveState, - MenuItem::LoadState, - MenuItem::LoadRom, - MenuItem::Shortcuts, - MenuItem::About, -]; +/// The menu items, top to bottom. The MIDI device items appear only when the +/// serial port is in MIDI mode, so the list is built per open rather than fixed. +pub fn menu_items(midi_active: bool) -> Vec { + let _ = midi_active; + let mut items = vec![ + MenuItem::MachineConfig, + MenuItem::FrameAnalyzer, + MenuItem::Debugger, + MenuItem::Console, + MenuItem::Calibration, + MenuItem::JoystickInput, + MenuItem::PixelAspect, + ]; + #[cfg(feature = "midi")] + if midi_active { + items.push(MenuItem::MidiInput); + items.push(MenuItem::MidiOutput); + } + items.extend([ + MenuItem::Warp, + MenuItem::WarpLimit, + MenuItem::Record, + MenuItem::RecordInput, + MenuItem::SaveState, + MenuItem::LoadState, + MenuItem::LoadRom, + MenuItem::Shortcuts, + MenuItem::About, + ]); + items +} -fn menu_item_label( - item: MenuItem, - warp: bool, - warp_speed: WarpSpeed, - recording: bool, - input_recording: bool, - joystick_input_mode: JoystickInputMode, - pixel_aspect: PixelAspect, -) -> String { +/// Bundled state a menu label may show, so the label helper takes one argument. +#[derive(Clone, Copy)] +pub struct MenuLabels<'a> { + pub warp: bool, + pub warp_speed: WarpSpeed, + pub recording: bool, + pub input_recording: bool, + pub joystick_input_mode: JoystickInputMode, + pub pixel_aspect: PixelAspect, + /// Current MIDI input/output device names (empty when not applicable). + #[cfg_attr(not(feature = "midi"), allow(dead_code))] + pub midi_in: &'a str, + #[cfg_attr(not(feature = "midi"), allow(dead_code))] + pub midi_out: &'a str, +} + +fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { match item { MenuItem::FrameAnalyzer => "Frame Analyzer...".to_string(), MenuItem::About => "About...".to_string(), @@ -125,22 +150,31 @@ fn menu_item_label( MenuItem::Calibration => "Calibrate Gamepad...".to_string(), MenuItem::Debugger => "Debugger...".to_string(), MenuItem::Console => "Console...".to_string(), - MenuItem::JoystickInput => format!("Joystick Input [{}]", joystick_input_mode.label()), + MenuItem::JoystickInput => format!("Joystick Input [{}]", s.joystick_input_mode.label()), MenuItem::PixelAspect => { - let value = match pixel_aspect { + let value = match s.pixel_aspect { PixelAspect::Tv => "tv", PixelAspect::Square => "square", }; format!("Pixel Aspect {:>8}", format!("[{value}]")) } - MenuItem::Warp if warp => "Warp Speed [on]".to_string(), + #[cfg(feature = "midi")] + MenuItem::MidiInput => format!("MIDI In [{}]", clip_menu_value(s.midi_in)), + #[cfg(feature = "midi")] + MenuItem::MidiOutput => format!("MIDI Out [{}]", clip_menu_value(s.midi_out)), + MenuItem::Warp if s.warp => "Warp Speed [on]".to_string(), MenuItem::Warp => "Warp Speed [off]".to_string(), // Right-pad so the closing bracket stays put as the value width // changes (2x/8x vs 16x/Max), aligning with the Warp Speed row above. - MenuItem::WarpLimit => format!("Warp Limit {:>5}", format!("[{}]", warp_speed.label())), - MenuItem::Record if recording => "Stop Video Recording".to_string(), + MenuItem::WarpLimit => { + format!( + "Warp Limit {:>5}", + format!("[{}]", s.warp_speed.label()) + ) + } + MenuItem::Record if s.recording => "Stop Video Recording".to_string(), MenuItem::Record => "Record Video".to_string(), - MenuItem::RecordInput if input_recording => "Stop Input Recording".to_string(), + MenuItem::RecordInput if s.input_recording => "Stop Input Recording".to_string(), MenuItem::RecordInput => "Record Input".to_string(), MenuItem::SaveState => "Save State".to_string(), MenuItem::LoadState => "Load State...".to_string(), @@ -149,8 +183,19 @@ fn menu_item_label( } } -fn menu_rect() -> Rect { - let h = MENU_ITEMS.len() * MENU_ITEM_H + 2 * MENU_PAD; +/// Clip a device name so the "MIDI In [name]" label stays within the popup. +#[cfg(feature = "midi")] +fn clip_menu_value(name: &str) -> String { + const MAX: usize = MENU_MAX_LABEL_CHARS - 11; // "MIDI Out [" plus "]" + if name.chars().count() <= MAX { + return name.to_string(); + } + let kept: String = name.chars().take(MAX.saturating_sub(1)).collect(); + format!("{kept}~") +} + +fn menu_rect(item_count: usize) -> Rect { + let h = item_count * MENU_ITEM_H + 2 * MENU_PAD; let right = MENU_BUTTON_X + MENU_BUTTON_W; Rect { x: right.saturating_sub(MENU_W), @@ -160,8 +205,8 @@ fn menu_rect() -> Rect { } } -fn menu_item_rect(index: usize) -> Rect { - let menu = menu_rect(); +fn menu_item_rect(index: usize, item_count: usize) -> Rect { + let menu = menu_rect(item_count); Rect { x: menu.x + 1, y: menu.y + MENU_PAD + index * MENU_ITEM_H, @@ -445,16 +490,20 @@ impl UiState { self.menu_open || self.panel.is_some() } - /// The UI control under `pos`, if any. `PanelBody` swallows clicks on - /// a panel's background so they never reach the emulated display. - pub fn control_at(&self, pos: (i32, i32)) -> Option { + /// The UI control under `pos`, if any. `midi_active` selects the same menu + /// item list the draw uses. `PanelBody` swallows clicks on a panel's + /// background so they never reach the emulated display. + pub fn control_at(&self, pos: (i32, i32), midi_active: bool) -> Option { if self.menu_open { - for (index, item) in MENU_ITEMS.iter().enumerate() { - if menu_item_rect(index).contains(pos) { + let items = menu_items(midi_active); + for (index, item) in items.iter().enumerate() { + if menu_item_rect(index, items.len()).contains(pos) { return Some(UiControl::MenuItem(*item)); } } - return menu_rect().contains(pos).then_some(UiControl::PanelBody); + return menu_rect(items.len()) + .contains(pos) + .then_some(UiControl::PanelBody); } self.panel .as_ref() @@ -1481,20 +1530,17 @@ fn draw_panel_chrome(frame: &mut [u8], panel: &Panel, hover: Option, fn draw_menu( frame: &mut [u8], hover: Option, - warp: bool, - warp_speed: WarpSpeed, - recording: bool, - input_recording: bool, - joystick_input_mode: JoystickInputMode, - pixel_aspect: PixelAspect, + midi_active: bool, + labels: MenuLabels, scale: usize, ) { - let rect = menu_rect(); + let items = menu_items(midi_active); + let rect = menu_rect(items.len()); let scaled = scale_rect(rect, scale); fill_rect(frame, scaled, MENU_BG, scale); draw_rect_bevel(frame, scaled, MENU_EDGE, MENU_EDGE, scale); - for (index, item) in MENU_ITEMS.iter().enumerate() { - let item_rect = menu_item_rect(index); + for (index, item) in items.iter().enumerate() { + let item_rect = menu_item_rect(index, items.len()); let hovered = hover == Some(UiControl::MenuItem(*item)); let (bg, fg) = if hovered { (MENU_HILIGHT_BG, MENU_HILIGHT_TEXT) @@ -1508,15 +1554,7 @@ fn draw_menu( frame, item_rect.x + MENU_TEXT_INSET, item_rect.y + (MENU_ITEM_H - 16) / 2, - &menu_item_label( - *item, - warp, - warp_speed, - recording, - input_recording, - joystick_input_mode, - pixel_aspect, - ), + &menu_item_label(*item, labels), fg, MENU_TEXT_PX, scale, @@ -3642,7 +3680,9 @@ fn draw_launcher_row( }), scale, ); - let text = setup.value_label(r.field); + // Clip a long value (e.g. a wordy MIDI device name) to the box so + // it cannot spill over the ">" stepper. + let text = truncate_to_width(&setup.value_label(r.field), value.w); let tw = text.chars().count() * font::GLYPH_W; let tx = value.x + value.w.saturating_sub(tw) / 2; draw_panel_text(frame, tx, value.y + 6, &text, PANEL_TEXT_HILIGHT, 1, scale); @@ -4078,28 +4118,14 @@ pub fn draw( ui: &UiState, hover: Option, data: Option<&PanelViewData>, - warp: bool, - warp_speed: WarpSpeed, - recording: bool, - input_recording: bool, - joystick_input_mode: JoystickInputMode, - pixel_aspect: PixelAspect, + midi_active: bool, + labels: MenuLabels, ) { if let Some(panel) = &ui.panel { draw_panel_layer(frame, texture_scale, panel, hover, data); } if ui.menu_open { - draw_menu( - frame, - hover, - warp, - warp_speed, - recording, - input_recording, - joystick_input_mode, - pixel_aspect, - texture_scale, - ); + draw_menu(frame, hover, midi_active, labels, texture_scale); } } @@ -4333,7 +4359,8 @@ mod tests { #[test] fn menu_sits_above_the_status_bar_and_hit_tests_items() { - let rect = menu_rect(); + let n = menu_items(false).len(); + let rect = menu_rect(n); assert!(rect.y + rect.h <= present_height()); assert!(rect.x + rect.w <= FB_WIDTH); @@ -4341,50 +4368,56 @@ mod tests { menu_open: true, panel: None, }; - let first = menu_item_rect(0); + let first = menu_item_rect(0, n); let pos = (first.x as i32 + 4, first.y as i32 + 4); assert_eq!( - ui.control_at(pos), + ui.control_at(pos, false), Some(UiControl::MenuItem(MenuItem::MachineConfig)) ); - let joystick = menu_item_rect(5); + let joystick = menu_item_rect(5, n); let pos = (joystick.x as i32 + 4, joystick.y as i32 + 4); assert_eq!( - ui.control_at(pos), + ui.control_at(pos, false), Some(UiControl::MenuItem(MenuItem::JoystickInput)) ); // Outside the menu: nothing (the click closes the menu). - assert_eq!(ui.control_at((0, 0)), None); + assert_eq!(ui.control_at((0, 0), false), None); } #[test] fn every_menu_label_fits_inside_the_popup() { // The label is drawn at `item_rect.x + MENU_TEXT_INSET`; its glyphs - // must end before the popup's right edge or the trailing "..." clips. - let menu = menu_rect(); + // must end before the popup's right edge or the trailing "~" clips. + let items = menu_items(true); + let menu = menu_rect(items.len()); let limit = menu.x + menu.w; let modes = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; let speeds = [WarpSpeed::X2, WarpSpeed::X8, WarpSpeed::X16, WarpSpeed::Max]; + // A deliberately over-long device name exercises the clip path. + let long = "Extremely Long USB MIDI Interface Name 9000"; let aspects = [PixelAspect::Tv, PixelAspect::Square]; - for item in MENU_ITEMS { + for &item in &items { for warp in [false, true] { for recording in [false, true] { for input_recording in [false, true] { for &mode in &modes { for &speed in &speeds { for &aspect in &aspects { - let label = menu_item_label( - item, + let labels = MenuLabels { warp, - speed, + warp_speed: speed, recording, input_recording, - mode, - aspect, - ); + joystick_input_mode: mode, + pixel_aspect: aspect, + midi_in: long, + midi_out: long, + }; + let label = menu_item_label(item, labels); let text_w = label.chars().count() * font::GLYPH_W * MENU_TEXT_PX; - let right = menu_item_rect(0).x + MENU_TEXT_INSET + text_w; + let right = + menu_item_rect(0, items.len()).x + MENU_TEXT_INSET + text_w; assert!( right <= limit, "label {label:?} ({text_w}px) overflows the menu by {}px", @@ -4408,7 +4441,10 @@ mod tests { let rect = panel_rect(ui.panel.as_ref().unwrap()); let raster = analyzer_raster_rect(rect); assert_eq!( - ui.control_at((raster.x as i32 + raster.w as i32 / 2, raster.y as i32 + 2)), + ui.control_at( + (raster.x as i32 + raster.w as i32 / 2, raster.y as i32 + 2), + false + ), Some(UiControl::AnalyzerPick { x: 511, y: 8, @@ -4417,10 +4453,13 @@ mod tests { ); let scanline = analyzer_scanline_rect(rect); assert_eq!( - ui.control_at(( - scanline.x as i32 + scanline.w as i32 / 2, - scanline.y as i32 + 2 - )), + ui.control_at( + ( + scanline.x as i32 + scanline.w as i32 / 2, + scanline.y as i32 + 2 + ), + false + ), Some(UiControl::AnalyzerPick { x: 511, y: 60, @@ -4430,12 +4469,12 @@ mod tests { let (control, button) = analyzer_button_rects(rect)[1]; assert_eq!(control, UiControl::AnalyzerFrame); assert_eq!( - ui.control_at((button.x as i32 + 2, button.y as i32 + 2)), + ui.control_at((button.x as i32 + 2, button.y as i32 + 2), false), Some(UiControl::AnalyzerFrame) ); let underlay = analyzer_underlay_rect(rect); assert_eq!( - ui.control_at((underlay.x as i32 + 2, underlay.y as i32 + 2)), + ui.control_at((underlay.x as i32 + 2, underlay.y as i32 + 2), false), Some(UiControl::AnalyzerUnderlay) ); // The checkbox must not overlap the transport buttons. @@ -4559,12 +4598,12 @@ mod tests { let rect = panel_rect(ui.panel.as_ref().unwrap()); let close = close_button_rect(rect); let pos = (close.x as i32 + 2, close.y as i32 + 2); - assert_eq!(ui.control_at(pos), Some(UiControl::PanelClose)); + assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelClose)); // Panel body swallows clicks. let body = (rect.x as i32 + 5, (rect.y + TITLE_H + 5) as i32); - assert_eq!(ui.control_at(body), Some(UiControl::PanelBody)); + assert_eq!(ui.control_at(body, false), Some(UiControl::PanelBody)); // Outside the panel: nothing. - assert_eq!(ui.control_at((0, 0)), None); + assert_eq!(ui.control_at((0, 0), false), None); } #[test] @@ -4576,22 +4615,22 @@ mod tests { let rect = panel_rect(ui.panel.as_ref().unwrap()); let tab = debug_tab_rect(rect, 3); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2)), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), Some(UiControl::DebugTab(DebugTab::Video)) ); let tab = debug_tab_rect(rect, 4); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2)), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), Some(UiControl::DebugTab(DebugTab::Audio)) ); let tab = debug_tab_rect(rect, 6); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2)), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), Some(UiControl::DebugTab(DebugTab::IoMap)) ); let tab = debug_tab_rect(rect, 7); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2)), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), Some(UiControl::DebugTab(DebugTab::Break)) ); // All eight tabs fit inside the panel. @@ -4600,7 +4639,7 @@ mod tests { let (control, step) = debug_button_rects(rect)[1]; assert_eq!(control, UiControl::DebugStep); assert_eq!( - ui.control_at((step.x as i32 + 2, step.y as i32 + 2)), + ui.control_at((step.x as i32 + 2, step.y as i32 + 2), false), Some(UiControl::DebugStep) ); @@ -4614,9 +4653,12 @@ mod tests { let (control, toggle) = break_tab_button_rects(rect)[0]; assert_eq!(control, UiControl::DebugBreakToggle); let pos = (toggle.x as i32 + 2, toggle.y as i32 + 2); - assert_eq!(ui_break.control_at(pos), Some(UiControl::DebugBreakToggle)); + assert_eq!( + ui_break.control_at(pos, false), + Some(UiControl::DebugBreakToggle) + ); // On another tab the same position is just panel body. - assert_eq!(ui.control_at(pos), Some(UiControl::PanelBody)); + assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelBody)); // Audio-tab mute buttons hit-test only while the Audio tab is active. let mut panel = DebuggerPanel::new(); @@ -4628,17 +4670,20 @@ mod tests { let (control, mute0) = audio_tab_button_rects(rect)[0]; assert_eq!(control, UiControl::DebugAudioMute(0)); let pos = (mute0.x as i32 + 2, mute0.y as i32 + 2); - assert_eq!(ui_audio.control_at(pos), Some(UiControl::DebugAudioMute(0))); + assert_eq!( + ui_audio.control_at(pos, false), + Some(UiControl::DebugAudioMute(0)) + ); // The CD mute is the fifth (index 4) button. let (cd_control, cd_mute) = audio_tab_button_rects(rect)[4]; assert_eq!(cd_control, UiControl::DebugAudioMute(4)); let cd_pos = (cd_mute.x as i32 + 2, cd_mute.y as i32 + 2); assert_eq!( - ui_audio.control_at(cd_pos), + ui_audio.control_at(cd_pos, false), Some(UiControl::DebugAudioMute(4)) ); // On another tab that position does not resolve to a mute. - assert_eq!(ui.control_at(pos), Some(UiControl::PanelBody)); + assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelBody)); let mut panel = DebuggerPanel::new(); for ch in ['c', '0', '0', '3', 'C'] { @@ -4892,14 +4937,19 @@ mod tests { &ui, None, None, - true, - WarpSpeed::Max, - false, false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: true, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); - let menu = menu_rect(); + let menu = menu_rect(menu_items(false).len()); let probe = ((menu.y + MENU_PAD + 2) * w + menu.x + 4) * 4; assert_eq!(&frame[probe..probe + 4], &MENU_BG.to_le_bytes()); save(&frame, "menu"); @@ -4926,11 +4976,16 @@ mod tests { None, Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "about"); @@ -4947,11 +5002,16 @@ mod tests { None, Some(&PanelViewData::Shortcuts), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "shortcuts"); @@ -4985,11 +5045,16 @@ mod tests { Some(UiControl::CalCancel), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "calibration"); @@ -5033,11 +5098,16 @@ mod tests { Some(UiControl::DebugStep), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger"); @@ -5078,11 +5148,16 @@ mod tests { Some(UiControl::DebugRegToggle), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger-break"); @@ -5175,11 +5250,16 @@ mod tests { Some(UiControl::DebugAudioMute(0)), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger-audio"); @@ -5234,11 +5314,16 @@ mod tests { None, Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger-iomap"); @@ -5304,11 +5389,16 @@ mod tests { Some(UiControl::DebugPlaneToggle(0)), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger-video"); @@ -5411,11 +5501,16 @@ mod tests { Some(UiControl::AnalyzerUnderlay), Some(&data), false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "frame-analyzer"); @@ -5448,11 +5543,16 @@ mod tests { None, None, false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "console"); @@ -5476,11 +5576,16 @@ mod tests { Some(UiControl::LauncherRun), None, false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "launcher"); @@ -5544,11 +5649,16 @@ mod tests { None, None, false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "launcher-zorro"); @@ -5579,11 +5689,16 @@ mod tests { None, None, false, - WarpSpeed::Max, - false, - false, - JoystickInputMode::Gamepad, - PixelAspect::Tv, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "launcher-storage"); diff --git a/src/video/window.rs b/src/video/window.rs index 6cdafc2..583624a 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -604,6 +604,9 @@ pub struct App { gamepad: crate::gamepad::GamepadReader, /// Host source policy for the emulated port-2 joystick/CD32 pad. joystick_input_mode: JoystickInputMode, + /// Whether the serial port is bridged to MIDI, so the runtime menu offers + /// the device items. Fixed for the machine's life. + serial_is_midi: bool, /// Output frame-skip level for warp/turbo mode: how many emulated frames /// are retired per presented frame while warp is engaged. Presentation is /// vsync-gated, so this is what decouples warp speed from the host monitor @@ -841,8 +844,19 @@ impl App { info!("threaded render pipeline enabled"); RenderWorker::new(phosphor) }); + // MIDI needs a &mut to probe the sink; rebind so the parameter is not + // needlessly `mut` in a build without the feature. + #[cfg(feature = "midi")] + let (serial_is_midi, emu) = { + let mut emu = emu; + let is_midi = emu.bus_mut().midi_serial_mut().is_some(); + (is_midi, emu) + }; + #[cfg(not(feature = "midi"))] + let serial_is_midi = false; Self { emu, + serial_is_midi, fb: vec![0u32; MAX_FB_PIXELS], deinterlacer: Deinterlacer::with_phosphor(phosphor), present_fb: vec![0u32; FB_WIDTH * OUT_HEIGHT], @@ -984,6 +998,43 @@ impl App { self.set_joystick_input_mode(self.joystick_input_mode.next()); } + /// Current MIDI input/output device names for the runtime menu (empty when + /// the serial port is not in MIDI mode). + #[cfg(feature = "midi")] + fn midi_menu_labels(&mut self) -> (String, String) { + match self.emu.bus_mut().midi_serial_mut() { + Some(sink) => (sink.input_label(), sink.output_label()), + None => (String::new(), String::new()), + } + } + + #[cfg(not(feature = "midi"))] + fn midi_menu_labels(&mut self) -> (String, String) { + (String::new(), String::new()) + } + + #[cfg(feature = "midi")] + fn cycle_midi_input(&mut self) { + let label = self.emu.bus_mut().midi_serial_mut().map(|sink| { + sink.cycle_input(true); + sink.input_label() + }); + if let Some(label) = label { + self.show_osd(format!("MIDI input: {label}")); + } + } + + #[cfg(feature = "midi")] + fn cycle_midi_output(&mut self) { + let label = self.emu.bus_mut().midi_serial_mut().map(|sink| { + sink.cycle_output(true); + sink.output_label() + }); + if let Some(label) = label { + self.show_osd(format!("MIDI output: {label}")); + } + } + fn set_joystick_input_mode(&mut self, mode: JoystickInputMode) { if self.joystick_input_mode == mode { return; @@ -1602,6 +1653,7 @@ impl ApplicationHandler for App { let warp_speed = self.warp_speed; let recording = self.recorder.is_some(); let input_recording = self.input_recorder.is_some(); + let (midi_in_label, midi_out_label) = self.midi_menu_labels(); let ui_data = self.build_panel_view_data(); if let Some(r) = self.render.as_mut() { let frame = r.pixels.frame_mut(); @@ -1628,12 +1680,17 @@ impl ApplicationHandler for App { &self.ui, ui_hover, ui_data.as_ref(), - warp, - warp_speed, - recording, - input_recording, - self.joystick_input_mode, - super::pixel_aspect(), + self.serial_is_midi, + ui::MenuLabels { + warp, + warp_speed, + recording, + input_recording, + joystick_input_mode: self.joystick_input_mode, + pixel_aspect: super::pixel_aspect(), + midi_in: &midi_in_label, + midi_out: &midi_out_label, + }, ); if let Err(e) = r.pixels.render() { error!("pixels.render: {e}"); @@ -2235,7 +2292,7 @@ impl App { if self.ui.panel.is_none() && self.tool_panel_open() && !self.ui.menu_open { return None; } - self.ui.control_at(pos) + self.ui.control_at(pos, self.serial_is_midi) } fn main_ui_hover_changed( @@ -2610,6 +2667,10 @@ impl App { ui::MenuItem::Debugger => self.open_debugger(), ui::MenuItem::Console => self.open_console(), ui::MenuItem::JoystickInput => self.cycle_joystick_input_mode(), + #[cfg(feature = "midi")] + ui::MenuItem::MidiInput => self.cycle_midi_input(), + #[cfg(feature = "midi")] + ui::MenuItem::MidiOutput => self.cycle_midi_output(), ui::MenuItem::PixelAspect => self.toggle_pixel_aspect(), ui::MenuItem::Warp => self.toggle_warp(), ui::MenuItem::WarpLimit => self.cycle_warp_speed(), @@ -3712,6 +3773,12 @@ impl App { /// audio sink, are dropped here. fn run_machine(&mut self, emu: Emulator, cfg: &Config, raw: RawConfig) { self.emu = emu; + // The real machine may bridge serial to MIDI; the config-screen + // placeholder never does, so recompute now that the machine is live. + #[cfg(feature = "midi")] + { + self.serial_is_midi = self.emu.bus_mut().midi_serial_mut().is_some(); + } self.machine_config = raw; self.disk_playlists = cfg.floppy_playlists.clone(); self.disk_write_protected = std::array::from_fn(|i| { From fb2c0eea7f9d50981e1e1bc5d50fed0a663db524 Mon Sep 17 00:00:00 2001 From: Lee Hobson Date: Sat, 4 Jul 2026 09:30:47 +0100 Subject: [PATCH 2/3] midi: build in by default; address review feedback 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 --- Cargo.toml | 10 ++++++---- README.md | 11 ++++------- docs/internals/peripherals.md | 20 +++++++++++--------- src/video/ui.rs | 7 +++++-- src/video/window.rs | 9 ++++++++- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6b55e88..4db3c66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,15 +12,17 @@ categories = ["emulators"] publish = false [features] +default = ["midi"] display-plan-trace = [] # Local investigation switches that deliberately alter timing or rendering. # Normal builds keep these environment-variable overrides inert so release # behavior is hardware-derived and reproducible. internal-diagnostics = [] -# Host MIDI bridge for Paula's serial port (`[serial] mode = "midi"`). Off by -# default: a plain build is unchanged and pulls no MIDI code. On macOS this -# links CoreMIDI directly (no new crate); other platforms compile a stub until -# their backend lands. See src/midi/. +# Host MIDI bridge for Paula's serial port (`[serial] mode = "midi"`). Built in +# by default; `--no-default-features` compiles it out, and a MIDI-free build +# pulls no MIDI code and links no MIDI framework. Native backends per platform: +# CoreMIDI (macOS), the ALSA sequencer (Linux), WinMM (Windows); other targets +# get a stub. See src/midi/. midi = [] [dependencies] diff --git a/README.md b/README.md index 0bdb3d8..ac1c273 100644 --- a/README.md +++ b/README.md @@ -180,16 +180,13 @@ The full reference -- every key, machine profiles, Zorro boards, CD/HDD images, validation rules, and audio options -- is in the [configuration guide](docs/guide/configuration.md). -## MIDI (optional) +## MIDI Copperline can bridge Paula's serial port to the host's MIDI system, so an Amiga sequencer or tracker plays real synths -- or is itself played from a -host MIDI keyboard -- over the emulated serial line. It is an opt-in build -feature; a default build compiles no MIDI code and is unchanged. - -```sh -cargo build --release --features midi -``` +host MIDI keyboard -- over the emulated serial line. It is built in by default; +`cargo build --no-default-features` compiles it out, and such a build pulls no +MIDI code and links no MIDI framework. The backend is selected at compile time and talks to each platform's native API directly, with no wrapper crate: **CoreMIDI** on macOS, the **ALSA diff --git a/docs/internals/peripherals.md b/docs/internals/peripherals.md index 681d408..2e6bbb3 100644 --- a/docs/internals/peripherals.md +++ b/docs/internals/peripherals.md @@ -225,17 +225,19 @@ fast path, so the poll never locks. The host connection lives behind the `MidiBackend` trait, chosen by `cfg(target_os)`: macOS drives CoreMIDI (`coremidi.rs`), Linux the ALSA -sequencer (`alsa.rs`), and other targets (Windows for now) get `stub.rs`, -which enumerates nothing and refuses to open. Each backend links its +sequencer (`alsa.rs`), and Windows WinMM (`winmm.rs`); any other target gets +`stub.rs`, which enumerates nothing and refuses to open. Each backend links its platform library directly with no wrapper crate, and each maps the scheduled send onto that platform's timed-delivery primitive: a CoreMIDI -packet timestamp, an ALSA real-time queue event. A new backend implements -`send`/`set_output`/`set_input`/`current_output`/`current_input` plus free -`enumerate`/`open`; nothing else changes. The raw FFI is layout-sensitive --- CoreMIDI packs its packet list to 4 bytes, and the ALSA `snd_seq_event_t` -scheduling helpers are header-only inlines whose field writes are -replicated by hand -- so both mirrors are pinned with compile-time layout -assertions and want checking against live MIDI, not just review. +packet timestamp, an ALSA real-time queue event, or -- since WinMM carries no +timestamp -- a scheduler thread that fires each message when it comes due. A +new backend implements `send`/`set_output`/`set_input`/`current_output`/`current_input` +plus free `enumerate`/`open`; nothing else changes. The raw FFI is +layout-sensitive -- CoreMIDI packs its packet list to 4 bytes, the ALSA +`snd_seq_event_t` scheduling helpers are header-only inlines whose field writes +are replicated by hand, and WinMM's `MIDIHDR` is packed -- so the mirrors are +pinned with compile-time layout assertions and want checking against live MIDI, +not just review. Two debug knobs help tell a dead path from a routing one: `COPPERLINE_MIDI_DEBUG=1` reports per-second tx/rx byte counts and the diff --git a/src/video/ui.rs b/src/video/ui.rs index 53a5bc0..9aaa181 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -98,7 +98,10 @@ pub enum MenuItem { /// serial port is in MIDI mode, so the list is built per open rather than fixed. pub fn menu_items(midi_active: bool) -> Vec { let _ = midi_active; - let mut items = vec![ + // 7 leading + up to 2 MIDI + 9 trailing items, sized so appending never + // reallocates. + let mut items = Vec::with_capacity(18); + items.extend([ MenuItem::MachineConfig, MenuItem::FrameAnalyzer, MenuItem::Debugger, @@ -106,7 +109,7 @@ pub fn menu_items(midi_active: bool) -> Vec { MenuItem::Calibration, MenuItem::JoystickInput, MenuItem::PixelAspect, - ]; + ]); #[cfg(feature = "midi")] if midi_active { items.push(MenuItem::MidiInput); diff --git a/src/video/window.rs b/src/video/window.rs index 583624a..bcef466 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -1653,7 +1653,14 @@ impl ApplicationHandler for App { let warp_speed = self.warp_speed; let recording = self.recorder.is_some(); let input_recording = self.input_recorder.is_some(); - let (midi_in_label, midi_out_label) = self.midi_menu_labels(); + // Only the open runtime menu shows the device labels, and + // querying them allocates and touches the bus; skip that work on + // every other frame. + let (midi_in_label, midi_out_label) = if self.ui.menu_open { + self.midi_menu_labels() + } else { + (String::new(), String::new()) + }; let ui_data = self.build_panel_view_data(); if let Some(r) = self.render.as_mut() { let frame = r.pixels.frame_mut(); From cf9667716c072816edca91014a23ffe8d205d0d9 Mon Sep 17 00:00:00 2001 From: Lee Hobson Date: Sat, 4 Jul 2026 10:04:01 +0100 Subject: [PATCH 3/3] midi: place the runtime menu MIDI items below Joystick input 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 --- src/video/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/video/ui.rs b/src/video/ui.rs index 9aaa181..ea8abc2 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -108,13 +108,13 @@ pub fn menu_items(midi_active: bool) -> Vec { MenuItem::Console, MenuItem::Calibration, MenuItem::JoystickInput, - MenuItem::PixelAspect, ]); #[cfg(feature = "midi")] if midi_active { items.push(MenuItem::MidiInput); items.push(MenuItem::MidiOutput); } + items.push(MenuItem::PixelAspect); items.extend([ MenuItem::Warp, MenuItem::WarpLimit,