diff --git a/include/mostly_harmless/CMakeLists.txt b/include/mostly_harmless/CMakeLists.txt index 1ec8912..1efffb8 100644 --- a/include/mostly_harmless/CMakeLists.txt +++ b/include/mostly_harmless/CMakeLists.txt @@ -15,6 +15,7 @@ set(MOSTLYHARMLESS_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_InputEventContext.h ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_WebEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_ParamEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_MidiEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/mostlyharmless_Parameters.h ${CMAKE_CURRENT_SOURCE_DIR}/gui/mostlyharmless_WebviewBase.h ${CMAKE_CURRENT_SOURCE_DIR}/gui/mostlyharmless_WebviewEditor.h @@ -27,6 +28,7 @@ set(MOSTLYHARMLESS_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/utils/mostlyharmless_Proxy.h ${CMAKE_CURRENT_SOURCE_DIR}/utils/mostlyharmless_NoDenormals.h ${CMAKE_CURRENT_SOURCE_DIR}/utils/mostlyharmless_Logging.h + ${CMAKE_CURRENT_SOURCE_DIR}/utils/mostlyharmless_Visitor.h ${CMAKE_CURRENT_SOURCE_DIR}/data/mostlyharmless_DatabaseState.h ${CMAKE_CURRENT_SOURCE_DIR}/data/mostlyharmless_DatabasePropertyWatcher.h ${PLATFORM_HEADERS} diff --git a/include/mostly_harmless/core/mostlyharmless_IEngine.h b/include/mostly_harmless/core/mostlyharmless_IEngine.h index 54fc6b6..3fd9d81 100644 --- a/include/mostly_harmless/core/mostlyharmless_IEngine.h +++ b/include/mostly_harmless/core/mostlyharmless_IEngine.h @@ -98,6 +98,56 @@ namespace mostly_harmless::core { * \param velocity The 0-1 velocity of the note event */ virtual void handleNoteOff([[maybe_unused]] std::uint16_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] std::uint8_t note, [[maybe_unused]] double velocity) {} + + /** + * Called if the plugin receives a midi control change event - not pure virtual, as this function isn't relevant if you haven't requested midi functionality. + * Called on the audio thread, in response to a control change event. + * Some of the identifiers here are reserved for special controls (mod wheel etc) - we don't do any handling of this framework side, so have a google if you need this info! + * @param portIndex The clap port index the event originated from. + * @param channel The midi channel the event was passed to + * @param controlNumber The midi control that was changed + * @param data The data in the midi message - see the midi spec for interpreting this. + */ + virtual void handleControlChange([[maybe_unused]] std::uint16_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] std::uint8_t controlNumber, [[maybe_unused]] std::uint8_t data) {} + + /** + * Called if the plugin receives a program change event - not pure virtual, as this function isn't relevant if you haven't requested midi functionality. + * Called on the audio thread, in response to a program change event. + * @param portIndex The clap port index the event originated from. + * @param channel The midi channel the event was passed to + * @param programNumber The program number that was set + */ + virtual void handleProgramChange([[maybe_unused]] std::uint16_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] std::uint8_t programNumber) {}; + + /** + * Called if the plugin receives a (polyphonic) aftertouch event (polyphonic in the sense that each note can have its own pressure data). + * Not pure virtual, as this function isn't relevant if you haven't requested midi functionality. + * Called on the audio thread, in response to a poly aftertouch event. + * @param portIndex The clap port index the event originated from. + * @param channel The midi channel the event was passed to + * @param note The midi note this aftertouch event applies to + * @param pressure The pressure applied to this midi note + */ + virtual void handlePolyAftertouch([[maybe_unused]] std::uint16_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] std::uint8_t note, [[maybe_unused]] std::uint8_t pressure) {} + + /** + * Called if the plugin receives a (channel-wide) aftertouch event. In this case, all notes within a channel get the same pressure value. + * Not pure virtual, as this function isn't relevant if you haven't requested midi functionality. + * Called on the audio thread, in response to a channel aftertouch event. + * @param portIndex The clap port index the event originated from. + * @param channel The midi channel the event was passed to + * @param pressure The pressure applied to this midi note + */ + virtual void handleChannelAftertouch([[maybe_unused]] std::uint8_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] std::uint8_t pressure) {} + + /** + * Called if the plugin receives a pitch wheel event - not pure virtual, as this function isn't relevant if you haven't requested midi functionality. + * Called on the audio thread, in response to a pitch wheel event. + * @param portIndex The clap port index the event originated from. + * @param channel The midi channel the event was passed to + * @param value The value, between -1.0 and 1.0 + */ + virtual void handlePitchWheel([[maybe_unused]] std::uint8_t portIndex, [[maybe_unused]] std::uint8_t channel, [[maybe_unused]] double value) {} }; } // namespace mostly_harmless::core #endif // MOSTLYHARMLESS_MOSTLYHARMLESS_IENGINE_H diff --git a/include/mostly_harmless/events/mostlyharmless_MidiEvent.h b/include/mostly_harmless/events/mostlyharmless_MidiEvent.h new file mode 100644 index 0000000..78bdd72 --- /dev/null +++ b/include/mostly_harmless/events/mostlyharmless_MidiEvent.h @@ -0,0 +1,60 @@ +#ifndef MOSTLY_HARMLESS_MIDI_EVENT +#define MOSTLY_HARMLESS_MIDI_EVENT +#include +#include +/// @internal +namespace mostly_harmless::events::midi { + /// @internal + struct NoteOn final { + std::uint8_t channel; + std::uint8_t note; + double velocity; + }; + + /// @internal + struct NoteOff final { + std::uint8_t channel; + std::uint8_t note; + double velocity; + }; + + /// @internal + struct PolyAftertouch final { + std::uint8_t channel; + std::uint8_t note; + std::uint8_t pressure; + }; + + /// @internal + struct ControlChange final { + std::uint8_t channel; + std::uint8_t controllerNumber; + std::uint8_t data; + }; + + /// @internal + struct ProgramChange final { + std::uint8_t channel; + std::uint8_t programNumber; + }; + + /// @internal + struct ChannelAftertouch final { + std::uint8_t channel; + std::uint8_t pressure; + }; + + /// @internal + struct PitchWheel final { + std::uint8_t channel; + double value; + }; + + /// @internal + using MidiEvent = std::variant; + + /// @internal + auto parse(std::uint8_t b0, std::uint8_t b1, std::uint8_t b2) -> std::optional; +} // namespace mostly_harmless::events::midi + +#endif \ No newline at end of file diff --git a/include/mostly_harmless/utils/mostlyharmless_Visitor.h b/include/mostly_harmless/utils/mostlyharmless_Visitor.h new file mode 100644 index 0000000..3e4cb41 --- /dev/null +++ b/include/mostly_harmless/utils/mostlyharmless_Visitor.h @@ -0,0 +1,26 @@ +// +// Created by Syl Morrison on 29/11/2025. +// + +#ifndef MOSTLYHARMLESS_VISITOR_H +#define MOSTLYHARMLESS_VISITOR_H +namespace mostly_harmless::utils { + /** + * \brief Util for use with `std::visit` to avoid a bunch of `if constexpr(...)`s. + * + * Usage:: + * ``` + * std::variant data; + * std::visit(Visitor{ + * [](int x) { std::cout << "Was int!\n"; }, + * [](double x) { std::cout << "Was double!\n"; } + * }, data); + * ``` + * @tparam Callable + */ + template + struct Visitor : Callable... { + using Callable::operator()...; + }; +} // namespace mostly_harmless::utils +#endif // GLEO_MOSTLYHARMLESS_VISITOR_H diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 7b835c1..d889b6c 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -23,6 +23,7 @@ set(MOSTLYHARMLESS_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_InputEventContext.cpp ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_ParamEvent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_WebEvent.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_MidiEvent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/gui/mostlyharmless_WebviewBase.cpp ${CMAKE_CURRENT_SOURCE_DIR}/gui/mostlyharmless_WebviewEditor.cpp ${CMAKE_CURRENT_SOURCE_DIR}/gui/mostlyharmless_Colour.cpp diff --git a/source/events/mostlyharmless_MidiEvent.cpp b/source/events/mostlyharmless_MidiEvent.cpp new file mode 100644 index 0000000..cd9fd03 --- /dev/null +++ b/source/events/mostlyharmless_MidiEvent.cpp @@ -0,0 +1,38 @@ +#include +namespace mostly_harmless::events::midi { + constexpr static auto s_note_off{ 0x80 }; + constexpr static auto s_note_on{ 0x90 }; + constexpr static auto s_poly_aftertouch{ 0xA0 }; + constexpr static auto s_control_change{ 0xB0 }; + constexpr static auto s_program_change{ 0xC0 }; + constexpr static auto s_channel_aftertouch{ 0xD0 }; + constexpr static auto s_pitch_wheel{ 0xE0 }; + + auto parse(std::uint8_t b0, std::uint8_t b1, std::uint8_t b2) -> std::optional { + const std::uint8_t message = b0 & 0xF0; + const std::uint8_t channel = b0 & 0x0F; + switch (message) { + case s_note_on: [[fallthrough]]; + case s_note_off: { + const std::uint8_t note = b1; + const std::uint8_t velocity = b2; + const auto floatVel = static_cast(velocity) / 127.0; + if (message == s_note_on && velocity != 0) { + return NoteOn{ .channel = channel, .note = note, .velocity = floatVel }; + } + return NoteOff{ .channel = channel, .note = note, .velocity = floatVel }; + } + case s_poly_aftertouch: return PolyAftertouch{ .channel = channel, .note = b1, .pressure = b2 }; + case s_control_change: return ControlChange{ .channel = channel, .controllerNumber = b1, .data = b2 }; + case s_program_change: return ProgramChange{ .channel = channel, .programNumber = b1 }; + case s_channel_aftertouch: return ChannelAftertouch{ .channel = channel, .pressure = b1 }; + case s_pitch_wheel: { + const std::int16_t combined = b1 | (b2 << 7); + double res = static_cast(combined - 8192) / 8192.0; + return PitchWheel{ .channel = channel, .value = res }; + } + default: return {}; + } + } + +} // namespace mostly_harmless::events::midi \ No newline at end of file diff --git a/source/mostlyharmless_PluginBase.cpp b/source/mostlyharmless_PluginBase.cpp index 12d900f..e21a1c9 100644 --- a/source/mostlyharmless_PluginBase.cpp +++ b/source/mostlyharmless_PluginBase.cpp @@ -1,10 +1,14 @@ // // Created by Syl Morrison on 20/10/2024. // +#include "mostly_harmless/utils/mostlyharmless_Visitor.h" + + #include #include #include #include +#include #include namespace mostly_harmless::internal { @@ -168,23 +172,33 @@ namespace mostly_harmless::internal { // 0PPP PPPP // 0VVV VVVV const auto* midiEvent = reinterpret_cast(event); - std::uint8_t message = midiEvent->data[0] & 0xF0; // SSSS 0000 - Message will be << 4 - std::uint8_t channel = midiEvent->data[0] & 0x0F; // 0000 CCCC - std::uint8_t note = midiEvent->data[1]; // 0PPP PPPP - std::uint8_t velocity = midiEvent->data[2]; // 0VVV VVVV - const auto fpVelocity = static_cast(velocity) / 127.0; - switch (message) { - case 0x90: { - m_engine->handleNoteOn(midiEvent->port_index, channel, note, fpVelocity); - break; - } - case 0x80: { - m_engine->handleNoteOff(midiEvent->port_index, channel, note, fpVelocity); - break; - } - default: break; // TODO + auto res = mostly_harmless::events::midi::parse(midiEvent->data[0], midiEvent->data[1], midiEvent->data[2]); + if (!res) { + return; } - break; + std::visit(utils::Visitor{ + [this, &midiEvent](events::midi::NoteOff x) { + m_engine->handleNoteOff(midiEvent->port_index, x.channel, x.note, x.velocity); + }, + [this, &midiEvent](events::midi::NoteOn x) { + m_engine->handleNoteOn(midiEvent->port_index, x.channel, x.note, x.velocity); + }, + [this, &midiEvent](events::midi::PolyAftertouch x) { + m_engine->handlePolyAftertouch(midiEvent->port_index, x.channel, x.note, x.pressure); + }, + [this, &midiEvent](events::midi::ControlChange x) { + m_engine->handleControlChange(midiEvent->port_index, x.channel, x.controllerNumber, x.data); + }, + [this, &midiEvent](events::midi::ProgramChange x) { + m_engine->handleProgramChange(midiEvent->port_index, x.channel, x.programNumber); + }, + [this, &midiEvent](events::midi::ChannelAftertouch x) { + m_engine->handleChannelAftertouch(midiEvent->port_index, x.channel, x.pressure); + }, + [this, &midiEvent](events::midi::PitchWheel x) { + m_engine->handlePitchWheel(midiEvent->port_index, x.channel, x.value); + } }, + *res); } case CLAP_EVENT_TRANSPORT: { if (const auto* transportEvent = reinterpret_cast(event)) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4a52f07..ae4ceef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,4 +5,5 @@ set(MOSTLYHARMLESS_TEST_SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/utils/mostlyharmless_TimerTests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_InputEventContextTests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/data/mostlyharmless_DatabaseStateTests.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/events/mostlyharmless_MidiEventTests.cpp PARENT_SCOPE) \ No newline at end of file diff --git a/tests/data/mostlyharmless_DatabaseStateTests.cpp b/tests/data/mostlyharmless_DatabaseStateTests.cpp index 001744d..026f1a6 100644 --- a/tests/data/mostlyharmless_DatabaseStateTests.cpp +++ b/tests/data/mostlyharmless_DatabaseStateTests.cpp @@ -79,8 +79,7 @@ namespace mostly_harmless::testing { } std::filesystem::remove(dbFile); } - - SECTION("Test DatabasePropertyWatcher") { + SECTION("Test DatabasePropertyWatcher") { for (auto i = 0; i < 100; ++i) { { auto databaseOpt = tryCreateDatabase(dbFile, { { "test", 0 } }); diff --git a/tests/events/mostlyharmless_MidiEventTests.cpp b/tests/events/mostlyharmless_MidiEventTests.cpp new file mode 100644 index 0000000..03b7d81 --- /dev/null +++ b/tests/events/mostlyharmless_MidiEventTests.cpp @@ -0,0 +1,91 @@ +// +// Created by Syl Morrison on 29/11/2025. +// +#include +#include +#include +namespace mostly_harmless::testing { + struct MidiMessage { + std::uint8_t b0; + std::uint8_t b1; + std::uint8_t b2; + }; + template + static auto check_result(std::optional to_check) -> void { + REQUIRE(to_check); + REQUIRE(std::holds_alternative(*to_check)); + } + TEST_CASE("Test Midi Status Bytes") { + SECTION("Test NoteOff") { + const MidiMessage msg{ 0b10000000, 36, 0 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, note, velocity] = std::get(*res_opt); + REQUIRE(channel == 0); + REQUIRE(note == 36); + REQUIRE_THAT(velocity, Catch::Matchers::WithinRel(0.0)); + } + SECTION("Test NoteOn") { + const MidiMessage msg{ 0b10010000, 36, 127 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, note, velocity] = std::get(*res_opt); + REQUIRE(channel == 0); + REQUIRE(note == 36); + REQUIRE_THAT(velocity, Catch::Matchers::WithinRel(1.0f)); + } + SECTION("Test PolyAftertouch") { + const MidiMessage msg{ 0b10100001, 60, 127 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, note, pressure] = std::get(*res_opt); + REQUIRE(channel == 1); + REQUIRE(note == 60); + REQUIRE(pressure == 127); + } + SECTION("Test ControlChange") { + const MidiMessage msg{ 0b10110001, 0, 127 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, controllerNumber, value] = std::get(*res_opt); + REQUIRE(channel == 1); + REQUIRE(controllerNumber == 0); + REQUIRE(value == 127); + } + SECTION("Test ProgramChange") { + const MidiMessage msg{ 0b11000000, 2, 0 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, program] = std::get(*res_opt); + REQUIRE(channel == 0); + REQUIRE(program == 2); + } + SECTION("Test ChannelAftertouch") { + const MidiMessage msg{ 0b11010010, 8, 0 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, pressure] = std::get(*res_opt); + REQUIRE(channel == 2); + REQUIRE(pressure == 8); + } + SECTION("Test PitchWheel") { + const MidiMessage msg{ 0b11100000, 0x00, 0x40 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, value] = std::get(*res_opt); + REQUIRE(channel == 0); + REQUIRE_THAT(value, Catch::Matchers::WithinRel(0.0)); + } + + SECTION("Test 0 Velocity Note-On") { + const MidiMessage msg{ 0b10010000, 40, 0 }; + const auto res_opt = events::midi::parse(msg.b0, msg.b1, msg.b2); + check_result(res_opt); + const auto [channel, note, velocity] = std::get(*res_opt); + REQUIRE(channel == 0); + REQUIRE(note == 40); + REQUIRE_THAT(velocity, Catch::Matchers::WithinRel(0.0)); + } + } + +} // namespace mostly_harmless::testing