From ba1a7a07607c03f81e7f3b0b4a88f526b0f569e1 Mon Sep 17 00:00:00 2001 From: Barabas Date: Fri, 2 Feb 2024 22:11:50 +0000 Subject: [PATCH] Load a preset using a midi button (#104) * Update frontend to support midi button * Create new class for handling mapped MIDI messages * Implement notifications on MIDI preset change * Implement preset dropdown for midi mapping page * Allow switching between parameter and toggle presets using dropdown * Implement midi mapping edit dialog --- firmware/CMakeLists.txt | 1 + firmware/components/CMakeLists.txt | 1 + .../audio_param/test/test_audio_param.cpp | 25 +- .../cmd_handling/test/test_cmd_handling.cpp | 3 +- .../components/midi_handling/CMakeLists.txt | 25 + .../midi_handling/include/midi_handling.h | 108 ++++ .../midi_handling/test/test_midi_handling.cpp | 187 +++++++ .../components/midi_mapping/CMakeLists.txt | 1 + .../midi_mapping/include/midi_mapping.h | 58 +- .../midi_mapping/include/midi_mapping_api.h | 4 + .../midi_mapping/src/midi_mapping_api.cpp | 16 +- .../midi_mapping/test/test_midi_mapping.cpp | 120 +--- .../presets/include/presets_manager.h | 1 + firmware/main/CMakeLists.txt | 2 + firmware/main/include/main_thread.h | 107 +++- frontend/.run/main.dart (Debug).run.xml | 6 + .../lib/api/generated/audio_events.pb.dart | 2 + .../lib/api/generated/cmd_handling.pb.dart | 5 + .../lib/api/generated/midi_mapping.pb.dart | 29 +- .../api/generated/midi_mapping.pbenum.dart | 3 + .../api/generated/midi_mapping.pbjson.dart | 9 +- frontend/lib/api/proto_extension.dart | 51 +- frontend/lib/main.dart | 77 +-- .../lib/midi_mapping/model/midi_learn.dart | 19 +- frontend/lib/midi_mapping/model/models.dart | 22 +- .../lib/midi_mapping/view/midi_mapping.dart | 523 +++++++++++++++++- frontend/lib/presets/model/presets.dart | 6 +- .../test/midi_mapping/model/service_test.dart | 15 +- .../midi_mapping/view/midi_mapping_test.dart | 48 +- proto/midi_mapping.options | 2 +- proto/midi_mapping.proto | 4 +- test/.idea/misc.xml | 1 + .../etl/include/etl_utility.h | 17 + 33 files changed, 1156 insertions(+), 342 deletions(-) create mode 100644 firmware/components/midi_handling/CMakeLists.txt create mode 100644 firmware/components/midi_handling/include/midi_handling.h create mode 100644 firmware/components/midi_handling/test/test_midi_handling.cpp create mode 100644 frontend/.run/main.dart (Debug).run.xml diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 2b968379..d9dc2d3c 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -38,6 +38,7 @@ if (ESP_PLATFORM) add_subdirectory(components/i2c) add_subdirectory(components/i2s) add_subdirectory(components/messages) + add_subdirectory(components/midi_handling) add_subdirectory(components/midi_mapping) add_subdirectory(components/midi_protocol) add_subdirectory(components/os) diff --git a/firmware/components/CMakeLists.txt b/firmware/components/CMakeLists.txt index f4e54af4..a925f8a9 100644 --- a/firmware/components/CMakeLists.txt +++ b/firmware/components/CMakeLists.txt @@ -10,6 +10,7 @@ add_subdirectory(float_convert) add_subdirectory(hardware) add_subdirectory(heap_tracing) add_subdirectory(messages) +add_subdirectory(midi_handling) add_subdirectory(midi_mapping) add_subdirectory(midi_protocol) add_subdirectory(os) diff --git a/firmware/components/audio_param/test/test_audio_param.cpp b/firmware/components/audio_param/test/test_audio_param.cpp index e12254cf..18fa3b81 100644 --- a/firmware/components/audio_param/test/test_audio_param.cpp +++ b/firmware/components/audio_param/test/test_audio_param.cpp @@ -56,53 +56,48 @@ TEST_F(AudioParams, DefaultValueIsCorrect) TEST_F(AudioParams, UpdateToMinimum) { - uut.update("test", 0); - + ASSERT_EQ(0, uut.update("test", 0)); ASSERT_EQ(0, *uut.get_raw_parameter("test")); } TEST_F(AudioParams, UpdateToMaximum) { - uut.update("test", 1); - + ASSERT_EQ(0, uut.update("test", 1)); ASSERT_EQ(10, *uut.get_raw_parameter("test")); } TEST_F(AudioParams, UpdateToHalfWithNonTrivialRange) { - (void)uut.create_and_add_parameter("non-trivial", -1, 1, -1); - uut.update("non-trivial", 0.5); - + ASSERT_EQ(0, uut.create_and_add_parameter("non-trivial", -1, 1, -1)); + ASSERT_EQ(0, uut.update("non-trivial", 0.5)); ASSERT_EQ(0, *uut.get_raw_parameter("non-trivial")); } TEST_F(AudioParams, UpdateTooHighIsNoop) { - uut.update("test", 1.1); - + ASSERT_EQ(0, uut.update("test", 1.1)); ASSERT_EQ(5, *uut.get_raw_parameter("test")); } TEST_F(AudioParams, UpdateTooLowIsNoop) { - uut.update("test", -0.1); - + ASSERT_EQ(0, uut.update("test", -0.1)); ASSERT_EQ(5, *uut.get_raw_parameter("test")); } TEST_F(AudioParams, UpdateLimitEdgeCases) { - uut.update("test", 0); + ASSERT_EQ(0, uut.update("test", 0)); ASSERT_EQ(0, *uut.get_raw_parameter("test")); - uut.update("test", 1); + ASSERT_EQ(0, uut.update("test", 1)); ASSERT_EQ(10, *uut.get_raw_parameter("test")); } TEST_F(AudioParams, Iterate) { - (void)uut.create_and_add_parameter("test1", 0, 10, 1); - (void)uut.create_and_add_parameter("test2", 0, 10, 2); + ASSERT_EQ(0, uut.create_and_add_parameter("test1", 0, 10, 1)); + ASSERT_EQ(0, uut.create_and_add_parameter("test2", 0, 10, 2)); std::map expected{ {"test", 5}, // defined in test fixture diff --git a/firmware/components/cmd_handling/test/test_cmd_handling.cpp b/firmware/components/cmd_handling/test/test_cmd_handling.cpp index 9ec30b64..3db3a6ea 100644 --- a/firmware/components/cmd_handling/test/test_cmd_handling.cpp +++ b/firmware/components/cmd_handling/test/test_cmd_handling.cpp @@ -71,6 +71,7 @@ class EventSendAdapter final void send(const shrapnel::parameters::ApiMessage &message, std::optional fd) { + event.send(message, fd); } @@ -157,6 +158,6 @@ TEST_F(CmdHandling, InitialiseParameters) EXPECT_CALL(event, send({expected}, testing::Eq(std::nullopt))).Times(1); dispatch(shrapnel::parameters::Initialise{}, 0); -} +} // namespace } // namespace diff --git a/firmware/components/midi_handling/CMakeLists.txt b/firmware/components/midi_handling/CMakeLists.txt new file mode 100644 index 00000000..95cee622 --- /dev/null +++ b/firmware/components/midi_handling/CMakeLists.txt @@ -0,0 +1,25 @@ +add_library(shrapnel_midi_handling INTERFACE) +add_library(shrapnel::midi_handling ALIAS shrapnel_midi_handling) + +target_include_directories(shrapnel_midi_handling INTERFACE include) + +target_link_libraries(shrapnel_midi_handling INTERFACE + shrapnel::etl + shrapnel::messages + shrapnel::presets +) + +if(DEFINED TESTING) + add_executable(midi_handling_test + test/test_midi_handling.cpp) + + target_link_libraries(midi_handling_test + PRIVATE + GTest::gmock + GTest::gtest_main + shrapnel::audio_param + shrapnel::compiler_warning_flags + shrapnel::midi_handling) + + gtest_discover_tests(midi_handling_test) +endif() diff --git a/firmware/components/midi_handling/include/midi_handling.h b/firmware/components/midi_handling/include/midi_handling.h new file mode 100644 index 00000000..2cddbb3f --- /dev/null +++ b/firmware/components/midi_handling/include/midi_handling.h @@ -0,0 +1,108 @@ +/* + * Copyright 2022 Barabas Raffai + * + * This file is part of ShrapnelDSP. + * + * ShrapnelDSP is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * ShrapnelDSP is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * ShrapnelDSP. If not, see . + */ + +#pragma once + +#include "etl/delegate.h" +#include "messages.h" +#include "midi_mapping.h" +#include "midi_protocol.h" +#include "presets_manager.h" +#include "selected_preset_manager.h" + +namespace shrapnel { + +template +class MidiMessageHandler +{ +public: + MidiMessageHandler(std::shared_ptr a_parameters, + std::shared_ptr a_mapping_manager, + std::shared_ptr a_preset_loader) + : parameters{std::move(a_parameters)}, + mapping_manager{std::move(a_mapping_manager)}, + preset_loader{std::move(a_preset_loader)} {}; + + /** React to a MIDI message by updating an audio parameter if there is a + * mapping registered + */ + void process_message(midi::Message message) const + { + auto cc_params = + get_if(&message.parameters); + if(!cc_params) + return; + + for(const auto &[_, mapping] : *mapping_manager) + { + if(mapping.midi_channel != message.channel) + { + continue; + } + + if(mapping.cc_number != cc_params->control) + { + continue; + } + + switch(mapping.mode) + { + case midi::Mapping::Mode::PARAMETER: + parameters->update(mapping.parameter_name, + cc_params->value / + float(midi::CC_VALUE_MAX)); + break; + case midi::Mapping::Mode::TOGGLE: + { + if(cc_params->value == 0) + { + continue; + } + + auto old_value = parameters->get(mapping.parameter_name); + + parameters->update(mapping.parameter_name, + old_value < 0.5f ? 1.f : 0.f); + break; + } + case midi::Mapping::Mode::BUTTON: + { + // A button sends the maximum value when it becomes pressed. + if(cc_params->value != midi::CC_VALUE_MAX) + { + continue; + } + + auto id = mapping.preset_id; + preset_loader->load_preset(id); + break; + } + } + } + } + +private: + std::shared_ptr parameters; + std::shared_ptr mapping_manager; + std::shared_ptr preset_loader; +}; + +} // namespace shrapnel \ No newline at end of file diff --git a/firmware/components/midi_handling/test/test_midi_handling.cpp b/firmware/components/midi_handling/test/test_midi_handling.cpp new file mode 100644 index 00000000..68a12482 --- /dev/null +++ b/firmware/components/midi_handling/test/test_midi_handling.cpp @@ -0,0 +1,187 @@ +/* + * Copyright 2022 Barabas Raffai + * + * This file is part of ShrapnelDSP. + * + * ShrapnelDSP is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * ShrapnelDSP is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * ShrapnelDSP. If not, see . + */ + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include + +#include "audio_param.h" +#include "midi_handling.h" + +namespace { + +using namespace shrapnel; +using shrapnel::midi::Mapping; +using shrapnel::midi::Message; + +using id_t = shrapnel::parameters::id_t; +using ::testing::FloatEq; +using ::testing::Not; +using ::testing::Return; +using ::testing::StrictMock; + +class MockAudioParameters +{ +public: + MOCK_METHOD(int, update, (id_t param, float value), ()); + MOCK_METHOD(float, get, (const id_t ¶m), ()); +}; + +class MockPresetLoader +{ +public: + MOCK_METHOD(int, load_preset, (presets::id_t id), ()); +}; + +TEST(MidiHandling, ProcessParameter) +{ + auto mappings = + std::make_shared>(); + auto parameters_mock = std::make_shared(); + auto preset_loader = std::make_shared>(); + + auto sut = MidiMessageHandler(parameters_mock, mappings, preset_loader); + + (*mappings)[0] = { + .midi_channel{1}, + .cc_number{2}, + .mode{Mapping::Mode::PARAMETER}, + .parameter_name{"gain"}, + }; + + EXPECT_CALL(*parameters_mock, update(id_t("gain"), 0.f)); + sut.process_message({ + .channel{1}, + .parameters{Message::ControlChange{.control = 2, .value = 0}}, + }); + + EXPECT_CALL(*parameters_mock, update(id_t("gain"), 1.f)); + sut.process_message({ + .channel{1}, + .parameters{Message::ControlChange{ + .control = 2, .value = shrapnel::midi::CC_VALUE_MAX}}, + }); + + EXPECT_CALL(*parameters_mock, update).Times(0); + sut.process_message({ + .channel{99}, + .parameters{Message::ControlChange{.control = 2, .value = 0}}, + }); + + sut.process_message({ + .channel{1}, + .parameters{Message::ControlChange{.control = 99, .value = 0}}, + }); +} + +TEST(MidiHandling, ProcessToggle) +{ + auto mappings = + std::make_shared>(); + auto parameters_mock = std::make_shared(); + auto preset_loader = std::make_shared>(); + + auto sut = MidiMessageHandler(parameters_mock, mappings, preset_loader); + + auto process_message_with_value = [&](uint8_t value) + { + sut.process_message({ + .channel{1}, + .parameters{Message::ControlChange{.control = 2, .value = value}}, + }); + }; + + (*mappings)[1] = { + .midi_channel{1}, + .cc_number{2}, + .mode{Mapping::Mode::TOGGLE}, + .parameter_name{"bypass"}, + }; + + EXPECT_CALL(*parameters_mock, get(id_t{"bypass"})) + .WillRepeatedly(Return(0.f)); + { + ::testing::InSequence seq; + + // update is called for every event with non-zero value + // mock always returns 0, so call should be with 1 always + EXPECT_CALL(*parameters_mock, update(id_t("bypass"), FloatEq(1.f))) + .Times(2); + EXPECT_CALL(*parameters_mock, update).Times(0); + } + + process_message_with_value(0); + process_message_with_value(1); + process_message_with_value(2); + + sut.process_message({ + .channel{99}, + .parameters{Message::ControlChange{.control = 2, .value = 1}}, + }); + + sut.process_message({ + .channel{1}, + .parameters{Message::ControlChange{.control = 99, .value = 1}}, + }); +} + +TEST(MidiHandling, ProcessPresetButton) +{ + auto mappings = + std::make_shared>(); + auto parameters_mock = std::make_shared(); + auto preset_loader = std::make_shared>(); + + auto sut = MidiMessageHandler(parameters_mock, mappings, preset_loader); + + (*mappings)[0] = { + .midi_channel{5}, + .cc_number{42}, + .mode{Mapping::Mode::BUTTON}, + .preset_id{3}, + }; + + // these messages are ignored + sut.process_message({ + .channel{5}, + .parameters{Message::ControlChange{.control = 42, + .value = midi::CC_VALUE_MAX - 1}}, + }); + + sut.process_message({ + .channel{5}, + .parameters{ + Message::ControlChange{.control = 41, .value = midi::CC_VALUE_MAX}}, + }); + + sut.process_message({ + .channel{6}, + .parameters{ + Message::ControlChange{.control = 41, .value = midi::CC_VALUE_MAX}}, + }); + + EXPECT_CALL(*preset_loader, load_preset(3)).Times(1); + sut.process_message({ + .channel{5}, + .parameters{ + Message::ControlChange{.control = 42, .value = midi::CC_VALUE_MAX}}, + }); +} + +} // namespace \ No newline at end of file diff --git a/firmware/components/midi_mapping/CMakeLists.txt b/firmware/components/midi_mapping/CMakeLists.txt index bde3dd88..60d53980 100644 --- a/firmware/components/midi_mapping/CMakeLists.txt +++ b/firmware/components/midi_mapping/CMakeLists.txt @@ -26,6 +26,7 @@ target_link_libraries(midi_mapping shrapnel::midi_protocol shrapnel::nanopb shrapnel::persistence + shrapnel::presets PRIVATE shrapnel::compiler_warning_flags) diff --git a/firmware/components/midi_mapping/include/midi_mapping.h b/firmware/components/midi_mapping/include/midi_mapping.h index 0fec6680..7f20e2e0 100644 --- a/firmware/components/midi_mapping/include/midi_mapping.h +++ b/firmware/components/midi_mapping/include/midi_mapping.h @@ -46,19 +46,21 @@ #include #include #include +#include #include "audio_param.h" #include "crud.h" #include "midi_mapping_api.h" #include "midi_protocol.h" +#include "presets.h" +#include "presets_manager.h" +#include "selected_preset_manager.h" namespace shrapnel::midi { using MappingObserver = etl::observer; -template +template class MappingManager final : public etl::observable, public persistence::Crud @@ -66,11 +68,9 @@ class MappingManager final public: using MapType = etl::map; - MappingManager( - std::shared_ptr a_parameters, + explicit MappingManager( std::unique_ptr>> a_storage) - : parameters{a_parameters}, - storage{std::move(a_storage)} + : storage{std::move(a_storage)} { for_each( [this](uint32_t id, const shrapnel::midi::Mapping &mapping) { @@ -173,7 +173,6 @@ class MappingManager final { return -1; } - mappings.erase(id); this->notify_observers(id); return 0; @@ -195,50 +194,11 @@ class MappingManager final }); } - /** React to a MIDI message by updating an audio parameter if there is a - * mapping registered - */ - void process_message(Message message) const - { - auto cc_params = get_if(&message.parameters); - if(!cc_params) - return; - - for(const auto &[_, mapping] : mappings) - { - if(mapping.midi_channel != message.channel) - { - continue; - } - - if(mapping.cc_number != cc_params->control) - { - continue; - } + MapType::iterator begin() { return mappings.begin(); } - switch(mapping.mode) - { - case Mapping::Mode::PARAMETER: - parameters->update(mapping.parameter_name, - cc_params->value / float(CC_VALUE_MAX)); - break; - case Mapping::Mode::TOGGLE: - if(cc_params->value == 0) - { - continue; - } - - auto old_value = parameters->get(mapping.parameter_name); - - parameters->update(mapping.parameter_name, - old_value < 0.5f ? 1.f : 0.f); - break; - } - } - }; + MapType::iterator end() { return mappings.end(); } private: - std::shared_ptr parameters; std::unique_ptr>> storage; MapType mappings; diff --git a/firmware/components/midi_mapping/include/midi_mapping_api.h b/firmware/components/midi_mapping/include/midi_mapping_api.h index edd4e682..b5c0a3d6 100644 --- a/firmware/components/midi_mapping/include/midi_mapping_api.h +++ b/firmware/components/midi_mapping/include/midi_mapping_api.h @@ -23,6 +23,7 @@ #include "audio_param.h" #include "midi_mapping.pb.h" #include "midi_protocol.h" +#include "presets.h" #include #include @@ -37,12 +38,14 @@ struct Mapping { PARAMETER, TOGGLE, + BUTTON, }; uint8_t midi_channel; uint8_t cc_number; Mode mode; parameters::id_t parameter_name; + presets::id_t preset_id; std::strong_ordering operator<=>(const Mapping &other) const = default; }; @@ -67,6 +70,7 @@ struct CreateResponse }; struct Update + { std::pair mapping; std::strong_ordering operator<=>(const Update &other) const = default; diff --git a/firmware/components/midi_mapping/src/midi_mapping_api.cpp b/firmware/components/midi_mapping/src/midi_mapping_api.cpp index fc3793a7..8aea3f2b 100644 --- a/firmware/components/midi_mapping/src/midi_mapping_api.cpp +++ b/firmware/components/midi_mapping/src/midi_mapping_api.cpp @@ -33,6 +33,7 @@ etl::string_stream &operator<<(etl::string_stream &out, const Mapping &self) out << " mode " << (self.mode == Mapping::Mode::TOGGLE ? "toggle" : "parameter"); out << " name " << self.parameter_name; + out << " preset " << self.preset_id; out << " }"; return out; } @@ -76,6 +77,7 @@ etl::string_stream &operator<<(etl::string_stream &out, const MappingApiMessage &self) { if(auto message = std::get_if(&self)) + { out << "" << *message; } @@ -158,12 +160,16 @@ int to_proto(const midi::Mapping &message, shrapnel_midi_mapping_Mapping &out) case midi::Mapping::Mode::TOGGLE: out.mode = shrapnel_midi_mapping_Mapping_Mode_toggle; break; + case midi::Mapping::Mode::BUTTON: + out.mode = shrapnel_midi_mapping_Mapping_Mode_button; + break; default: return -1; } - strncpy(out.parameterName, + strncpy(out.parameter_name, message.parameter_name.data(), - sizeof out.parameterName); + sizeof out.parameter_name); + out.preset_id = message.preset_id; return 0; } @@ -172,7 +178,8 @@ int from_proto(const shrapnel_midi_mapping_Mapping &message, midi::Mapping &out) { out.midi_channel = static_cast(message.midi_channel); out.cc_number = static_cast(message.cc_number); - out.parameter_name = message.parameterName; + out.parameter_name = message.parameter_name; + out.preset_id = message.preset_id; switch(message.mode) { case shrapnel_midi_mapping_Mapping_Mode_parameter: @@ -181,6 +188,9 @@ int from_proto(const shrapnel_midi_mapping_Mapping &message, midi::Mapping &out) case shrapnel_midi_mapping_Mapping_Mode_toggle: out.mode = midi::Mapping::Mode::TOGGLE; break; + case shrapnel_midi_mapping_Mapping_Mode_button: + out.mode = midi::Mapping::Mode::BUTTON; + break; default: return -1; } diff --git a/firmware/components/midi_mapping/test/test_midi_mapping.cpp b/firmware/components/midi_mapping/test/test_midi_mapping.cpp index cb4cb35d..dab7efa1 100644 --- a/firmware/components/midi_mapping/test/test_midi_mapping.cpp +++ b/firmware/components/midi_mapping/test/test_midi_mapping.cpp @@ -35,24 +35,15 @@ using ::testing::FloatEq; using ::testing::Not; using ::testing::Return; -class MockAudioParameters -{ -public: - MOCK_METHOD(int, update, (id_t param, float value), ()); - MOCK_METHOD(float, get, (const id_t ¶m), ()); -}; - class MidiMapping : public ::testing::Test { - using SutType = MappingManager; + using SutType = MappingManager<2, 1>; protected: MidiMapping() {} - SutType create_sut() { return {parameters_mock, std::move(storage_mock)}; } + SutType create_sut() { return SutType{std::move(storage_mock)}; } - std::shared_ptr parameters_mock = - std::make_shared(); std::unique_ptr storage_mock = std::make_unique(); }; @@ -69,80 +60,6 @@ TEST_F(MidiMapping, Create) Not(0)); } -TEST_F(MidiMapping, ProcessParameter) -{ - uint32_t id; - auto sut = create_sut(); - EXPECT_EQ(0, sut.create({1, 2, Mapping::Mode::PARAMETER, "gain"}, id)); - - EXPECT_CALL(*parameters_mock, update(id_t("gain"), 0.f)); - sut.process_message({ - .channel{1}, - .parameters{Message::ControlChange{.control = 2, .value = 0}}, - }); - - EXPECT_CALL(*parameters_mock, update(id_t("gain"), 1.f)); - sut.process_message({ - .channel{1}, - .parameters{ - Message::ControlChange{.control = 2, .value = CC_VALUE_MAX}}, - }); - - EXPECT_CALL(*parameters_mock, update).Times(0); - sut.process_message({ - .channel{99}, - .parameters{Message::ControlChange{.control = 2, .value = 0}}, - }); - - sut.process_message({ - .channel{1}, - .parameters{Message::ControlChange{.control = 99, .value = 0}}, - }); -} - -TEST_F(MidiMapping, ProcessToggle) -{ - auto sut = create_sut(); - - auto process_message_with_value = [&](uint8_t value) - { - sut.process_message({ - .channel{1}, - .parameters{Message::ControlChange{.control = 2, .value = value}}, - }); - }; - - // FIXME: use a mock for this, rather than setting up here - uint32_t id; - EXPECT_EQ(0, sut.create({1, 2, Mapping::Mode::TOGGLE, "bypass"}, id)); - - EXPECT_CALL(*parameters_mock, get(id_t{"bypass"})) - .WillRepeatedly(Return(0.f)); - { - ::testing::InSequence seq; - - // update is called for every event with non-zero value - // mock always returns 0, so call should be with 1 always - EXPECT_CALL(*parameters_mock, update(id_t("bypass"), FloatEq(1.f))) - .Times(2); - EXPECT_CALL(*parameters_mock, update).Times(0); - } - - process_message_with_value(0); - process_message_with_value(1); - process_message_with_value(2); - - sut.process_message({ - .channel{99}, - .parameters{Message::ControlChange{.control = 2, .value = 1}}, - }); - - sut.process_message({ - .channel{1}, - .parameters{Message::ControlChange{.control = 99, .value = 1}}, - }); -} - TEST_F(MidiMapping, Update) { uint32_t id; @@ -159,18 +76,7 @@ TEST_F(MidiMapping, Update) EXPECT_THAT(sut.update(0, {5, 6, Mapping::Mode::PARAMETER, "tone"}), 0); EXPECT_THAT(sut.get()->size(), 2); - // FIXME: move the tests related to this function elsewhere - EXPECT_CALL(*parameters_mock, update).Times(0); - sut.process_message({ - .channel{1}, - .parameters{Message::ControlChange{.control = 2, .value = 0}}, - }); - - EXPECT_CALL(*parameters_mock, update(id_t("tone"), 0)); - sut.process_message({ - .channel{5}, - .parameters{Message::ControlChange{.control = 6, .value = 0}}, - }); + // TODO should we verify iteration here or something? } TEST_F(MidiMapping, Destroy) @@ -215,12 +121,12 @@ TEST(MidiMappingPod, ToString) { Mapping mapping{1, 2, Mapping::Mode::PARAMETER, parameters::id_t("test")}; - etl::string<64> buffer; + etl::string<128> buffer; etl::string_stream stream{buffer}; stream << mapping; EXPECT_THAT(std::string(buffer.data()), - "{ channel 1 cc number 2 mode parameter name test }"); + "{ channel 1 cc number 2 mode parameter name test preset 0 }"); } TEST(MidiMappingPod, GetRequestToString) @@ -251,9 +157,9 @@ TEST(MidiMappingPod, CreateRequestToString) etl::string_stream stream{buffer}; stream << message; - EXPECT_THAT( - std::string(buffer.data()), - "{ { channel 1 cc number 2 mode toggle name test } }"); + EXPECT_THAT(std::string(buffer.data()), + "{ { channel 1 cc number 2 mode toggle name " + "test preset 0 } }"); } TEST(MidiMappingPod, CreateResponseToString) @@ -278,7 +184,7 @@ TEST(MidiMappingPod, CreateResponseToString) EXPECT_THAT(std::string(buffer.data()), "{ { 42, { channel 1 cc number 2 mode toggle " - "name test } } }"); + "name test preset 0 } } }"); } TEST(MidiMappingPod, UpdateToString) @@ -301,10 +207,10 @@ TEST(MidiMappingPod, UpdateToString) etl::string_stream stream{buffer}; stream << message; - EXPECT_THAT( - std::string(buffer.data()), - "{ { 42, { channel 1 cc number 2 mode toggle name test } } }"); -} + EXPECT_THAT(std::string(buffer.data()), + "{ { 42, { channel 1 cc number 2 mode toggle name test " + "preset 0 } } }"); +} // namespace TEST(MidiMappingPod, RemoveToString) { diff --git a/firmware/components/presets/include/presets_manager.h b/firmware/components/presets/include/presets_manager.h index f9cb802f..d0178447 100644 --- a/firmware/components/presets/include/presets_manager.h +++ b/firmware/components/presets/include/presets_manager.h @@ -16,6 +16,7 @@ * You should have received a copy of the GNU General Public License along with * ShrapnelDSP. If not, see . */ +#pragma once #include "crud.h" #include "preset_serialisation.h" diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 0383253a..1d71bd4c 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -24,6 +24,7 @@ if(ESP_PLATFORM) shrapnel::heap_tracing shrapnel::i2s shrapnel::messages + shrapnel::midi_handling shrapnel::midi_mapping shrapnel::midi_protocol shrapnel::os @@ -43,6 +44,7 @@ else() shrapnel::cmd_handling shrapnel::etl shrapnel::messages + shrapnel::midi_handling shrapnel::midi_mapping shrapnel::midi_protocol shrapnel::os diff --git a/firmware/main/include/main_thread.h b/firmware/main/include/main_thread.h index 685d3570..00a442b4 100644 --- a/firmware/main/include/main_thread.h +++ b/firmware/main/include/main_thread.h @@ -25,6 +25,7 @@ #include "audio_param.h" #include "cmd_handling.h" #include "messages.h" +#include "midi_handling.h" #include "midi_mapping.pb.h" #include "midi_uart.h" #include "os/queue.h" @@ -46,7 +47,7 @@ class ParameterUpdateNotifier; using AudioParameters = parameters::AudioParameters; using SendMessageCallback = etl::delegate; -using MidiMappingType = midi::MappingManager; +using MidiMappingType = midi::MappingManager<10, 1>; class ParameterUpdateNotifier { @@ -153,6 +154,56 @@ class ParameterObserver final : public parameters::ParameterObserver etl::map updated_parameters; }; +template +class PresetLoader +{ +public: + PresetLoader(std::shared_ptr a_parameters, + std::shared_ptr a_presets_manager, + std::shared_ptr + a_selected_preset_manager, + SendMessageCallback a_send_message) + : parameters{std::move(a_parameters)}, + presets_manager{std::move(a_presets_manager)}, + selected_preset_manager{std::move(a_selected_preset_manager)}, + send_message{a_send_message} + { + } + + int load_preset(presets::id_t id) + { + int rc = selected_preset_manager->set(id); + if(rc != 0) + { + ESP_LOGE(TAG, "Failed to set selected preset ID"); + return -1; + } + + presets::PresetData preset{}; + rc = presets_manager->read(id, preset); + if(rc != 0) + { + ESP_LOGE(TAG, "Failed to read preset data"); + return -1; + } + + presets::deserialise_live_parameters(*parameters, preset.parameters); + + send_message({selected_preset::Notify{ + .selectedPresetId = id, + }, + std::nullopt}); + return 0; + } + +private: + std::shared_ptr parameters; + std::shared_ptr presets_manager; + std::shared_ptr + selected_preset_manager; + SendMessageCallback send_message; +}; + template class MainThread { @@ -194,10 +245,10 @@ class MainThread SendMessageCallback::create< MainThread, &MainThread::cmd_handling_send_message>(*this))}, - presets_manager{std::make_unique( + presets_manager{std::make_shared( std::move(a_presets_storage))}, selected_preset_manager{ - std::make_unique( + std::make_shared( a_persistence)} { auto create_and_load_parameter = [&](const parameters::id_t &name, @@ -261,8 +312,20 @@ class MainThread parameter_notifier = std::make_shared( a_audio_params, a_send_message); - midi_mapping_manager = std::make_unique( - parameter_notifier, std::move(a_midi_mapping_storage)); + midi_mapping_manager = std::make_shared( + std::move(a_midi_mapping_storage)); + + preset_loader = std::make_shared>( + parameter_notifier, + presets_manager, + selected_preset_manager, + send_message); + + midi_message_handler = std::make_shared< + MidiMessageHandler>>( + parameter_notifier, midi_mapping_manager, preset_loader); BaseType_t rc = midi_message_notify_timer.start(portMAX_DELAY); if(rc != pdPASS) @@ -545,24 +608,9 @@ class MainThread std::optional handle_selected_preset_message(selected_preset::Write write) { - int rc = selected_preset_manager->set(write.selectedPresetId); - if(rc != 0) - { - return std::nullopt; - } - - presets::PresetData preset{}; - rc = presets_manager->read(write.selectedPresetId, preset); - if(rc != 0) - { - return std::nullopt; - } - - deserialise_live_parameters(*parameter_notifier, preset.parameters); - - return selected_preset::Notify{ - .selectedPresetId = write.selectedPresetId, - }; + presets::id_t id = write.selectedPresetId; + preset_loader->load_preset(id); + return std::nullopt; } void on_midi_message(midi::Message message) @@ -576,7 +624,7 @@ class MainThread { std::scoped_lock lock{midi_mutex}; - midi_mapping_manager->process_message(message); + midi_message_handler->process_message(message); } }; @@ -599,15 +647,20 @@ class MainThread std::optional last_notified_midi_message; std::unique_ptr midi_decoder; std::mutex midi_mutex; - std::unique_ptr midi_mapping_manager; + std::shared_ptr midi_mapping_manager; + std::shared_ptr>> + midi_message_handler; std::shared_ptr audio_params; std::unique_ptr>> cmd_handling; - std::unique_ptr presets_manager; - std::unique_ptr + std::shared_ptr presets_manager; + std::shared_ptr selected_preset_manager; std::shared_ptr parameter_notifier; + std::shared_ptr> preset_loader; }; } // namespace shrapnel \ No newline at end of file diff --git a/frontend/.run/main.dart (Debug).run.xml b/frontend/.run/main.dart (Debug).run.xml new file mode 100644 index 00000000..97979e00 --- /dev/null +++ b/frontend/.run/main.dart (Debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/frontend/lib/api/generated/audio_events.pb.dart b/frontend/lib/api/generated/audio_events.pb.dart index 6c38bc29..d32d5a99 100644 --- a/frontend/lib/api/generated/audio_events.pb.dart +++ b/frontend/lib/api/generated/audio_events.pb.dart @@ -13,6 +13,7 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; +/// Sent when the input level is too high, causing clipping class InputClipped extends $pb.GeneratedMessage { factory InputClipped() => create(); InputClipped._() : super(); @@ -54,6 +55,7 @@ class InputClipped extends $pb.GeneratedMessage { static InputClipped? _defaultInstance; } +/// Sent when the output level is too high, causing clipping class OutputClipped extends $pb.GeneratedMessage { factory OutputClipped() => create(); OutputClipped._() : super(); diff --git a/frontend/lib/api/generated/cmd_handling.pb.dart b/frontend/lib/api/generated/cmd_handling.pb.dart index a6192f53..35287eb8 100644 --- a/frontend/lib/api/generated/cmd_handling.pb.dart +++ b/frontend/lib/api/generated/cmd_handling.pb.dart @@ -13,6 +13,9 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; +/// Firmware: update DSP processing to use the new parameter value. +/// +/// Frontend: Update UI to show new parameter value. class Update extends $pb.GeneratedMessage { factory Update({ $core.String? id, @@ -90,6 +93,8 @@ class Update extends $pb.GeneratedMessage { void clearValue() => clearField(2); } +/// The firmware will respond by sending a `parameterUpdate` message with the +/// current value of each audio parameter class Initialise extends $pb.GeneratedMessage { factory Initialise() => create(); Initialise._() : super(); diff --git a/frontend/lib/api/generated/midi_mapping.pb.dart b/frontend/lib/api/generated/midi_mapping.pb.dart index 58f8d808..02a4beee 100644 --- a/frontend/lib/api/generated/midi_mapping.pb.dart +++ b/frontend/lib/api/generated/midi_mapping.pb.dart @@ -23,6 +23,7 @@ class Mapping extends $pb.GeneratedMessage { $core.int? ccNumber, Mapping_Mode? mode, $core.String? parameterName, + $core.int? presetId, }) { final $result = create(); if (midiChannel != null) { @@ -37,6 +38,9 @@ class Mapping extends $pb.GeneratedMessage { if (parameterName != null) { $result.parameterName = parameterName; } + if (presetId != null) { + $result.presetId = presetId; + } return $result; } Mapping._() : super(); @@ -58,7 +62,8 @@ class Mapping extends $pb.GeneratedMessage { defaultOrMaker: Mapping_Mode.parameter, valueOf: Mapping_Mode.valueOf, enumValues: Mapping_Mode.values) - ..aOS(4, _omitFieldNames ? '' : 'parameterName', protoName: 'parameterName') + ..aOS(4, _omitFieldNames ? '' : 'parameterName') + ..a<$core.int>(5, _omitFieldNames ? '' : 'presetId', $pb.PbFieldType.OU3) ..hasRequiredFields = false; @$core.Deprecated('Using this can add significant overhead to your binary. ' @@ -129,6 +134,18 @@ class Mapping extends $pb.GeneratedMessage { $core.bool hasParameterName() => $_has(3); @$pb.TagNumber(4) void clearParameterName() => clearField(4); + + @$pb.TagNumber(5) + $core.int get presetId => $_getIZ(4); + @$pb.TagNumber(5) + set presetId($core.int v) { + $_setUnsignedInt32(4, v); + } + + @$pb.TagNumber(5) + $core.bool hasPresetId() => $_has(4); + @$pb.TagNumber(5) + void clearPresetId() => clearField(5); } class MidiMessage_NoteOn extends $pb.GeneratedMessage { @@ -442,6 +459,7 @@ enum MidiMessage_Parameters { notSet } +/// A MIDI message that was received by the firmware class MidiMessage extends $pb.GeneratedMessage { factory MidiMessage({ $core.int? channel, @@ -679,6 +697,8 @@ class MappingRecord extends $pb.GeneratedMessage { Mapping ensureMapping() => $_ensure(1); } +/// Send from the frontend to the firmware. When received, the firmware will send +/// an update for each midi mapping that currently exists. class GetRequest extends $pb.GeneratedMessage { factory GetRequest() => create(); GetRequest._() : super(); @@ -718,6 +738,7 @@ class GetRequest extends $pb.GeneratedMessage { static GetRequest? _defaultInstance; } +/// Firmware creates a new MIDI mapping according to the parameters. class CreateRequest extends $pb.GeneratedMessage { factory CreateRequest({ Mapping? mapping, @@ -783,6 +804,7 @@ class CreateRequest extends $pb.GeneratedMessage { Mapping ensureMapping() => $_ensure(0); } +/// Indicates that a new MIDI mapping was created successfully. class CreateResponse extends $pb.GeneratedMessage { factory CreateResponse({ MappingRecord? mapping, @@ -848,6 +870,7 @@ class CreateResponse extends $pb.GeneratedMessage { MappingRecord ensureMapping() => $_ensure(0); } +/// Firmware updates an existing MIDI mapping according to the parameters. class Update extends $pb.GeneratedMessage { factory Update({ MappingRecord? mapping, @@ -911,6 +934,7 @@ class Update extends $pb.GeneratedMessage { MappingRecord ensureMapping() => $_ensure(0); } +/// Firmware removes the specified MIDI mapping . class Remove extends $pb.GeneratedMessage { factory Remove({ $core.int? id, @@ -971,6 +995,9 @@ class Remove extends $pb.GeneratedMessage { void clearId() => clearField(1); } +/// Firmware notifies this periodically, so that the frontend can react +/// to MIDI messages, for example to connect a parameter to the MIDI +/// message. class MessageReceived extends $pb.GeneratedMessage { factory MessageReceived({ MidiMessage? receivedMessage, diff --git a/frontend/lib/api/generated/midi_mapping.pbenum.dart b/frontend/lib/api/generated/midi_mapping.pbenum.dart index ebfd4c28..2faa4b9e 100644 --- a/frontend/lib/api/generated/midi_mapping.pbenum.dart +++ b/frontend/lib/api/generated/midi_mapping.pbenum.dart @@ -18,10 +18,13 @@ class Mapping_Mode extends $pb.ProtobufEnum { Mapping_Mode._(0, _omitEnumNames ? '' : 'parameter'); static const Mapping_Mode toggle = Mapping_Mode._(1, _omitEnumNames ? '' : 'toggle'); + static const Mapping_Mode button = + Mapping_Mode._(2, _omitEnumNames ? '' : 'button'); static const $core.List values = [ parameter, toggle, + button, ]; static final $core.Map<$core.int, Mapping_Mode> _byValue = diff --git a/frontend/lib/api/generated/midi_mapping.pbjson.dart b/frontend/lib/api/generated/midi_mapping.pbjson.dart index 98248221..24adc6e8 100644 --- a/frontend/lib/api/generated/midi_mapping.pbjson.dart +++ b/frontend/lib/api/generated/midi_mapping.pbjson.dart @@ -27,7 +27,8 @@ const Mapping$json = { '6': '.shrapnel.midi_mapping.Mapping.Mode', '10': 'mode' }, - {'1': 'parameterName', '3': 4, '4': 1, '5': 9, '10': 'parameterName'}, + {'1': 'parameter_name', '3': 4, '4': 1, '5': 9, '10': 'parameterName'}, + {'1': 'preset_id', '3': 5, '4': 1, '5': 13, '10': 'presetId'}, ], '4': [Mapping_Mode$json], }; @@ -38,6 +39,7 @@ const Mapping_Mode$json = { '2': [ {'1': 'parameter', '2': 0}, {'1': 'toggle', '2': 1}, + {'1': 'button', '2': 2}, ], }; @@ -45,8 +47,9 @@ const Mapping_Mode$json = { final $typed_data.Uint8List mappingDescriptor = $convert.base64Decode( 'CgdNYXBwaW5nEiEKDG1pZGlfY2hhbm5lbBgBIAEoDVILbWlkaUNoYW5uZWwSGwoJY2NfbnVtYm' 'VyGAIgASgNUghjY051bWJlchI3CgRtb2RlGAMgASgOMiMuc2hyYXBuZWwubWlkaV9tYXBwaW5n' - 'Lk1hcHBpbmcuTW9kZVIEbW9kZRIkCg1wYXJhbWV0ZXJOYW1lGAQgASgJUg1wYXJhbWV0ZXJOYW' - '1lIiEKBE1vZGUSDQoJcGFyYW1ldGVyEAASCgoGdG9nZ2xlEAE='); + 'Lk1hcHBpbmcuTW9kZVIEbW9kZRIlCg5wYXJhbWV0ZXJfbmFtZRgEIAEoCVINcGFyYW1ldGVyTm' + 'FtZRIbCglwcmVzZXRfaWQYBSABKA1SCHByZXNldElkIi0KBE1vZGUSDQoJcGFyYW1ldGVyEAAS' + 'CgoGdG9nZ2xlEAESCgoGYnV0dG9uEAI='); @$core.Deprecated('Use midiMessageDescriptor instead') const MidiMessage$json = { diff --git a/frontend/lib/api/proto_extension.dart b/frontend/lib/api/proto_extension.dart index da0e4206..49001998 100644 --- a/frontend/lib/api/proto_extension.dart +++ b/frontend/lib/api/proto_extension.dart @@ -200,21 +200,46 @@ extension MidiMappingEntryProtoEx on MidiMappingEntry { extension MidiMappingProtoEx on MidiMapping { static MidiMapping fromProto(midi_mapping_pb.Mapping proto) { - return MidiMapping( - midiChannel: proto.midiChannel, - ccNumber: proto.ccNumber, - parameterId: proto.parameterName, - mode: MidiMappingModeProtoEx.fromProto(proto.mode), - ); + return switch (MidiMappingModeProtoEx.fromProto(proto.mode)) { + MidiMappingMode.toggle => MidiMapping.toggle( + midiChannel: proto.midiChannel, + ccNumber: proto.ccNumber, + parameterId: proto.parameterName, + ), + MidiMappingMode.parameter => MidiMapping.toggle( + midiChannel: proto.midiChannel, + ccNumber: proto.ccNumber, + parameterId: proto.parameterName, + ), + MidiMappingMode.button => MidiMapping.button( + midiChannel: proto.midiChannel, + ccNumber: proto.ccNumber, + presetId: proto.presetId, + ), + }; } midi_mapping_pb.Mapping toProto() { - return midi_mapping_pb.Mapping( - midiChannel: midiChannel, - ccNumber: ccNumber, - mode: mode.toProto(), - parameterName: parameterId, - ); + return switch (this) { + MidiMappingToggle(:final parameterId) => midi_mapping_pb.Mapping( + midiChannel: midiChannel, + ccNumber: ccNumber, + mode: MidiMappingMode.toggle.toProto(), + parameterName: parameterId, + ), + MidiMappingParameter(:final parameterId) => midi_mapping_pb.Mapping( + midiChannel: midiChannel, + ccNumber: ccNumber, + mode: MidiMappingMode.parameter.toProto(), + parameterName: parameterId, + ), + MidiMappingButton(:final presetId) => midi_mapping_pb.Mapping( + midiChannel: midiChannel, + ccNumber: ccNumber, + mode: MidiMappingMode.button.toProto(), + presetId: presetId, + ), + }; } } @@ -223,6 +248,7 @@ extension MidiMappingModeProtoEx on MidiMappingMode { return switch (proto) { midi_mapping_pb.Mapping_Mode.parameter => MidiMappingMode.parameter, midi_mapping_pb.Mapping_Mode.toggle => MidiMappingMode.toggle, + midi_mapping_pb.Mapping_Mode.button => MidiMappingMode.button, _ => throw ProtoException(), }; } @@ -231,6 +257,7 @@ extension MidiMappingModeProtoEx on MidiMappingMode { return switch (this) { MidiMappingMode.toggle => midi_mapping_pb.Mapping_Mode.toggle, MidiMappingMode.parameter => midi_mapping_pb.Mapping_Mode.parameter, + MidiMappingMode.button => midi_mapping_pb.Mapping_Mode.button, }; } } diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 99c10adf..445ebc47 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -225,6 +225,43 @@ class App extends StatelessWidget { ), Provider.value(value: presetsRepository), Provider.value(value: selectedPresetRepository), + StateNotifierProvider( + create: (_) { + return PresetsService( + presetsRepository: presetsRepository, + selectedPresetRepository: selectedPresetRepository, + parametersState: ParametersMergeStream( + ampGain: parameterService.getParameter('ampGain').value, + ampChannel: parameterService.getParameter('ampChannel').value, + bass: parameterService.getParameter('bass').value, + middle: parameterService.getParameter('middle').value, + treble: parameterService.getParameter('treble').value, + contour: parameterService.getParameter('contour').value, + volume: parameterService.getParameter('volume').value, + noiseGateThreshold: + parameterService.getParameter('noiseGateThreshold').value, + noiseGateHysteresis: + parameterService.getParameter('noiseGateHysteresis').value, + noiseGateAttack: + parameterService.getParameter('noiseGateAttack').value, + noiseGateHold: + parameterService.getParameter('noiseGateHold').value, + noiseGateRelease: + parameterService.getParameter('noiseGateRelease').value, + noiseGateBypass: + parameterService.getParameter('noiseGateBypass').value, + chorusRate: parameterService.getParameter('chorusRate').value, + chorusDepth: parameterService.getParameter('chorusDepth').value, + chorusMix: parameterService.getParameter('chorusMix').value, + chorusBypass: + parameterService.getParameter('chorusBypass').value, + wahPosition: parameterService.getParameter('wahPosition').value, + wahVocal: parameterService.getParameter('wahVocal').value, + wahBypass: parameterService.getParameter('wahBypass').value, + ).stream, + ); + }, + ), ], child: const MyApp(), ); @@ -431,44 +468,8 @@ class MyHomePage extends StatelessWidget { body: Center( child: Column( children: [ - StateNotifierProvider( - create: (_) { - final parameters = context.read(); - return PresetsService( - presetsRepository: context.read(), - selectedPresetRepository: - context.read(), - parametersState: ParametersMergeStream( - ampGain: parameters.getParameter('ampGain').value, - ampChannel: parameters.getParameter('ampChannel').value, - bass: parameters.getParameter('bass').value, - middle: parameters.getParameter('middle').value, - treble: parameters.getParameter('treble').value, - contour: parameters.getParameter('contour').value, - volume: parameters.getParameter('volume').value, - noiseGateThreshold: - parameters.getParameter('noiseGateThreshold').value, - noiseGateHysteresis: - parameters.getParameter('noiseGateHysteresis').value, - noiseGateAttack: - parameters.getParameter('noiseGateAttack').value, - noiseGateHold: - parameters.getParameter('noiseGateHold').value, - noiseGateRelease: - parameters.getParameter('noiseGateRelease').value, - noiseGateBypass: - parameters.getParameter('noiseGateBypass').value, - chorusRate: parameters.getParameter('chorusRate').value, - chorusDepth: parameters.getParameter('chorusDepth').value, - chorusMix: parameters.getParameter('chorusMix').value, - chorusBypass: parameters.getParameter('chorusBypass').value, - wahPosition: parameters.getParameter('wahPosition').value, - wahVocal: parameters.getParameter('wahVocal').value, - wahBypass: parameters.getParameter('wahBypass').value, - ).stream, - ); - }, - builder: (context, _) { + Builder( + builder: (context) { final model = context.read(); final state = context.watch(); diff --git a/frontend/lib/midi_mapping/model/midi_learn.dart b/frontend/lib/midi_mapping/model/midi_learn.dart index 5f0eaa64..8a45e81c 100644 --- a/frontend/lib/midi_mapping/model/midi_learn.dart +++ b/frontend/lib/midi_mapping/model/midi_learn.dart @@ -51,7 +51,7 @@ class MidiLearnService extends StateNotifier { void midiMessageReceived(MidiMessage message) { state.maybeWhen( - waitForMidi: (parameterId) { + waitForMidi: (mappedParameterId) { unawaited( message.maybeMap( controlChange: (message) async { @@ -67,9 +67,15 @@ class MidiLearnService extends StateNotifier { return e; }).where( (e) => - e.value.midiChannel == channel && - e.value.ccNumber == control || - e.value.parameterId == parameterId, + (e.value.midiChannel == channel && + e.value.ccNumber == control) || + switch (e.value) { + MidiMappingToggle(:final parameterId) || + MidiMappingParameter(:final parameterId) => + parameterId, + MidiMappingButton() => null, + } == + mappedParameterId, ); // Need copy to prevent concurrent modification of the lazy iterator @@ -86,11 +92,10 @@ class MidiLearnService extends StateNotifier { } await mappingService.createMapping( - MidiMapping( + MidiMapping.parameter( ccNumber: control, midiChannel: channel, - parameterId: parameterId, - mode: MidiMappingMode.parameter, + parameterId: mappedParameterId, ), ); diff --git a/frontend/lib/midi_mapping/model/models.dart b/frontend/lib/midi_mapping/model/models.dart index 4a11f32d..b2f401dd 100644 --- a/frontend/lib/midi_mapping/model/models.dart +++ b/frontend/lib/midi_mapping/model/models.dart @@ -47,13 +47,24 @@ sealed class MidiApiMessage with _$MidiApiMessage { } @freezed -class MidiMapping with _$MidiMapping { - const factory MidiMapping({ +sealed class MidiMapping with _$MidiMapping { + const factory MidiMapping.toggle({ required int midiChannel, required int ccNumber, required String parameterId, - required MidiMappingMode mode, - }) = _MidiMapping; + }) = MidiMappingToggle; + + const factory MidiMapping.parameter({ + required int midiChannel, + required int ccNumber, + required String parameterId, + }) = MidiMappingParameter; + + const factory MidiMapping.button({ + required int midiChannel, + required int ccNumber, + required int presetId, + }) = MidiMappingButton; const MidiMapping._(); } @@ -70,7 +81,8 @@ class MidiMappingEntry with _$MidiMappingEntry { enum MidiMappingMode { toggle(uiName: 'Toggle'), - parameter(uiName: 'Knob'); + parameter(uiName: 'Knob'), + button(uiName: 'Button'); const MidiMappingMode({required this.uiName}); diff --git a/frontend/lib/midi_mapping/view/midi_mapping.dart b/frontend/lib/midi_mapping/view/midi_mapping.dart index 0965dbcd..d1cf404c 100644 --- a/frontend/lib/midi_mapping/view/midi_mapping.dart +++ b/frontend/lib/midi_mapping/view/midi_mapping.dart @@ -23,9 +23,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../parameter.dart'; +import '../../presets/model/presets.dart'; import '../model/models.dart'; import '../model/service.dart'; +const _requiredValueString = 'A value must be selected'; + class MidiMappingPage extends StatelessWidget { const MidiMappingPage({super.key}); @@ -46,7 +49,7 @@ class MidiMappingPage extends StatelessWidget { DataColumn(label: Text('MIDI channel')), DataColumn(label: Text('CC number')), DataColumn(label: Text('Mode')), - DataColumn(label: Text('Parameter')), + DataColumn(label: Text('Target')), DataColumn(label: Text('Delete')), ], rows: midiMappingService.mappings.entries.map( @@ -84,23 +87,145 @@ class MidiMappingPage extends StatelessWidget { ModeDropdown( key: Key('${mapping.id}-mode-dropdown'), value: mapping.mapping.mode, - onChanged: (value) => unawaited( - midiMappingService.updateMapping( - mapping.copyWith.mapping(mode: value!), - ), - ), + onChanged: (value) { + if (value == mapping.mapping.mode) { + return; + } + + if (value == null) { + return; + } + + const parameterModes = [ + MidiMappingMode.parameter, + MidiMappingMode.toggle, + ]; + + final isTrivialChange = + parameterModes.contains(mapping.mapping.mode) && + parameterModes.contains(value); + + if (isTrivialChange) { + unawaited( + midiMappingService.updateMapping( + switch (mapping.mapping) { + final MidiMappingToggle toggle => switch ( + value) { + MidiMappingMode.toggle => + throw StateError(''), + MidiMappingMode.parameter => + mapping.copyWith( + mapping: MidiMapping.parameter( + midiChannel: toggle.midiChannel, + ccNumber: toggle.ccNumber, + parameterId: toggle.parameterId, + ), + ), + MidiMappingMode.button => + throw StateError(''), + }, + final MidiMappingParameter parameter => + switch (value) { + MidiMappingMode.toggle => + mapping.copyWith( + mapping: MidiMapping.toggle( + midiChannel: parameter.midiChannel, + ccNumber: parameter.ccNumber, + parameterId: parameter.parameterId, + ), + ), + MidiMappingMode.parameter => + throw StateError(''), + MidiMappingMode.button => + throw StateError(''), + }, + MidiMappingButton() => throw StateError(''), + }, + ), + ); + } else { + unawaited( + showDialog( + context: context, + builder: (context) => EditMappingDialog( + mapping: mapping, + mode: value, + ), + ), + ); + } + }, ), ), DataCell( - ParametersDropdown( - key: Key('${mapping.id}-parameter-id-dropdown'), - value: mapping.mapping.parameterId, - onChanged: (value) => unawaited( - midiMappingService.updateMapping( - mapping.copyWith.mapping(parameterId: value!), + switch (mapping.mapping) { + final MidiMappingToggle midiMapping => Row( + children: [ + Text( + 'Parameter:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 8), + ParametersDropdown( + key: Key('${mapping.id}-parameter-id-dropdown'), + value: midiMapping.parameterId, + onChanged: (value) => unawaited( + midiMappingService.updateMapping( + mapping.copyWith( + mapping: midiMapping.copyWith( + parameterId: value!, + ), + ), + ), + ), + ), + ], ), - ), - ), + final MidiMappingParameter midiMapping => Row( + children: [ + Text( + 'Parameter:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 8), + ParametersDropdown( + key: Key('${mapping.id}-parameter-id-dropdown'), + value: midiMapping.parameterId, + onChanged: (value) => unawaited( + midiMappingService.updateMapping( + mapping.copyWith( + mapping: midiMapping.copyWith( + parameterId: value!, + ), + ), + ), + ), + ), + ], + ), + final MidiMappingButton midiMapping => Row( + children: [ + Text( + 'Preset:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 8), + PresetsDropdown( + key: Key('${mapping.id}-preset-id-dropdown'), + value: midiMapping.presetId, + onChanged: (value) => unawaited( + midiMappingService.updateMapping( + mapping.copyWith( + mapping: midiMapping.copyWith( + presetId: value!, + ), + ), + ), + ), + ), + ], + ), + }, ), DataCell( IconButton( @@ -145,11 +270,13 @@ class CreateMappingDialogState extends State { int? channel; int? ccNumber; MidiMappingMode? mode; - String? parameter; + int? presetId; + String? parameterId; @override Widget build(BuildContext context) { final parameters = context.read().parameterNames; + final presets = context.watch(); final mappings = context.read(); return Dialog( @@ -164,7 +291,7 @@ class CreateMappingDialogState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - 'Create a new MIDI mapping', + 'Create MIDI mapping', style: Theme.of(context).textTheme.titleLarge, ), ), @@ -221,9 +348,55 @@ class CreateMappingDialogState extends State { ), ), ], - value: parameter, - onChanged: (value) => setState(() => parameter = value), - validator: validateIsNotNull, + value: parameterId, + onChanged: switch (mode) { + MidiMappingMode.toggle || + MidiMappingMode.parameter => + (value) => setState(() => parameterId = value), + null || MidiMappingMode.button => null, + }, + validator: (v) { + return switch (mode) { + null || MidiMappingMode.button => null, + MidiMappingMode.toggle || + MidiMappingMode.parameter => + v == null ? _requiredValueString : null, + }; + }, + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('Preset'), + ), + items: switch (presets) { + LoadingPresetsState() => null, + ReadyPresetsState(:final presets) => presets.map( + (e) => DropdownMenuItem( + value: e.id, + child: Text(e.preset.name), + ), + ), + } + ?.toList(), + value: presetId, + onChanged: switch (mode) { + null || + MidiMappingMode.toggle || + MidiMappingMode.parameter => + null, + MidiMappingMode.button => (value) => + setState(() => presetId = value), + }, + validator: (v) { + return switch (mode) { + null || + MidiMappingMode.toggle || + MidiMappingMode.parameter => + null, + MidiMappingMode.button => + v == null ? _requiredValueString : null, + }; + }, ), ElevatedButton( onPressed: () { @@ -231,12 +404,25 @@ class CreateMappingDialogState extends State { Navigator.pop(context); unawaited( mappings.createMapping( - MidiMapping( - midiChannel: channel!, - ccNumber: ccNumber!, - parameterId: parameter!, - mode: mode!, - ), + switch (mode) { + null => + throw StateError('Mode has not been selected'), + MidiMappingMode.toggle => MidiMapping.toggle( + midiChannel: channel!, + ccNumber: ccNumber!, + parameterId: parameterId!, + ), + MidiMappingMode.parameter => MidiMapping.parameter( + midiChannel: channel!, + ccNumber: ccNumber!, + parameterId: parameterId!, + ), + MidiMappingMode.button => MidiMapping.button( + midiChannel: channel!, + ccNumber: ccNumber!, + presetId: presetId!, + ), + }, ), ); } @@ -252,7 +438,244 @@ class CreateMappingDialogState extends State { String? validateIsNotNull(T? value) { if (value == null) { - return 'A value must be selected'; + return _requiredValueString; + } + + return null; + } +} + +class EditMappingDialog extends StatefulWidget { + const EditMappingDialog({ + super.key, + required this.mapping, + this.channel, + this.ccNumber, + this.mode, + this.presetId, + this.parameterId, + }); + + final MidiMappingEntry mapping; + + final int? channel; + final int? ccNumber; + final MidiMappingMode? mode; + final int? presetId; + final String? parameterId; + + @override + State createState() { + return EditMappingDialogState(); + } +} + +class EditMappingDialogState extends State { + final _formKey = GlobalKey(); + int? channel; + int? ccNumber; + MidiMappingMode? mode; + int? presetId; + String? parameterId; + + @override + void initState() { + super.initState(); + + // Let the widget specify an override for some settings, e.g. in case the + // dialog is shown in response to the user adjusting a dropdown and + // selecting something that requires more setup. + channel = widget.channel; + ccNumber = widget.ccNumber; + mode = widget.mode; + presetId = widget.presetId; + parameterId = widget.parameterId; + + // If no overrides were specified, copy the data from the existing mapping. + channel ??= widget.mapping.mapping.midiChannel; + ccNumber ??= widget.mapping.mapping.ccNumber; + mode ??= widget.mapping.mapping.mode; + presetId ??= switch (widget.mapping.mapping) { + MidiMappingToggle() => null, + MidiMappingParameter() => null, + MidiMappingButton(:final presetId) => presetId, + }; + parameterId ??= switch (widget.mapping.mapping) { + MidiMappingToggle(:final parameterId) => parameterId, + MidiMappingParameter(:final parameterId) => parameterId, + MidiMappingButton() => null, + }; + } + + @override + Widget build(BuildContext context) { + final parameters = context.read().parameterNames; + final presets = context.watch(); + final mappings = context.read(); + + return Dialog( + child: Padding( + padding: const EdgeInsets.all(8), + child: Form( + key: _formKey, + child: Wrap( + alignment: WrapAlignment.center, + runSpacing: 8, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text( + 'Edit MIDI mapping', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('MIDI channel'), + ), + items: List>.generate( + 16, + (i) => + DropdownMenuItem(value: i + 1, child: Text('${i + 1}')), + ), + value: channel, + onChanged: (value) => setState(() => channel = value), + validator: validateIsNotNull, + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('CC number'), + ), + items: List>.generate( + 16, + (i) => + DropdownMenuItem(value: i + 1, child: Text('${i + 1}')), + ), + value: ccNumber, + onChanged: (value) => setState(() => ccNumber = value), + validator: validateIsNotNull, + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('Mode'), + ), + items: List>.generate( + MidiMappingMode.values.length, + (i) => DropdownMenuItem( + value: MidiMappingMode.values[i], + child: Text(MidiMappingMode.values[i].uiName), + ), + ), + value: mode, + onChanged: (value) => setState(() => mode = value), + validator: validateIsNotNull, + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('Parameter'), + ), + items: [ + ...parameters.keys.map( + (id) => DropdownMenuItem( + value: id, + child: Text(parameters[id]!), + ), + ), + ], + value: parameterId, + onChanged: switch (mode) { + MidiMappingMode.toggle || + MidiMappingMode.parameter => + (value) => setState(() => parameterId = value), + null || MidiMappingMode.button => null, + }, + validator: (v) { + return switch (mode) { + null || MidiMappingMode.button => null, + MidiMappingMode.toggle || + MidiMappingMode.parameter => + v == null ? _requiredValueString : null, + }; + }, + ), + DropdownButtonFormField( + decoration: const InputDecoration( + label: Text('Preset'), + ), + items: switch (presets) { + LoadingPresetsState() => null, + ReadyPresetsState(:final presets) => presets.map( + (e) => DropdownMenuItem( + value: e.id, + child: Text(e.preset.name), + ), + ), + } + ?.toList(), + value: presetId, + onChanged: switch (mode) { + null || + MidiMappingMode.toggle || + MidiMappingMode.parameter => + null, + MidiMappingMode.button => (value) => + setState(() => presetId = value), + }, + validator: (v) { + return switch (mode) { + null || + MidiMappingMode.toggle || + MidiMappingMode.parameter => + null, + MidiMappingMode.button => + v == null ? _requiredValueString : null, + }; + }, + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.pop(context); + unawaited( + mappings.updateMapping( + MidiMappingEntry( + id: widget.mapping.id, + mapping: switch (mode) { + null => + throw StateError('Mode has not been selected'), + MidiMappingMode.toggle => MidiMapping.toggle( + midiChannel: channel!, + ccNumber: ccNumber!, + parameterId: parameterId!, + ), + MidiMappingMode.parameter => MidiMapping.parameter( + midiChannel: channel!, + ccNumber: ccNumber!, + parameterId: parameterId!, + ), + MidiMappingMode.button => MidiMapping.button( + midiChannel: channel!, + ccNumber: ccNumber!, + presetId: presetId!, + ), + }, + ), + ), + ); + } + }, + child: const Text('Update'), + ), + ], + ), + ), + ), + ); + } + + String? validateIsNotNull(T? value) { + if (value == null) { + return _requiredValueString; } return null; @@ -313,7 +736,7 @@ class ModeDropdown extends StatelessWidget { }); final MidiMappingMode value; - final void Function(MidiMappingMode?) onChanged; + final void Function(MidiMappingMode?)? onChanged; @override Widget build(BuildContext context) { @@ -360,3 +783,49 @@ class ParametersDropdown extends StatelessWidget { ); } } + +class PresetsDropdown extends StatelessWidget { + const PresetsDropdown({ + required this.value, + required this.onChanged, + super.key, + }); + + final int value; + final void Function(int?) onChanged; + + @override + Widget build(BuildContext context) { + final presets = switch (context.watch()) { + ReadyPresetsState(:final presets) => presets, + LoadingPresetsState() => [], + } + .map((e) => (e.id, e.preset.name)) + .toList(); + + if (presets.every((element) => element.$1 != value)) { + presets.add((value, '')); + } + + return DropdownButton( + items: [ + ...presets.map( + (preset) => DropdownMenuItem( + value: preset.$1, + child: Text(preset.$2), + ), + ), + ], + onChanged: onChanged, + value: value, + ); + } +} + +extension on MidiMapping { + MidiMappingMode get mode => switch (this) { + MidiMappingToggle() => MidiMappingMode.toggle, + MidiMappingParameter() => MidiMappingMode.parameter, + MidiMappingButton() => MidiMappingMode.button, + }; +} diff --git a/frontend/lib/presets/model/presets.dart b/frontend/lib/presets/model/presets.dart index c5d6b2cf..41383be6 100644 --- a/frontend/lib/presets/model/presets.dart +++ b/frontend/lib/presets/model/presets.dart @@ -65,15 +65,15 @@ class PresetState with _$PresetState { } @freezed -class PresetsState with _$PresetsState { - factory PresetsState.loading() = Loading; +sealed class PresetsState with _$PresetsState { + factory PresetsState.loading() = LoadingPresetsState; factory PresetsState.ready({ required bool isCurrentModified, required bool canUndo, required List presets, required int? selectedPreset, - }) = _PresetsState; + }) = ReadyPresetsState; PresetsState._(); } diff --git a/frontend/test/midi_mapping/model/service_test.dart b/frontend/test/midi_mapping/model/service_test.dart index 2643de28..06318626 100644 --- a/frontend/test/midi_mapping/model/service_test.dart +++ b/frontend/test/midi_mapping/model/service_test.dart @@ -50,11 +50,10 @@ void main() { const response = MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'gain', - mode: MidiMappingMode.parameter, ), ), ); @@ -83,10 +82,9 @@ void main() { expect(outputMessages.removeLast(), request); expect(outputMessages, isEmpty); expect(uut.mappings, { - 123: const MidiMapping( + 123: const MidiMapping.parameter( midiChannel: 1, ccNumber: 2, - mode: MidiMappingMode.parameter, parameterId: 'gain', ), }); @@ -120,20 +118,18 @@ void main() { const response = MidiApiMessage.createResponse( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, - mode: MidiMappingMode.parameter, parameterId: 'gain', ), ), ); const request = MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, - mode: MidiMappingMode.parameter, parameterId: 'gain', ), ); @@ -154,10 +150,9 @@ void main() { expect(uut.mappings, isEmpty); await uut.createMapping( - const MidiMapping( + const MidiMapping.parameter( midiChannel: 1, ccNumber: 2, - mode: MidiMappingMode.parameter, parameterId: 'gain', ), ); diff --git a/frontend/test/midi_mapping/view/midi_mapping_test.dart b/frontend/test/midi_mapping/view/midi_mapping_test.dart index d48f49da..8d13e216 100644 --- a/frontend/test/midi_mapping/view/midi_mapping_test.dart +++ b/frontend/test/midi_mapping/view/midi_mapping_test.dart @@ -63,11 +63,10 @@ void main() { const createRequest = ApiMessage.midiMapping( message: MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'chorusDepth', - mode: MidiMappingMode.parameter, ), ), ); @@ -79,11 +78,10 @@ void main() { message: MidiApiMessage.createResponse( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'chorusDepth', - mode: MidiMappingMode.parameter, ), ), ), @@ -139,11 +137,10 @@ void main() { const createRequest = ApiMessage.midiMapping( message: MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'chorusDepth', - mode: MidiMappingMode.parameter, ), ), ); @@ -200,11 +197,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'ampGain', - mode: MidiMappingMode.parameter, ), ), ), @@ -235,11 +231,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 3, ccNumber: 2, parameterId: 'ampGain', - mode: MidiMappingMode.parameter, ), ), ), @@ -254,11 +249,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 3, ccNumber: 4, parameterId: 'ampGain', - mode: MidiMappingMode.parameter, ), ), ), @@ -276,11 +270,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.toggle( midiChannel: 3, ccNumber: 4, parameterId: 'ampGain', - mode: MidiMappingMode.toggle, ), ), ), @@ -298,11 +291,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.toggle( midiChannel: 3, ccNumber: 4, parameterId: 'contour', - mode: MidiMappingMode.toggle, ), ), ), @@ -338,11 +330,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'ampGain', - mode: MidiMappingMode.parameter, ), ), ), @@ -419,11 +410,10 @@ void main() { const createRequest = ApiMessage.midiMapping( message: MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ); @@ -435,11 +425,10 @@ void main() { message: MidiApiMessage.createResponse( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ), @@ -501,11 +490,10 @@ void main() { message: MidiApiMessage.update( mapping: MidiMappingEntry( id: 456, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 3, ccNumber: 4, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ), @@ -539,11 +527,10 @@ void main() { const createRequest = ApiMessage.midiMapping( message: MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ); @@ -555,11 +542,10 @@ void main() { message: MidiApiMessage.createResponse( mapping: MidiMappingEntry( id: 123, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 1, ccNumber: 2, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ), @@ -606,11 +592,10 @@ void main() { const createRequest2 = ApiMessage.midiMapping( message: MidiApiMessage.createRequest( - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 3, ccNumber: 4, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ); @@ -622,11 +607,10 @@ void main() { message: MidiApiMessage.createResponse( mapping: MidiMappingEntry( id: 456, - mapping: MidiMapping( + mapping: MidiMapping.parameter( midiChannel: 3, ccNumber: 4, parameterId: 'volume', - mode: MidiMappingMode.parameter, ), ), ), diff --git a/proto/midi_mapping.options b/proto/midi_mapping.options index ac58d042..d0675511 100644 --- a/proto/midi_mapping.options +++ b/proto/midi_mapping.options @@ -1,3 +1,3 @@ # nanopb-specific options for the messages defined in midi_mapping.proto. -shrapnel.midi_mapping.Mapping.parameterName max_size:32 +shrapnel.midi_mapping.Mapping.parameter_name max_size:32 diff --git a/proto/midi_mapping.proto b/proto/midi_mapping.proto index 6940359c..d8fc773d 100644 --- a/proto/midi_mapping.proto +++ b/proto/midi_mapping.proto @@ -6,12 +6,14 @@ message Mapping { enum Mode { parameter = 0; toggle = 1; + button = 2; } uint32 midi_channel = 1; uint32 cc_number = 2; Mode mode = 3; - string parameterName = 4; + string parameter_name = 4; + uint32 preset_id = 5; } // A MIDI message that was received by the firmware diff --git a/test/.idea/misc.xml b/test/.idea/misc.xml index 4c018bce..048657bc 100644 --- a/test/.idea/misc.xml +++ b/test/.idea/misc.xml @@ -13,6 +13,7 @@ + \ No newline at end of file diff --git a/thirdparty/esp-idf-components/etl/include/etl_utility.h b/thirdparty/esp-idf-components/etl/include/etl_utility.h index 78fe1d39..02788e5d 100644 --- a/thirdparty/esp-idf-components/etl/include/etl_utility.h +++ b/thirdparty/esp-idf-components/etl/include/etl_utility.h @@ -3,6 +3,7 @@ #include "etl/string_stream.h" #include #include +#include namespace etl { @@ -25,4 +26,20 @@ etl::string_stream& operator<<(etl::string_stream& out, const std::array +etl::string_stream &operator<<(etl::string_stream &out, + const std::optional &self) +{ + out << "optional "; + if(self.has_value()) + { + out << "with value " << *self; + } + else + { + out << "with no value"; + } + return out; +} + }