diff --git a/bindings/python/example_rawio.py b/bindings/python/example_rawio.py new file mode 100644 index 00000000..0e7a4780 --- /dev/null +++ b/bindings/python/example_rawio.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +""" +Raw I/O backend example for pylibremidi. + +Demonstrates a loopback: bytes written by MidiOut are fed directly +back into MidiIn, simulating a serial wire connection. +This is the pattern you'd use for custom transports (serial, SPI, USB HID, etc.) +""" +import pylibremidi as lm + + +def midi1_example(): + print("=== MIDI 1 Raw I/O ===") + + # The library will give us a callback to call when bytes arrive + stored_cb = [None] + + # Input: receive and print parsed MIDI messages + in_config = lm.InputConfiguration() + in_config.on_message = lambda msg: print(f" Received: {msg}") + in_config.direct = True # Call Python callbacks directly (no poll needed) + + rawio_in = lm.RawioInputConfiguration() + rawio_in.set_receive_callback = lambda cb: stored_cb.__setitem__(0, cb) + rawio_in.stop_receive = lambda: stored_cb.__setitem__(0, None) + + midi_in = lm.MidiIn(in_config, rawio_in) + midi_in.open_virtual_port("rawio_in") + + # Output: loopback written bytes into the input + out_config = lm.OutputConfiguration() + + def loopback_write(data): + if stored_cb[0]: + stored_cb[0](data, 0) + return lm.Error() + + rawio_out = lm.RawioOutputConfiguration() + rawio_out.write_bytes = loopback_write + + midi_out = lm.MidiOut(out_config, rawio_out) + midi_out.open_virtual_port("rawio_out") + + # Send some MIDI messages - they loop back through the rawio transport + print("Sending Note On C4...") + midi_out.send_message(0x90, 60, 100) + + print("Sending Note Off C4...") + midi_out.send_message(0x80, 60, 0) + + print("Sending CC Volume=80...") + midi_out.send_message(0xB0, 7, 80) + + # close_port breaks the reference cycle: midi_out -> write -> stored_cb -> C++ callback -> midi_in + midi_in.close_port() + midi_out.close_port() + + +def midi2_example(): + print("\n=== MIDI 2 Raw I/O (UMP) ===") + + stored_cb = [None] + + ump_in_config = lm.UmpInputConfiguration() + ump_in_config.on_message = lambda msg: print(f" Received UMP: {msg}") + ump_in_config.direct = True + + rawio_ump_in = lm.RawioUmpInputConfiguration() + rawio_ump_in.set_receive_callback = lambda cb: stored_cb.__setitem__(0, cb) + rawio_ump_in.stop_receive = lambda: stored_cb.__setitem__(0, None) + + midi_in = lm.MidiIn(ump_in_config, rawio_ump_in) + midi_in.open_virtual_port("rawio_ump_in") + + ump_out_config = lm.OutputConfiguration() + + def loopback_write_ump(data): + if stored_cb[0]: + stored_cb[0](data, 0) + return lm.Error() + + rawio_ump_out = lm.RawioUmpOutputConfiguration() + rawio_ump_out.write_ump = loopback_write_ump + + midi_out = lm.MidiOut(ump_out_config, rawio_ump_out) + midi_out.open_virtual_port("rawio_ump_out") + + # Send a MIDI 2.0 Note On UMP (group 0, channel 0, note 60) + print("Sending UMP Note On...") + midi_out.send_ump(0x4090003C, 0xC0000000) + + midi_in.close_port() + midi_out.close_port() + + +midi1_example() +midi2_example() +print("\nDone.") diff --git a/bindings/python/pylibremidi.cpp b/bindings/python/pylibremidi.cpp index 56a21451..e384041d 100644 --- a/bindings/python/pylibremidi.cpp +++ b/bindings/python/pylibremidi.cpp @@ -68,6 +68,10 @@ struct observer_poll_wrapper { explicit observer_poll_wrapper(observer_configuration conf, libremidi::observer_api_configuration api_conf) : conf{conf}, impl{process(std::move(conf)), std::move(api_conf)} {} + ~observer_poll_wrapper() { + conf = {}; + } + observer_configuration process(observer_configuration &&obs) { if (obs.on_error) obs.on_error = [this](std::string_view errorText, const source_location &) { queue.enqueue(poll_queue::error_message{std::string{errorText}}); }; @@ -128,6 +132,12 @@ struct midi_in_poll_wrapper { explicit midi_in_poll_wrapper(ump_input_configuration_wrapper conf) noexcept : python_ump_callbacks{conf}, impl{this->process(std::move(conf))} {} explicit midi_in_poll_wrapper(ump_input_configuration_wrapper conf, input_api_configuration api_conf) : python_ump_callbacks{conf}, impl{this->process(std::move(conf)), std::move(api_conf)} {} + ~midi_in_poll_wrapper() { + impl.close_port(); + python_midi1_callbacks = {}; + python_ump_callbacks = {}; + } + input_configuration process(input_configuration_wrapper obs) { python_midi1_callbacks = obs; @@ -244,6 +254,79 @@ struct midi_in_poll_wrapper { } }; +// Python-friendly wrappers for rawio configs. +// std::span cannot cross the Python/C++ boundary, so we use std::vector +// in the Python-facing callback signatures and convert internally. +struct rawio_input_configuration_python { + using py_receive_callback = std::function, int64_t)>; + std::function set_receive_callback; + std::function stop_receive = [] {}; + + rawio_input_configuration to_cpp() const { + rawio_input_configuration conf; + if (set_receive_callback) { + conf.set_receive_callback = [py_cb = set_receive_callback]( + rawio_input_configuration::receive_callback cpp_cb) { + py_cb([cpp_cb = std::move(cpp_cb)](std::vector data, int64_t ts) { + cpp_cb(std::span(data), ts); + }); + }; + } + conf.stop_receive = stop_receive; + return conf; + } +}; + +struct rawio_output_configuration_python { + std::function)> write_bytes + = [](std::vector) { return stdx::error{}; }; + + rawio_output_configuration to_cpp() const { + rawio_output_configuration conf; + if (write_bytes) { + conf.write_bytes = [py_cb = write_bytes](std::span bytes) -> stdx::error { + return py_cb({bytes.begin(), bytes.end()}); + }; + } + return conf; + } +}; + +struct rawio_ump_input_configuration_python { + using py_receive_callback = std::function, int64_t)>; + std::function set_receive_callback; + std::function stop_receive = [] {}; + + rawio_ump_input_configuration to_cpp() const { + rawio_ump_input_configuration conf; + if (set_receive_callback) { + conf.set_receive_callback = [py_cb = set_receive_callback]( + rawio_ump_input_configuration::receive_callback cpp_cb) { + py_cb([cpp_cb = std::move(cpp_cb)](std::vector data, int64_t ts) { + cpp_cb(std::span(data), ts); + }); + }; + } + conf.stop_receive = stop_receive; + return conf; + } +}; + +struct rawio_ump_output_configuration_python { + std::function)> write_ump + = [](std::vector) { return stdx::error{}; }; + + rawio_ump_output_configuration to_cpp() const { + rawio_ump_output_configuration conf; + if (write_ump) { + conf.write_ump = [py_cb = write_ump](std::span words) -> stdx::error { + return py_cb({words.begin(), words.end()}); + }; + } + return conf; + } +}; + struct midi_out_poll_wrapper { moodycamel::ReaderWriterQueue queue{}; output_configuration_wrapper python_midi1_callbacks; @@ -253,6 +336,11 @@ struct midi_out_poll_wrapper { explicit midi_out_poll_wrapper(const output_configuration_wrapper &conf) noexcept : python_midi1_callbacks{conf}, impl{this->process(std::move(conf))} {} explicit midi_out_poll_wrapper(output_configuration_wrapper conf, output_api_configuration api_conf) : python_midi1_callbacks{conf}, impl{this->process(std::move(conf)), std::move(api_conf)} {} + ~midi_out_poll_wrapper() { + impl.close_port(); + python_midi1_callbacks = {}; + } + output_configuration process(output_configuration_wrapper obs) { python_midi1_callbacks = obs; @@ -294,6 +382,7 @@ NB_MODULE(pylibremidi, m) { namespace nb = nanobind; nb::class_(m, "Error") + .def(nb::init<>()) .def("__bool__", [](stdx::error e) { return e != stdx::error{}; }) .def("__str__", [](stdx::error e) { return e.message().data(); }) .def("__repr__", [](stdx::error e) { return e.message().data(); }); @@ -310,6 +399,7 @@ NB_MODULE(pylibremidi, m) { .value("PIPEWIRE", libremidi::API::PIPEWIRE) .value("KEYBOARD", libremidi::API::KEYBOARD) .value("NETWORK", libremidi::API::NETWORK) + .value("RAW_IO", libremidi::API::RAW_IO) .value("ALSA_RAW_UMP", libremidi::API::ALSA_RAW_UMP) .value("ALSA_SEQ_UMP", libremidi::API::ALSA_SEQ_UMP) @@ -319,6 +409,7 @@ NB_MODULE(pylibremidi, m) { .value("NETWORK_UMP", libremidi::API::NETWORK_UMP) .value("JACK_UMP", libremidi::API::JACK_UMP) .value("PIPEWIRE_UMP", libremidi::API::PIPEWIRE_UMP) + .value("RAW_IO_UMP", libremidi::API::RAW_IO_UMP) .value("DUMMY", libremidi::API::DUMMY) .export_values(); @@ -469,6 +560,15 @@ NB_MODULE(pylibremidi, m) { nb::class_(m, "WinmmInputConfiguration").def(nb::init<>()); nb::class_(m, "WinuwpInputConfiguration").def(nb::init<>()); + nb::class_(m, "RawioInputConfiguration") + .def(nb::init<>()) + .def_rw("set_receive_callback", &libremidi::rawio_input_configuration_python::set_receive_callback) + .def_rw("stop_receive", &libremidi::rawio_input_configuration_python::stop_receive); + nb::class_(m, "RawioUmpInputConfiguration") + .def(nb::init<>()) + .def_rw("set_receive_callback", &libremidi::rawio_ump_input_configuration_python::set_receive_callback) + .def_rw("stop_receive", &libremidi::rawio_ump_input_configuration_python::stop_receive); + nb::class_(m, "AlsaRawOutputConfiguration").def(nb::init<>()); nb::class_(m, "AlsaRawUmpOutputConfiguration").def(nb::init<>()); nb::class_(m, "AlsaSeqOutputConfiguration").def(nb::init<>()).def_rw("client_name", &libremidi::alsa_seq::output_configuration::client_name); @@ -486,6 +586,13 @@ NB_MODULE(pylibremidi, m) { nb::class_(m, "WinmmOutputConfiguration").def(nb::init<>()); nb::class_(m, "WinuwpOutputConfiguration").def(nb::init<>()); + nb::class_(m, "RawioOutputConfiguration") + .def(nb::init<>()) + .def_rw("write_bytes", &libremidi::rawio_output_configuration_python::write_bytes); + nb::class_(m, "RawioUmpOutputConfiguration") + .def(nb::init<>()) + .def_rw("write_ump", &libremidi::rawio_ump_output_configuration_python::write_ump); + nb::class_(m, "AlsaRawObserverConfiguration").def(nb::init<>()); nb::class_(m, "AlsaRawUmpObserverConfiguration").def(nb::init<>()); nb::class_(m, "AlsaSeqObserverConfiguration").def(nb::init<>()).def_rw("client_name", &libremidi::alsa_seq::observer_configuration::client_name); @@ -511,6 +618,9 @@ NB_MODULE(pylibremidi, m) { nb::class_(m, "WinmmObserverConfiguration").def(nb::init<>()); nb::class_(m, "WinuwpObserverConfiguration").def(nb::init<>()); + nb::class_(m, "RawioObserverConfiguration").def(nb::init<>()); + nb::class_(m, "RawioUmpObserverConfiguration").def(nb::init<>()); + nb::class_(m, "Observer") .def(nb::init<>()) .def(nb::init()) @@ -525,6 +635,12 @@ NB_MODULE(pylibremidi, m) { .def(nb::init()) .def(nb::init()) .def(nb::init()) + .def("__init__", [](libremidi::midi_in_poll_wrapper *self, libremidi::input_configuration_wrapper conf, libremidi::rawio_input_configuration_python apiconf) { + new (self) libremidi::midi_in_poll_wrapper(std::move(conf), libremidi::input_api_configuration{apiconf.to_cpp()}); + }) + .def("__init__", [](libremidi::midi_in_poll_wrapper *self, libremidi::ump_input_configuration_wrapper conf, libremidi::rawio_ump_input_configuration_python apiconf) { + new (self) libremidi::midi_in_poll_wrapper(std::move(conf), libremidi::input_api_configuration{apiconf.to_cpp()}); + }) .def("get_current_api", [](libremidi::midi_in_poll_wrapper &self) { return self.impl.get_current_api(); }) .def("open_port", [](libremidi::midi_in_poll_wrapper &self, const libremidi::input_port &p) { return self.impl.open_port(p); }) .def("open_port", [](libremidi::midi_in_poll_wrapper &self, const libremidi::input_port &p, std::string_view name) { return self.impl.open_port(p, name); }) @@ -542,6 +658,12 @@ NB_MODULE(pylibremidi, m) { .def(nb::init<>()) .def(nb::init()) .def(nb::init()) + .def("__init__", [](libremidi::midi_out_poll_wrapper *self, libremidi::output_configuration_wrapper conf, libremidi::rawio_output_configuration_python apiconf) { + new (self) libremidi::midi_out_poll_wrapper(std::move(conf), libremidi::output_api_configuration{apiconf.to_cpp()}); + }) + .def("__init__", [](libremidi::midi_out_poll_wrapper *self, libremidi::output_configuration_wrapper conf, libremidi::rawio_ump_output_configuration_python apiconf) { + new (self) libremidi::midi_out_poll_wrapper(std::move(conf), libremidi::output_api_configuration{apiconf.to_cpp()}); + }) .def("get_current_api", [](libremidi::midi_out_poll_wrapper &self) { return self.impl.get_current_api(); }) .def("open_port", [](libremidi::midi_out_poll_wrapper &self, const libremidi::output_port &p) { return self.impl.open_port(p); }) .def("open_port", [](libremidi::midi_out_poll_wrapper &self, const libremidi::output_port &p, std::string_view name) { return self.impl.open_port(p, name); }) @@ -555,23 +677,21 @@ NB_MODULE(pylibremidi, m) { // clang-format off .def("send_message", [](libremidi::midi_out_poll_wrapper &self, const libremidi::message& m) { return self.impl.send_message(m); }) - .def("send_message", [](libremidi::midi_out_poll_wrapper &self, const unsigned char* m, size_t size) { return self.impl.send_message(m, size); }) .def("send_message", [](libremidi::midi_out_poll_wrapper &self, std::vector m) { return self.impl.send_message(m); }) .def("send_message", [](libremidi::midi_out_poll_wrapper &self, unsigned char b0) { return self.impl.send_message(b0); }) .def("send_message", [](libremidi::midi_out_poll_wrapper &self, unsigned char b0, unsigned char b1) { return self.impl.send_message(b0, b1); }) .def("send_message", [](libremidi::midi_out_poll_wrapper &self, unsigned char b0, unsigned char b1, unsigned char b2) { return self.impl.send_message(b0, b1, b2); }) - .def("schedule_message", [](libremidi::midi_out_poll_wrapper &self, int64_t t, const unsigned char* m, size_t size) { return self.impl.schedule_message(t, m, size); }) + .def("schedule_message", [](libremidi::midi_out_poll_wrapper &self, int64_t t, std::vector m) { return self.impl.schedule_message(t, m.data(), m.size()); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, const libremidi::ump& m) { return self.impl.send_ump(m); }) - .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, const uint32_t* ump, size_t size) { return self.impl.send_ump(ump, size); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, std::vector m) { return self.impl.send_ump(m); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, uint32_t u0) { return self.impl.send_ump(u0); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, uint32_t u0, uint32_t u1) { return self.impl.send_ump(u0, u1); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, uint32_t u0, uint32_t u1, uint32_t u2) { return self.impl.send_ump(u0, u1, u2); }) .def("send_ump", [](libremidi::midi_out_poll_wrapper &self, uint32_t u0, uint32_t u1, uint32_t u2, uint32_t u3) { return self.impl.send_ump(u0, u1, u2, u3); }) - .def("schedule_message", [](libremidi::midi_out_poll_wrapper &self, int64_t t, const uint32_t* m, size_t size) { return self.impl.schedule_ump(t, m, size); }) + .def("schedule_ump", [](libremidi::midi_out_poll_wrapper &self, int64_t t, std::vector m) { return self.impl.schedule_ump(t, m.data(), m.size()); }) // clang-format on .def("poll", &libremidi::midi_out_poll_wrapper::poll); diff --git a/cmake/libremidi.examples.cmake b/cmake/libremidi.examples.cmake index 26fb0458..f293113b 100644 --- a/cmake/libremidi.examples.cmake +++ b/cmake/libremidi.examples.cmake @@ -30,6 +30,7 @@ add_example(sysextest) add_example(minimal) add_example(midi2_echo) add_example(rawmidiin) +add_example(rawio) if(LIBREMIDI_HAS_STD_FLAT_SET AND LIBREMIDI_HAS_STD_PRINTLN) add_example(midi_to_pattern) diff --git a/cmake/libremidi.tests.cmake b/cmake/libremidi.tests.cmake index fed060e8..0a1ce628 100644 --- a/cmake/libremidi.tests.cmake +++ b/cmake/libremidi.tests.cmake @@ -52,6 +52,9 @@ target_link_libraries(midi_stream_decoder_test PRIVATE libremidi Catch2::Catch2W add_executable(midi_timing_test tests/unit/midi_timing.cpp) target_link_libraries(midi_timing_test PRIVATE libremidi Catch2::Catch2WithMain) +add_executable(rawio_test tests/unit/rawio.cpp) +target_link_libraries(rawio_test PRIVATE libremidi Catch2::Catch2WithMain) + include(CTest) add_test(NAME conversion_test COMMAND conversion_test) add_test(NAME error_test COMMAND error_test) @@ -63,3 +66,4 @@ add_test(NAME midifile_write_tracks_test COMMAND midifile_write_tracks_test) add_test(NAME protocols_test COMMAND protocols_test) add_test(NAME midi_stream_decoder_test COMMAND midi_stream_decoder_test) add_test(NAME midi_timing_test COMMAND midi_timing_test) +add_test(NAME rawio_test COMMAND rawio_test) diff --git a/examples/rawio.cpp b/examples/rawio.cpp new file mode 100644 index 00000000..19c5c82f --- /dev/null +++ b/examples/rawio.cpp @@ -0,0 +1,87 @@ +#include "utils.hpp" + +#include +#include + +#include +#include + +/// This example demonstrates the raw I/O backend, which allows +/// the user to plug in their own byte-level transport functions. +/// This is useful for Arduino, Teensy, ESP32, serial ports, SPI, USB HID, etc. +/// +/// In this example we simulate a loopback: bytes written by the output +/// are fed directly into the input, as if connected by a serial wire. +int main() +{ + // MIDI 1.0 raw I/O example + { + std::cout << "=== MIDI 1 Raw I/O ===" << std::endl; + + // The receive callback will be stored here by the library + libremidi::rawio_input_configuration::receive_callback feed_input; + + // Create a MIDI input that prints received messages + libremidi::midi_in midiin{ + libremidi::input_configuration{ + .on_message + = [](const libremidi::message& m) { + std::cout << "Received MIDI 1: " << m << std::endl; + }}, + libremidi::rawio_input_configuration{ + .set_receive_callback = [&](auto cb) { feed_input = std::move(cb); }, + .stop_receive = [&] { feed_input = nullptr; }}}; + + // Create a MIDI output whose write function loops back to the input + libremidi::midi_out midiout{ + libremidi::output_configuration{}, + libremidi::rawio_output_configuration{ + .write_bytes = [&](std::span bytes) -> stdx::error { + // Simulate a serial loopback: bytes go directly to the input + if (feed_input) + feed_input(bytes, 0); + return {}; + }}}; + + midiin.open_virtual_port("rawio_in"); + midiout.open_virtual_port("rawio_out"); + + // Send some MIDI messages + midiout.send_message(0x90, 60, 100); // Note On: C4, velocity 100 + midiout.send_message(0x80, 60, 0); // Note Off: C4 + midiout.send_message(0xB0, 7, 80); // CC: Volume = 80 + } + + // MIDI 2.0 (UMP) raw I/O example + { + std::cout << "\n=== MIDI 2 Raw I/O (UMP) ===" << std::endl; + + libremidi::rawio_ump_input_configuration::receive_callback feed_input; + + libremidi::midi_in midiin{ + libremidi::ump_input_configuration{.on_message = [](const libremidi::ump& m) { + std::cout << "Received UMP: " << m << std::endl; + }}, + libremidi::rawio_ump_input_configuration{ + .set_receive_callback = [&](auto cb) { feed_input = std::move(cb); }, + .stop_receive = [&] { feed_input = nullptr; }}}; + + libremidi::midi_out midiout{ + libremidi::output_configuration{}, + libremidi::rawio_ump_output_configuration{ + .write_ump = [&](std::span words) -> stdx::error { + if (feed_input) + feed_input(words, 0); + return {}; + }}}; + + midiin.open_virtual_port("rawio_ump_in"); + midiout.open_virtual_port("rawio_ump_out"); + + // Send a MIDI 2.0 Note On UMP (type 4, group 0, channel 0, note 60) + uint32_t ump[2] = {0x40900000 | 60, 0xC0000000}; + midiout.send_ump(ump, 2); + } + + return 0; +} diff --git a/examples/rawio_esp32_serial/libremidi b/examples/rawio_esp32_serial/libremidi new file mode 120000 index 00000000..4dfb0b10 --- /dev/null +++ b/examples/rawio_esp32_serial/libremidi @@ -0,0 +1 @@ +../../include/libremidi \ No newline at end of file diff --git a/examples/rawio_esp32_serial/rawio_esp32_serial.ino b/examples/rawio_esp32_serial/rawio_esp32_serial.ino new file mode 100644 index 00000000..d458f27b --- /dev/null +++ b/examples/rawio_esp32_serial/rawio_esp32_serial.ino @@ -0,0 +1,149 @@ +/// ESP32 Serial MIDI example using libremidi's raw I/O backend. +/// +/// Wiring: MIDI IN on Serial2 RX (GPIO 16), MIDI OUT on Serial2 TX (GPIO 17) +/// Standard MIDI baud rate is 31250. +/// +/// This sketch receives MIDI on Serial2, parses it through libremidi +/// (giving you structured messages with sysex filtering, etc.), +/// sends MIDI back out on the same serial port, +/// and broadcasts every received message as an OSC /midi UDP packet +/// to the local network (192.168.1.255:5678). + +#include +#include + +// Note: you need to have the "include/libremidi" folder copied or +// symlinked into the sketch folder for this to work, or change the +// Arduino platform configuration files to add the proper include path. +#define LIBREMIDI_HEADER_ONLY 1 +#include +#include + +// --- Configuration --- +static constexpr int MIDI_BAUD = 31250; +static constexpr int MIDI_RX_PIN = 16; +static constexpr int MIDI_TX_PIN = 17; + +static const char* WIFI_SSID = "your-ssid"; +static const char* WIFI_PASS = "your-password"; + +static const IPAddress UDP_BROADCAST{192, 168, 1, 255}; +static constexpr uint16_t UDP_PORT = 49444; +static const char* OSC_PATH = "/midi"; // must be 5 chars (+ padding) for the packet layout below + +// --- Globals --- + +// The callback that libremidi gives us to feed incoming bytes +static libremidi::rawio_input_configuration::receive_callback g_feed_input; + +// The libremidi objects need to survive across loop() calls +static std::optional g_midi_in; +static std::optional g_midi_out; + +static WiFiUDP g_udp; + +/// Build and send an OSC message containing a single 3-byte MIDI event. +/// +/// OSC /midi packet layout (all 4-byte aligned): +/// "/midi\0\0\0" (8 bytes: pattern + null + padding) +/// ",m\0\0" (4 bytes: typetag string) +/// [port, b0, b1, b2] (4 bytes: OSC MIDI atom) +void osc_broadcast_midi(uint8_t status, uint8_t d1, uint8_t d2) +{ + // Pre-built packet: only the last 3 bytes change + uint8_t pkt[16] = { + // OSC address pattern "/midi" + null + 2 padding zeros + '/', 'm', 'i', 'd', 'i', 0, 0, 0, + // OSC type tag ",m" + null + 1 padding zero + ',', 'm', 0, 0, + // OSC MIDI data: port (0), status, data1, data2 + 0, status, d1, d2}; + + g_udp.beginPacket(UDP_BROADCAST, UDP_PORT); + g_udp.write(pkt, sizeof(pkt)); + g_udp.endPacket(); +} + +// Called by libremidi whenever a complete MIDI message is received and parsed +void on_midi_message(const libremidi::message& msg) +{ + // Print to USB serial for debugging + Serial.printf("MIDI [%02X", msg.bytes[0]); + for (size_t i = 1; i < msg.bytes.size(); i++) + Serial.printf(" %02X", msg.bytes[i]); + Serial.println("]"); + + // Broadcast as OSC over UDP (channel messages are always 3 bytes) + if (msg.bytes.size() == 3) + osc_broadcast_midi(msg.bytes[0], msg.bytes[1], msg.bytes[2]); + + // Echo note-on messages back on serial with velocity halved + if (msg.get_message_type() == libremidi::message_type::NOTE_ON && g_midi_out) + { + uint8_t velocity = msg.bytes[2] / 2; + g_midi_out->send_message(msg.bytes[0], msg.bytes[1], velocity); + } +} + +void setup() +{ + // USB serial for debug output + Serial.begin(115200); + + // Connect to WiFi + WiFi.begin(WIFI_SSID, WIFI_PASS); + Serial.print("Connecting to WiFi"); + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.printf("\nConnected, IP: %s\n", WiFi.localIP().toString().c_str()); + + // Start UDP for OSC broadcast + g_udp.begin(UDP_PORT); + + // MIDI serial port + Serial2.begin(MIDI_BAUD, SERIAL_8N1, MIDI_RX_PIN, MIDI_TX_PIN); + + // Create MIDI input + g_midi_in.emplace( + libremidi::input_configuration{ + .on_message = on_midi_message, + .ignore_sysex = true, + .ignore_timing = true, + .ignore_sensing = true, + }, + libremidi::rawio_input_configuration{ + .set_receive_callback = [](auto cb) { g_feed_input = std::move(cb); }, + .stop_receive = [] { g_feed_input = nullptr; }, + }); + + // Create MIDI output (serial) + g_midi_out.emplace( + libremidi::output_configuration{}, + libremidi::rawio_output_configuration{ + .write_bytes = [](std::span bytes) -> stdx::error { + Serial2.write(bytes.data(), bytes.size()); + return {}; + }}); + + // Open ports to activate the callbacks + g_midi_in->open_virtual_port("esp32_in"); + g_midi_out->open_virtual_port("esp32_out"); + + Serial.println("MIDI ready, broadcasting OSC to " + UDP_BROADCAST.toString() + ":" + UDP_PORT); +} + +void loop() +{ + // Read all available bytes from the MIDI serial port + // and feed them into libremidi for parsing + int avail = Serial2.available(); + if (avail > 0 && g_feed_input) + { + uint8_t buf[64]; + int n = Serial2.readBytes(buf, min(avail, (int)sizeof(buf))); + g_feed_input({buf, static_cast(n)}, 0); + } +} diff --git a/include/libremidi/api-c.h b/include/libremidi/api-c.h index 510cd793..f0d7eb06 100644 --- a/include/libremidi/api-c.h +++ b/include/libremidi/api-c.h @@ -26,6 +26,7 @@ enum libremidi_api NETWORK, /*!< MIDI over IP */ ANDROID_AMIDI, /*!< Android AMidi API */ KDMAPI, /*!< OmniMIDI KDMAPI (Windows) */ + RAW_IO, /*!< User-provided raw byte I/O (serial, SPI, USB, etc.) */ // MIDI 2.0 APIs ALSA_RAW_UMP = 0x1000, /*!< Raw ALSA API for MIDI 2.0 */ @@ -36,6 +37,7 @@ enum libremidi_api NETWORK_UMP, /*!< MIDI2 over IP */ JACK_UMP, /*!< MIDI2 over JACK, type "32 bit raw UMP". Requires PipeWire v1.4+. */ PIPEWIRE_UMP, /*!< MIDI2 over PipeWire. Requires v1.4+. */ + RAW_IO_UMP, /*!< User-provided raw UMP I/O (serial, SPI, USB, etc.) */ DUMMY = 0xFFFF /*!< A compilable but non-functional API. */ }; diff --git a/include/libremidi/backends.hpp b/include/libremidi/backends.hpp index 54443761..911cf75b 100644 --- a/include/libremidi/backends.hpp +++ b/include/libremidi/backends.hpp @@ -77,6 +77,9 @@ #include #endif +#include +#include + #if defined(LIBREMIDI_ANDROID) #include #endif @@ -144,6 +147,7 @@ LIBREMIDI_STATIC constexpr auto available_backends = make_tl( android::backend{} #endif , + rawio::backend{}, dummy_backend{}); // There should always be at least one back-end. @@ -194,6 +198,7 @@ LIBREMIDI_STATIC constexpr auto available_backends = make_tl( pipewire_ump::backend{} #endif , + rawio_ump::backend{}, dummy_backend{}); // There should always be at least one back-end. diff --git a/include/libremidi/backends/rawio.hpp b/include/libremidi/backends/rawio.hpp new file mode 100644 index 00000000..a837e449 --- /dev/null +++ b/include/libremidi/backends/rawio.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +#include + +#include + +NAMESPACE_LIBREMIDI::rawio +{ +struct backend +{ + using midi_in = rawio::midi_in; + using midi_out = rawio::midi_out; + using midi_observer = rawio::observer; + using midi_in_configuration = rawio_input_configuration; + using midi_out_configuration = rawio_output_configuration; + using midi_observer_configuration = rawio_observer_configuration; + static const constexpr auto API = libremidi::API::RAW_IO; + static const constexpr std::string_view name = "raw_io"; + static const constexpr std::string_view display_name = "Raw I/O"; + + static inline bool available() noexcept { return true; } +}; +} diff --git a/include/libremidi/backends/rawio/config.hpp b/include/libremidi/backends/rawio/config.hpp new file mode 100644 index 00000000..bf644476 --- /dev/null +++ b/include/libremidi/backends/rawio/config.hpp @@ -0,0 +1,101 @@ +#pragma once +#include +#include + +#include +#include +#include + +NAMESPACE_LIBREMIDI +{ +/** + * Configuration for raw I/O MIDI 1.0 input. + * + * The user provides a function that will be called with a callback + * to invoke whenever raw MIDI bytes are received from the transport + * (serial port, SPI, USB, etc.) + * + * Example with Arduino: + * rawio_input_configuration::receive_callback stored_cb; + * .set_receive_callback = [&](auto cb) { stored_cb = cb; }, + * .stop_receive = [&] { stored_cb = nullptr; } + * // In serialEvent(): stored_cb({buf, n}, 0); + */ +struct rawio_input_configuration +{ + /// Signature of the callback the library gives to the user. + /// The user should call it with incoming MIDI bytes and a timestamp + /// (in nanoseconds, or 0 if unknown). + using receive_callback = std::function, int64_t)>; + + /// Called when the port opens. The library passes a callback that + /// the user must store and invoke whenever bytes arrive. + std::function set_receive_callback + = [](const receive_callback&) {}; + + /// Called when the port closes. The user should stop calling the + /// receive callback after this. + std::function stop_receive = [] {}; +}; + +/** + * Configuration for raw I/O MIDI 1.0 output. + * + * The user provides a function to write raw MIDI bytes to their transport. + * + * Example: + * .write_bytes = [](std::span bytes) { + * Serial.write(bytes.data(), bytes.size()); + * return stdx::error{}; + * } + */ +struct rawio_output_configuration +{ + /// Called by the library to send raw MIDI bytes over the user's transport. + std::function)> write_bytes + = [](std::span) { return stdx::error{}; }; +}; + +/// Observer configuration - raw I/O has no port enumeration. +struct rawio_observer_configuration +{ +}; + +/** + * Configuration for raw I/O MIDI 2.0 (UMP) input. + * + * Same pattern as rawio_input_configuration but with uint32_t UMP words. + */ +struct rawio_ump_input_configuration +{ + /// Signature of the callback the library gives to the user. + /// The user should call it with incoming UMP words and a timestamp + /// (in nanoseconds, or 0 if unknown). + using receive_callback = std::function, int64_t)>; + + /// Called when the port opens. The library passes a callback that + /// the user must store and invoke whenever UMP words arrive. + std::function set_receive_callback + = [](const receive_callback&) {}; + + /// Called when the port closes. + std::function stop_receive = [] {}; +}; + +/** + * Configuration for raw I/O MIDI 2.0 (UMP) output. + * + * The user provides a function to write UMP words to their transport. + */ +struct rawio_ump_output_configuration +{ + /// Called by the library to send UMP words over the user's transport. + std::function)> write_ump + = [](std::span) { return stdx::error{}; }; +}; + +/// Observer configuration - raw I/O UMP has no port enumeration. +struct rawio_ump_observer_configuration +{ +}; +} diff --git a/include/libremidi/backends/rawio/midi_in.hpp b/include/libremidi/backends/rawio/midi_in.hpp new file mode 100644 index 00000000..5d1ac6fd --- /dev/null +++ b/include/libremidi/backends/rawio/midi_in.hpp @@ -0,0 +1,84 @@ +#pragma once +#include +#include +#include + +#include + +NAMESPACE_LIBREMIDI::rawio +{ +class midi_in final + : public midi1::in_api + , public error_handler +{ +public: + using midi_api::client_open_; + struct + : input_configuration + , rawio_input_configuration + { + } configuration; + + explicit midi_in(input_configuration&& conf, rawio_input_configuration&& apiconf) + : configuration{std::move(conf), std::move(apiconf)} + { + client_open_ = stdx::error{}; + } + + ~midi_in() override { close_port(); } + + libremidi::API get_current_api() const noexcept override { return libremidi::API::RAW_IO; } + + stdx::error open_port(const input_port&, std::string_view) override + { + return open_virtual_port({}); + } + + stdx::error open_virtual_port(std::string_view) override + { + if (!configuration.set_receive_callback) + return std::errc::function_not_supported; + + configuration.set_receive_callback( + [this](std::span bytes, int64_t timestamp) { + on_bytes(bytes, timestamp); + }); + + return stdx::error{}; + } + + stdx::error close_port() override + { + if (configuration.stop_receive) + configuration.stop_receive(); + // Clear all callbacks to break reference cycles (prevents leaks in binding layers) + configuration.set_receive_callback = nullptr; + configuration.stop_receive = nullptr; + return stdx::error{}; + } + + stdx::error set_port_name(std::string_view) override { return stdx::error{}; } + + timestamp absolute_timestamp() const noexcept override + { + return std::chrono::steady_clock::now().time_since_epoch().count(); + } + +private: + void on_bytes(std::span bytes, int64_t ts) + { + static constexpr timestamp_backend_info timestamp_info{ + .has_absolute_timestamps = true, + .absolute_is_monotonic = false, + .has_samples = false, + }; + + const auto to_ns = [ts]() { return ts; }; + + m_processing.on_bytes_multi( + bytes, m_processing.timestamp(to_ns, 0)); + } + + midi1::input_state_machine m_processing{this->configuration}; +}; +} diff --git a/include/libremidi/backends/rawio/midi_in_ump.hpp b/include/libremidi/backends/rawio/midi_in_ump.hpp new file mode 100644 index 00000000..ddb709b6 --- /dev/null +++ b/include/libremidi/backends/rawio/midi_in_ump.hpp @@ -0,0 +1,82 @@ +#pragma once +#include +#include +#include + +#include + +NAMESPACE_LIBREMIDI::rawio_ump +{ +class midi_in final + : public midi2::in_api + , public error_handler +{ +public: + using midi_api::client_open_; + struct + : ump_input_configuration + , rawio_ump_input_configuration + { + } configuration; + + explicit midi_in(ump_input_configuration&& conf, rawio_ump_input_configuration&& apiconf) + : configuration{std::move(conf), std::move(apiconf)} + { + client_open_ = stdx::error{}; + } + + ~midi_in() override { close_port(); } + + libremidi::API get_current_api() const noexcept override { return libremidi::API::RAW_IO_UMP; } + + stdx::error open_port(const input_port&, std::string_view) override + { + return open_virtual_port({}); + } + + stdx::error open_virtual_port(std::string_view) override + { + if (!configuration.set_receive_callback) + return std::errc::function_not_supported; + + configuration.set_receive_callback([this](std::span words, int64_t timestamp) { + on_bytes(words, timestamp); + }); + + return stdx::error{}; + } + + stdx::error close_port() override + { + if (configuration.stop_receive) + configuration.stop_receive(); + configuration.set_receive_callback = nullptr; + configuration.stop_receive = nullptr; + return stdx::error{}; + } + + stdx::error set_port_name(std::string_view) override { return stdx::error{}; } + + timestamp absolute_timestamp() const noexcept override + { + return std::chrono::steady_clock::now().time_since_epoch().count(); + } + +private: + void on_bytes(std::span words, int64_t ts) + { + static constexpr timestamp_backend_info timestamp_info{ + .has_absolute_timestamps = true, + .absolute_is_monotonic = false, + .has_samples = false, + }; + + const auto to_ns = [ts]() { return ts; }; + + m_processing.on_bytes_multi( + words, m_processing.timestamp(to_ns, 0)); + } + + midi2::input_state_machine m_processing{this->configuration}; +}; +} diff --git a/include/libremidi/backends/rawio/midi_out.hpp b/include/libremidi/backends/rawio/midi_out.hpp new file mode 100644 index 00000000..2bc89ea0 --- /dev/null +++ b/include/libremidi/backends/rawio/midi_out.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +NAMESPACE_LIBREMIDI::rawio +{ +class midi_out final + : public midi1::out_api + , public error_handler +{ +public: + struct + : output_configuration + , rawio_output_configuration + { + } configuration; + + explicit midi_out(output_configuration&& conf, rawio_output_configuration&& apiconf) + : configuration{std::move(conf), std::move(apiconf)} + { + client_open_ = stdx::error{}; + } + + ~midi_out() override { close_port(); } + + libremidi::API get_current_api() const noexcept override { return libremidi::API::RAW_IO; } + + stdx::error open_port(const output_port&, std::string_view) override { return stdx::error{}; } + + stdx::error open_virtual_port(std::string_view) override { return stdx::error{}; } + + stdx::error close_port() override + { + configuration.write_bytes = nullptr; + return stdx::error{}; + } + + stdx::error set_port_name(std::string_view) override { return stdx::error{}; } + + stdx::error send_message(const unsigned char* message, size_t size) override + { + return configuration.write_bytes({message, size}); + } +}; +} diff --git a/include/libremidi/backends/rawio/midi_out_ump.hpp b/include/libremidi/backends/rawio/midi_out_ump.hpp new file mode 100644 index 00000000..da47e002 --- /dev/null +++ b/include/libremidi/backends/rawio/midi_out_ump.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +NAMESPACE_LIBREMIDI::rawio_ump +{ +class midi_out final + : public midi2::out_api + , public error_handler +{ +public: + struct + : output_configuration + , rawio_ump_output_configuration + { + } configuration; + + explicit midi_out(output_configuration&& conf, rawio_ump_output_configuration&& apiconf) + : configuration{std::move(conf), std::move(apiconf)} + { + client_open_ = stdx::error{}; + } + + ~midi_out() override { close_port(); } + + libremidi::API get_current_api() const noexcept override { return libremidi::API::RAW_IO_UMP; } + + stdx::error open_port(const output_port&, std::string_view) override { return stdx::error{}; } + + stdx::error open_virtual_port(std::string_view) override { return stdx::error{}; } + + stdx::error close_port() override + { + configuration.write_ump = nullptr; + return stdx::error{}; + } + + stdx::error set_port_name(std::string_view) override { return stdx::error{}; } + + stdx::error send_ump(const uint32_t* message, size_t size) override + { + return configuration.write_ump({message, size}); + } +}; +} diff --git a/include/libremidi/backends/rawio/observer.hpp b/include/libremidi/backends/rawio/observer.hpp new file mode 100644 index 00000000..1c128c25 --- /dev/null +++ b/include/libremidi/backends/rawio/observer.hpp @@ -0,0 +1,12 @@ +#pragma once +#include + +NAMESPACE_LIBREMIDI::rawio +{ +using observer = libremidi::observer_dummy; +} + +NAMESPACE_LIBREMIDI::rawio_ump +{ +using observer = libremidi::observer_dummy; +} diff --git a/include/libremidi/backends/rawio_ump.hpp b/include/libremidi/backends/rawio_ump.hpp new file mode 100644 index 00000000..4db83d74 --- /dev/null +++ b/include/libremidi/backends/rawio_ump.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +#include + +#include + +NAMESPACE_LIBREMIDI::rawio_ump +{ +struct backend +{ + using midi_in = rawio_ump::midi_in; + using midi_out = rawio_ump::midi_out; + using midi_observer = rawio_ump::observer; + using midi_in_configuration = rawio_ump_input_configuration; + using midi_out_configuration = rawio_ump_output_configuration; + using midi_observer_configuration = rawio_ump_observer_configuration; + static const constexpr auto API = libremidi::API::RAW_IO_UMP; + static const constexpr std::string_view name = "raw_io_ump"; + static const constexpr std::string_view display_name = "Raw I/O (UMP)"; + + static inline bool available() noexcept { return true; } +}; +} diff --git a/include/libremidi/configurations.hpp b/include/libremidi/configurations.hpp index 9af9fba7..ec1f1be4 100644 --- a/include/libremidi/configurations.hpp +++ b/include/libremidi/configurations.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -38,7 +39,8 @@ using input_api_configuration = libremidi_variant_alias::variant< coremidi_ump::input_configuration, emscripten_input_configuration, jack_input_configuration, kbd_input_configuration, kdmapi::input_configuration, libremidi::net::dgram_input_configuration, libremidi::net_ump::dgram_input_configuration, - pipewire_input_configuration, winmidi::input_configuration, winmm_input_configuration, + pipewire_input_configuration, rawio_input_configuration, rawio_ump_input_configuration, + winmidi::input_configuration, winmm_input_configuration, winuwp_input_configuration, jack_ump::input_configuration, pipewire_ump::input_configuration, android::input_configuration, libremidi::API>; @@ -49,6 +51,7 @@ using output_api_configuration = libremidi_variant_alias::variant< coremidi_ump::output_configuration, emscripten_output_configuration, jack_output_configuration, kdmapi::output_configuration, libremidi::net::dgram_output_configuration, libremidi::net_ump::dgram_output_configuration, pipewire_output_configuration, + rawio_output_configuration, rawio_ump_output_configuration, winmidi::output_configuration, winmm_output_configuration, winuwp_output_configuration, jack_ump::output_configuration, pipewire_ump::output_configuration, android::output_configuration, libremidi::API>; @@ -60,7 +63,9 @@ using observer_api_configuration = libremidi_variant_alias::variant< coremidi_ump::observer_configuration, emscripten_observer_configuration, jack_observer_configuration, kdmapi::observer_configuration, libremidi::net::dgram_observer_configuration, libremidi::net_ump::dgram_observer_configuration, - pipewire_observer_configuration, winmidi::observer_configuration, winmm_observer_configuration, + pipewire_observer_configuration, rawio_observer_configuration, + rawio_ump_observer_configuration, winmidi::observer_configuration, + winmm_observer_configuration, winuwp_observer_configuration, jack_ump::observer_configuration, pipewire_ump::observer_configuration, android::observer_configuration, libremidi::API>; diff --git a/tests/python/test_rawio.py b/tests/python/test_rawio.py new file mode 100644 index 00000000..a4e7bb60 --- /dev/null +++ b/tests/python/test_rawio.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python +""" +Tests for the raw I/O backend and all send_message / send_ump overloads. +Run with: python test_rawio.py +""" +import pylibremidi as lm +import gc +import sys + +failures = [] + +def check(name, condition): + if not condition: + failures.append(name) + print(f" FAIL: {name}") + else: + print(f" ok: {name}") + +# ============================================================ +# Helper: MIDI 1 rawio loopback with automatic cleanup +# ============================================================ +class Midi1Loopback: + def __init__(self): + self._stored_cb = [None] + self.received = [] + + in_config = lm.InputConfiguration() + received = self.received # avoid capturing self in lambda + in_config.on_message = lambda msg: received.append(msg) + in_config.direct = True + + rawio_in = lm.RawioInputConfiguration() + rawio_in.set_receive_callback = lambda cb: self._stored_cb.__setitem__(0, cb) + rawio_in.stop_receive = lambda: self._stored_cb.__setitem__(0, None) + + self.midi_in = lm.MidiIn(in_config, rawio_in) + self.midi_in.open_virtual_port("test_in") + + out_config = lm.OutputConfiguration() + rawio_out = lm.RawioOutputConfiguration() + stored = self._stored_cb + rawio_out.write_bytes = lambda data: (stored[0](data, 0) if stored[0] else None, lm.Error())[-1] + + self.midi_out = lm.MidiOut(out_config, rawio_out) + self.midi_out.open_virtual_port("test_out") + + def close(self): + self.midi_in.close_port() + self.midi_out.close_port() + self.received.clear() + del self.midi_in, self.midi_out + +# ============================================================ +# Helper: MIDI 2 (UMP) rawio loopback with automatic cleanup +# ============================================================ +class UmpLoopback: + def __init__(self, ignore_sysex=True): + self._stored_cb = [None] + self.received = [] + + in_config = lm.UmpInputConfiguration() + received = self.received # avoid capturing self in lambda + in_config.on_message = lambda msg: received.append(msg) + in_config.ignore_sysex = ignore_sysex + in_config.direct = True + + rawio_in = lm.RawioUmpInputConfiguration() + rawio_in.set_receive_callback = lambda cb: self._stored_cb.__setitem__(0, cb) + rawio_in.stop_receive = lambda: self._stored_cb.__setitem__(0, None) + + self.midi_in = lm.MidiIn(in_config, rawio_in) + self.midi_in.open_virtual_port("test_ump_in") + + out_config = lm.OutputConfiguration() + rawio_out = lm.RawioUmpOutputConfiguration() + stored = self._stored_cb + rawio_out.write_ump = lambda data: (stored[0](data, 0) if stored[0] else None, lm.Error())[-1] + + self.midi_out = lm.MidiOut(out_config, rawio_out) + self.midi_out.open_virtual_port("test_ump_out") + + def close(self): + self.midi_in.close_port() + self.midi_out.close_port() + self.received.clear() + del self.midi_in, self.midi_out + + +# ============================================================ +# Test: API detection +# ============================================================ +print("--- API detection ---") +t = Midi1Loopback() +check("midi1 input api is RAW_IO", t.midi_in.get_current_api() == lm.API.RAW_IO) +check("midi1 output api is RAW_IO", t.midi_out.get_current_api() == lm.API.RAW_IO) +t.close() + +t = UmpLoopback() +check("ump input api is RAW_IO_UMP", t.midi_in.get_current_api() == lm.API.RAW_IO_UMP) +check("ump output api is RAW_IO_UMP", t.midi_out.get_current_api() == lm.API.RAW_IO_UMP) +t.close() + + +# ============================================================ +# Test: send_message overloads (MIDI 1) +# ============================================================ +print("\n--- send_message overloads ---") + +# send_message(b0) - 1 byte +t = Midi1Loopback() +t.midi_out.send_message(0xFA) # Start (not filtered by default, unlike Active Sensing) +check("send_message(b0): 1 message received", len(t.received) == 1) +check("send_message(b0): correct byte", t.received[0].bytes[0] == 0xFA) +t.close() + +# send_message(b0, b1) - 2 bytes +t = Midi1Loopback() +t.midi_out.send_message(0xC0, 42) # Program Change +check("send_message(b0,b1): 1 message received", len(t.received) == 1) +check("send_message(b0,b1): correct status", t.received[0].bytes[0] == 0xC0) +check("send_message(b0,b1): correct data", t.received[0].bytes[1] == 42) +t.close() + +# send_message(b0, b1, b2) - 3 bytes +t = Midi1Loopback() +t.midi_out.send_message(0x90, 60, 100) # Note On +check("send_message(b0,b1,b2): 1 message received", len(t.received) == 1) +check("send_message(b0,b1,b2): correct status", t.received[0].bytes[0] == 0x90) +check("send_message(b0,b1,b2): correct note", t.received[0].bytes[1] == 60) +check("send_message(b0,b1,b2): correct velocity", t.received[0].bytes[2] == 100) +t.close() + +# send_message(list) - vector overload +t = Midi1Loopback() +t.midi_out.send_message([0xB0, 7, 80]) # CC +check("send_message(list): 1 message received", len(t.received) == 1) +check("send_message(list): correct status", t.received[0].bytes[0] == 0xB0) +check("send_message(list): correct cc", t.received[0].bytes[1] == 7) +check("send_message(list): correct value", t.received[0].bytes[2] == 80) +t.close() + +# send_message(Message) - message object overload +t = Midi1Loopback() +msg = lm.Message() +msg.bytes = [0xE0, 0x00, 0x40] # Pitch Bend center +t.midi_out.send_message(msg) +check("send_message(Message): 1 message received", len(t.received) == 1) +check("send_message(Message): correct status", t.received[0].bytes[0] == 0xE0) +check("send_message(Message): correct lsb", t.received[0].bytes[1] == 0x00) +check("send_message(Message): correct msb", t.received[0].bytes[2] == 0x40) +t.close() +del msg + +# schedule_message(timestamp, list) +t = Midi1Loopback() +t.midi_out.schedule_message(12345, [0x90, 48, 127]) +check("schedule_message(ts,list): 1 message received", len(t.received) == 1) +check("schedule_message(ts,list): correct note", t.received[0].bytes[1] == 48) +t.close() + + +# ============================================================ +# Test: send_ump overloads (MIDI 2 / UMP) +# ============================================================ +print("\n--- send_ump overloads ---") + +# send_ump(u0) - 1 word (System Real-Time Start, type 1 - not subject to MIDI1->2 upconversion) +t = UmpLoopback() +t.midi_out.send_ump(0x10FA0000) # System RT Start, group 0 +check("send_ump(u0): 1 message received", len(t.received) == 1) +check("send_ump(u0): correct word0", t.received[0].data[0] == 0x10FA0000) +t.close() + +# send_ump(u0, u1) - 2 words (MIDI 2 channel voice) +t = UmpLoopback() +t.midi_out.send_ump(0x4090003C, 0xC0000000) # Note On +check("send_ump(u0,u1): 1 message received", len(t.received) == 1) +check("send_ump(u0,u1): correct word0", t.received[0].data[0] == 0x4090003C) +check("send_ump(u0,u1): correct word1", t.received[0].data[1] == 0xC0000000) +t.close() + +# send_ump(u0, u1, u2, u3) - 4 words (SysEx8, type 5 - need ignore_sysex=False) +t = UmpLoopback(ignore_sysex=False) +t.midi_out.send_ump(0x50010000, 0x01020304, 0x05060708, 0x090A0B0C) +check("send_ump(u0,u1,u2,u3): 1 message received", len(t.received) == 1) +if t.received: + check("send_ump(u0,u1,u2,u3): correct word0", t.received[0].data[0] == 0x50010000) + check("send_ump(u0,u1,u2,u3): correct word1", t.received[0].data[1] == 0x01020304) + check("send_ump(u0,u1,u2,u3): correct word2", t.received[0].data[2] == 0x05060708) + check("send_ump(u0,u1,u2,u3): correct word3", t.received[0].data[3] == 0x090A0B0C) +t.close() + +# send_ump(list) - vector overload +t = UmpLoopback() +t.midi_out.send_ump([0x4090003C, 0xC0000000]) +check("send_ump(list): 1 message received", len(t.received) == 1) +check("send_ump(list): correct word0", t.received[0].data[0] == 0x4090003C) +check("send_ump(list): correct word1", t.received[0].data[1] == 0xC0000000) +t.close() + +# send_ump(Ump) - ump object overload +t = UmpLoopback() +ump = lm.Ump() +ump.data = (0x4090003C, 0xC0000000, 0, 0) +t.midi_out.send_ump(ump) +check("send_ump(Ump): 1 message received", len(t.received) == 1) +check("send_ump(Ump): correct word0", t.received[0].data[0] == 0x4090003C) +check("send_ump(Ump): correct word1", t.received[0].data[1] == 0xC0000000) +t.close() +del ump + +# schedule_ump(timestamp, list) +t = UmpLoopback() +t.midi_out.schedule_ump(99999, [0x4090003C, 0xC0000000]) +check("schedule_ump(ts,list): 1 message received", len(t.received) == 1) +check("schedule_ump(ts,list): correct word0", t.received[0].data[0] == 0x4090003C) +t.close() + + +# ============================================================ +# Test: multiple messages in sequence +# ============================================================ +print("\n--- multiple messages ---") + +t = Midi1Loopback() +t.midi_out.send_message(0x90, 60, 100) +t.midi_out.send_message(0x80, 60, 0) +t.midi_out.send_message(0xB0, 7, 80) +t.midi_out.send_message([0xB0, 10, 64]) +check("multiple midi1: 4 messages", len(t.received) == 4) +check("multiple midi1: msg0 note on", t.received[0].bytes[0] == 0x90) +check("multiple midi1: msg1 note off", t.received[1].bytes[0] == 0x80) +check("multiple midi1: msg2 cc7", t.received[2].bytes[1] == 7) +check("multiple midi1: msg3 cc10", t.received[3].bytes[1] == 10) +t.close() + +t = UmpLoopback() +t.midi_out.send_ump(0x4090003C, 0xC0000000) +t.midi_out.send_ump(0x4080003C, 0x00000000) +t.midi_out.send_ump([0x40B00007, 0x80000000]) +check("multiple ump: 3 messages", len(t.received) == 3) +t.close() + + +# ============================================================ +# Test: close_port calls stop_receive +# ============================================================ +print("\n--- close_port ---") + +stopped = [False] +_in_config = lm.InputConfiguration() +_in_config.on_message = lambda msg: None +_in_config.direct = True + +_rawio_in = lm.RawioInputConfiguration() +_rawio_in.set_receive_callback = lambda cb: None +_rawio_in.stop_receive = lambda: stopped.__setitem__(0, True) + +_midi_in = lm.MidiIn(_in_config, _rawio_in) +_midi_in.open_virtual_port("test") +check("stop_receive not called yet", not stopped[0]) +_midi_in.close_port() +check("stop_receive called on close", stopped[0]) +del _midi_in, _in_config, _rawio_in + + +# ============================================================ +# Test: Error default constructor +# ============================================================ +print("\n--- Error ---") +err = lm.Error() +check("default Error is falsy (no error)", not err) +del err + + +# ============================================================ +# Test: edge cases - large values, boundary values +# ============================================================ +print("\n--- edge cases ---") + +# Maximum MIDI 1 byte values +t = Midi1Loopback() +t.midi_out.send_message(0xFF) # System Reset +check("send_message(0xFF): received", len(t.received) == 1) +check("send_message(0xFF): correct", t.received[0].bytes[0] == 0xFF) +t.close() + +# UMP with max values in data portion (type 4, 2 words) +t = UmpLoopback() +t.midi_out.send_ump(0x409F7F7F, 0xFFFFFFFF) +check("send_ump(large values): received", len(t.received) == 1) +check("send_ump(large values): word0", t.received[0].data[0] == 0x409F7F7F) +check("send_ump(large values): word1", t.received[0].data[1] == 0xFFFFFFFF) +t.close() + + +# ============================================================ +# Ensure cleanup before exit +# ============================================================ +del t +gc.collect() + +# ============================================================ +# Summary +# ============================================================ +print(f"\n{'='*40}") +if failures: + print(f"FAILED: {len(failures)} test(s)") + for f in failures: + print(f" - {f}") + sys.exit(1) +else: + print("All tests passed.") + sys.exit(0) diff --git a/tests/unit/rawio.cpp b/tests/unit/rawio.cpp new file mode 100644 index 00000000..0817bca1 --- /dev/null +++ b/tests/unit/rawio.cpp @@ -0,0 +1,144 @@ +#include "../include_catch.hpp" + +#include +#include + +TEST_CASE("rawio midi1 roundtrip", "[rawio]") +{ + // The callback that the library will give us to feed bytes into + libremidi::rawio_input_configuration::receive_callback on_receive; + + // Received messages + std::vector received; + + libremidi::midi_in midiin{ + libremidi::input_configuration{ + .on_message = [&](const libremidi::message& m) { received.push_back(m); }}, + libremidi::rawio_input_configuration{ + .set_receive_callback = [&](auto cb) { on_receive = std::move(cb); }, + .stop_receive = [&] { on_receive = nullptr; }}}; + + REQUIRE(midiin.get_current_api() == libremidi::API::RAW_IO); + + // Bytes written by midi_out + std::vector written; + + libremidi::midi_out midiout{ + libremidi::output_configuration{}, + libremidi::rawio_output_configuration{ + .write_bytes = [&](std::span bytes) -> stdx::error { + written.assign(bytes.begin(), bytes.end()); + return {}; + }}}; + + REQUIRE(midiout.get_current_api() == libremidi::API::RAW_IO); + + midiin.open_virtual_port("test"); + midiout.open_virtual_port("test"); + + SECTION("note on roundtrip") + { + // Send a note-on through midi_out + midiout.send_message(0x90, 60, 100); + + // Verify the output wrote correct bytes + REQUIRE(written.size() == 3); + REQUIRE(written[0] == 0x90); + REQUIRE(written[1] == 60); + REQUIRE(written[2] == 100); + + // Feed those bytes back into midi_in (simulating a loopback transport) + REQUIRE(on_receive); + on_receive(written, 0); + + // Verify the message was received and parsed + REQUIRE(received.size() == 1); + REQUIRE(received[0].bytes.size() == 3); + REQUIRE(received[0].bytes[0] == 0x90); + REQUIRE(received[0].bytes[1] == 60); + REQUIRE(received[0].bytes[2] == 100); + } + + SECTION("multiple messages") + { + // Note on + midiout.send_message(0x90, 60, 100); + REQUIRE(on_receive); + on_receive(written, 0); + + // Note off + midiout.send_message(0x80, 60, 0); + on_receive(written, 0); + + // CC + midiout.send_message(0xB0, 7, 100); + on_receive(written, 0); + + REQUIRE(received.size() == 3); + REQUIRE(received[0].bytes[0] == 0x90); + REQUIRE(received[1].bytes[0] == 0x80); + REQUIRE(received[2].bytes[0] == 0xB0); + REQUIRE(received[2].bytes[1] == 7); + REQUIRE(received[2].bytes[2] == 100); + } + + SECTION("close port calls stop_receive") + { + REQUIRE(on_receive); + midiin.close_port(); + REQUIRE_FALSE(on_receive); + } +} + +TEST_CASE("rawio midi2 ump roundtrip", "[rawio]") +{ + // The callback that the library will give us to feed UMP words into + libremidi::rawio_ump_input_configuration::receive_callback on_receive; + + // Received UMP messages + std::vector received; + + libremidi::midi_in midiin{ + libremidi::ump_input_configuration{ + .on_message = [&](const libremidi::ump& m) { received.push_back(m); }}, + libremidi::rawio_ump_input_configuration{ + .set_receive_callback = [&](auto cb) { on_receive = std::move(cb); }, + .stop_receive = [&] { on_receive = nullptr; }}}; + + REQUIRE(midiin.get_current_api() == libremidi::API::RAW_IO_UMP); + + // Words written by midi_out + std::vector written; + + libremidi::midi_out midiout{ + libremidi::output_configuration{}, + libremidi::rawio_ump_output_configuration{ + .write_ump = [&](std::span words) -> stdx::error { + written.assign(words.begin(), words.end()); + return {}; + }}}; + + REQUIRE(midiout.get_current_api() == libremidi::API::RAW_IO_UMP); + + midiin.open_virtual_port("test"); + midiout.open_virtual_port("test"); + + SECTION("ump roundtrip") + { + // Send a MIDI 2.0 note-on UMP (type 4, group 0, channel 0, note 60, velocity 0xC000) + uint32_t ump[2] = {0x40900000 | 60, 0xC0000000}; + midiout.send_ump(ump, 2); + + // Verify output + REQUIRE(written.size() == 2); + + // Feed back into input + REQUIRE(on_receive); + on_receive(written, 0); + + // Verify reception + REQUIRE(received.size() == 1); + REQUIRE(received[0].data[0] == ump[0]); + REQUIRE(received[0].data[1] == ump[1]); + } +}