From 1a13d6b0e17c85bcff4e7fe21c8bfbc596dd43f8 Mon Sep 17 00:00:00 2001 From: khodowany Date: Thu, 10 Sep 2026 09:22:26 -0500 Subject: [PATCH] Cleanup --- CMakeFiles/CMakeSystem.cmake | 15 +++ cmake/zero.cmake | 5 +- conanfile.py | 6 + include/vamp_zero/audio.hpp | 37 +++++++ include/vamp_zero/config.hpp | 15 +++ include/vamp_zero/types.hpp | 11 ++ package/config/vamp.example.json | 12 ++ package/zero/Dockerfile | 1 + src/vamp_zero/app.cpp | 41 ++++++- src/vamp_zero/audio.cpp | 108 ++++++++++++++++++ src/vamp_zero/config.cpp | 100 ++++++++++++++++- src/vamp_zero/page_control.cpp | 80 +++++++++++++- src/vamp_zero/ui.cpp | 7 ++ tests/test.cpp | 184 +++++++++++++++++++++++++++++++ 14 files changed, 609 insertions(+), 13 deletions(-) create mode 100644 CMakeFiles/CMakeSystem.cmake create mode 100644 include/vamp_zero/audio.hpp create mode 100644 src/vamp_zero/audio.cpp diff --git a/CMakeFiles/CMakeSystem.cmake b/CMakeFiles/CMakeSystem.cmake new file mode 100644 index 0000000..72f2370 --- /dev/null +++ b/CMakeFiles/CMakeSystem.cmake @@ -0,0 +1,15 @@ +set(CMAKE_HOST_SYSTEM "Linux-7.1.9-arch1-2") +set(CMAKE_HOST_SYSTEM_NAME "Linux") +set(CMAKE_HOST_SYSTEM_VERSION "7.1.9-arch1-2") +set(CMAKE_HOST_SYSTEM_PROCESSOR "x86_64") + + + +set(CMAKE_SYSTEM "Linux-7.1.9-arch1-2") +set(CMAKE_SYSTEM_NAME "Linux") +set(CMAKE_SYSTEM_VERSION "7.1.9-arch1-2") +set(CMAKE_SYSTEM_PROCESSOR "x86_64") + +set(CMAKE_CROSSCOMPILING "FALSE") + +set(CMAKE_SYSTEM_LOADED 1) diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 099df59..3238324 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -5,11 +5,12 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -find_package(modulus CONFIG REQUIRED) +find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl) find_package(Threads REQUIRED) find_package(ligo CONFIG REQUIRED) find_package(noto CONFIG REQUIRED) find_package(luma CONFIG REQUIRED) +find_package(sonus CONFIG REQUIRED) find_package(Python3 COMPONENTS Interpreter REQUIRED) execute_process( @@ -52,6 +53,7 @@ target_link_libraries(vamp-protocol PUBLIC set(VAMP_CORE_SOURCES src/vamp_zero/app.cpp + src/vamp_zero/audio.cpp src/vamp_zero/color.cpp src/vamp_zero/config.cpp src/vamp_zero/database.cpp @@ -84,6 +86,7 @@ target_link_libraries(vamp-zero-core PUBLIC ligo::ligo noto::noto luma::luma + sonus::sonus ) add_executable(vamp-zero src/vamp_zero/main.cpp) diff --git a/conanfile.py b/conanfile.py index e746a8e..765691a 100644 --- a/conanfile.py +++ b/conanfile.py @@ -57,11 +57,17 @@ class product_vamp_conan(conan.ConanFile): str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, + options={ + "with_openssl": True, + "with_sqlite": True, + "with_raylib": True, + }, ) for reference in ( "ligo/latest", "noto/latest", "luma/latest", + "sonus/latest", ): self.requires( reference, diff --git a/include/vamp_zero/audio.hpp b/include/vamp_zero/audio.hpp new file mode 100644 index 0000000..1d3f9f9 --- /dev/null +++ b/include/vamp_zero/audio.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace vamp_zero { + +class audio_controller { +public: + audio_controller(vamp_zero::audio_config config, sonus::engine engine); + audio_controller(vamp_zero::audio_controller const&) = delete; + auto operator=(vamp_zero::audio_controller const&) -> vamp_zero::audio_controller& = delete; + ~audio_controller(); + + auto sync(vamp_zero::app_state const& state) -> void; + +private: + auto make_metronome_config(vamp_zero::app_state const& state) const -> sonus::metronome_config; + + vamp_zero::audio_config config_{}; + sonus::engine engine_; + mutable std::mutex mutex_{}; + std::bool_t metronome_running_{false}; + std::uint32_t last_metronome_bpm_{}; +}; + +auto create_audio_controller(vamp_zero::audio_config const& config) -> std::expected, modulus::error>; + +} diff --git a/include/vamp_zero/config.hpp b/include/vamp_zero/config.hpp index 745638e..523becb 100644 --- a/include/vamp_zero/config.hpp +++ b/include/vamp_zero/config.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -44,11 +45,25 @@ struct control_config { std::string zero_cidr{"192.168.42.1/24"}; }; +struct audio_config { + std::bool_t enabled{true}; + std::bool_t track_enabled{false}; + std::bool_t metronome_enabled{false}; + std::uint32_t metronome_bpm{120}; + std::uint32_t metronome_min_bpm{40}; + std::uint32_t metronome_max_bpm{240}; + std::uint32_t metronome_step{5}; + std::uint32_t beats_per_bar{4}; + std::float32_t master_volume{0.70F}; + std::float32_t metronome_volume{0.45F}; +}; + struct config { modulus::config modulus{}; vamp_zero::runtime_config runtime{}; vamp_zero::database_config database{}; vamp_zero::control_config control{}; + vamp_zero::audio_config audio{}; luma::application_config display{.title = "Product Vamp"}; }; diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index ac8a23b..31985a0 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -142,10 +142,21 @@ enum class app_mode { PRACTICE_SCORE, }; +struct audio_state { + std::bool_t track_enabled{false}; + std::bool_t metronome_enabled{false}; + std::uint32_t metronome_bpm{120}; + std::uint32_t metronome_min_bpm{40}; + std::uint32_t metronome_max_bpm{240}; + std::uint32_t metronome_step{5}; + std::bool_t tempo_editing{false}; +}; + struct app_state { vamp_zero::library_state performance{}; vamp_zero::practice_library practice{}; vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU}; + vamp_zero::audio_state audio{}; std::size_t selected_main_menu_index{}; std::size_t selected_song_index{}; std::size_t selected_part_index{}; diff --git a/package/config/vamp.example.json b/package/config/vamp.example.json index 9343ccc..f605289 100644 --- a/package/config/vamp.example.json +++ b/package/config/vamp.example.json @@ -36,6 +36,18 @@ "zero_ip": "192.168.42.1", "zero_cidr": "192.168.42.1/24" }, + "audio": { + "enabled": true, + "track_enabled": false, + "metronome_enabled": false, + "metronome_bpm": 120, + "metronome_min_bpm": 40, + "metronome_max_bpm": 240, + "metronome_step": 5, + "beats_per_bar": 4, + "master_volume": 0.7, + "metronome_volume": 0.45 + }, "display": { "title": "Product Vamp", "display": { diff --git a/package/zero/Dockerfile b/package/zero/Dockerfile index b865cdc..f63c523 100644 --- a/package/zero/Dockerfile +++ b/package/zero/Dockerfile @@ -5,6 +5,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ + libasound2t64 \ libdrm2 \ libegl1 \ libgbm1 \ diff --git a/src/vamp_zero/app.cpp b/src/vamp_zero/app.cpp index 3fa9e99..3ec1781 100644 --- a/src/vamp_zero/app.cpp +++ b/src/vamp_zero/app.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include #include +#include #include #include #include @@ -129,6 +131,14 @@ auto load_initial_state(vamp_zero::config const& config) -> std::expected std::expected std::expected { +auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& state, vamp_zero::audio_controller* audio) -> std::expected { auto io = boost::asio::io_context{}; auto endpoint = make_server_endpoint(config); if (!endpoint) { @@ -147,6 +157,12 @@ auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& sta if (!started) { return std::unexpected(started.error()); } + if (audio != nullptr) { + state.set_observer([audio](vamp_zero::app_state app) { + audio->sync(app); + }); + audio->sync(state.snapshot()); + } auto heartbeat = write_heartbeat(config); if (!heartbeat) { server.stop(); @@ -162,7 +178,7 @@ auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& sta return {}; } -auto run_display(vamp_zero::config const& config, vamp_zero::control_state& state) -> std::expected { +auto run_display(vamp_zero::config const& config, vamp_zero::control_state& state, vamp_zero::audio_controller* audio) -> std::expected { auto io = boost::asio::io_context{}; auto endpoint = make_server_endpoint(config); if (!endpoint) { @@ -171,12 +187,18 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, [&state](vamp_protocol::page_command command) { static_cast(state.apply(command)); }}; - state.set_observer([&ui, &config](vamp_zero::app_state app) { + state.set_observer([&ui, &config, audio](vamp_zero::app_state app) { + if (audio != nullptr) { + audio->sync(app); + } auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app))); if (!status) { modulus::log_error("failed to publish UI state: " + status.error().describe()); } }); + if (audio != nullptr) { + audio->sync(state.snapshot()); + } auto published = ui.publish(vamp_zero::make_ui_state(config, state.snapshot())); if (!published) { return std::unexpected(published.error()); @@ -316,8 +338,15 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expectedruntime.headless) { - return run_headless(*config, state); + auto audio_controller = std::unique_ptr{}; + auto audio = vamp_zero::create_audio_controller(config->audio); + if (!audio) { + modulus::log_error("audio unavailable: " + audio.error().describe()); + } else { + audio_controller = std::move(*audio); } - return run_display(*config, state); + if (config->runtime.headless) { + return run_headless(*config, state, audio_controller.get()); + } + return run_display(*config, state, audio_controller.get()); } diff --git a/src/vamp_zero/audio.cpp b/src/vamp_zero/audio.cpp new file mode 100644 index 0000000..580f13d --- /dev/null +++ b/src/vamp_zero/audio.cpp @@ -0,0 +1,108 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace { + +auto score_active(vamp_zero::app_state const& state) -> std::bool_t { + if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { + return vamp_zero::active_part(state) != nullptr; + } + if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { + auto const* exercise = vamp_zero::active_practice(state); + return exercise != nullptr && vamp_zero::selected_practice_variant(state, *exercise) != nullptr; + } + return false; +} + +auto log_audio_error(std::string const& action, modulus::error const& error) -> void { + modulus::log_error(action + ": " + error.describe()); +} + +} + +vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, sonus::engine engine) + : config_{config}, engine_{std::move(engine)} {} + +vamp_zero::audio_controller::~audio_controller() { + auto lock = std::scoped_lock{mutex_}; + engine_.stop_metronome(); + engine_.stop_all(); + engine_.stop(); +} + +auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state const& state) const -> sonus::metronome_config { + auto result = sonus::metronome_config{}; + result.bpm = state.audio.metronome_bpm; + result.beats_per_bar = config_.beats_per_bar; + result.volume = config_.metronome_volume; + return result; +} + +auto vamp_zero::audio_controller::sync(vamp_zero::app_state const& state) -> void { + auto lock = std::scoped_lock{mutex_}; + auto const should_run_metronome = state.audio.metronome_enabled && score_active(state); + if (!should_run_metronome) { + if (metronome_running_) { + engine_.stop_metronome(); + metronome_running_ = false; + } + return; + } + + auto metronome = make_metronome_config(state); + if (!metronome_running_) { + auto started = engine_.start_metronome(metronome); + if (!started) { + log_audio_error("failed to start metronome", started.error()); + engine_.stop_metronome(); + metronome_running_ = false; + return; + } + metronome_running_ = true; + last_metronome_bpm_ = metronome.bpm; + return; + } + + if (last_metronome_bpm_ != metronome.bpm) { + auto updated = engine_.update_metronome(metronome); + if (!updated) { + log_audio_error("failed to update metronome", updated.error()); + engine_.stop_metronome(); + metronome_running_ = false; + return; + } + last_metronome_bpm_ = metronome.bpm; + } +} + +auto vamp_zero::create_audio_controller(vamp_zero::audio_config const& config) -> std::expected, modulus::error> { + if (!config.enabled) { + return nullptr; + } + + auto sonus_config = sonus::audio_config{}; + sonus_config.sample_rate = 48000; + sonus_config.channels = 2; + sonus_config.master_volume = config.master_volume; + + auto engine = sonus::engine::create(sonus_config); + if (!engine) { + return std::unexpected(engine.error()); + } + auto started = engine->start(); + if (!started) { + return std::unexpected(started.error()); + } + + return std::make_unique(config, std::move(*engine)); +} diff --git a/src/vamp_zero/config.cpp b/src/vamp_zero/config.cpp index 86c8393..ec96a5f 100644 --- a/src/vamp_zero/config.cpp +++ b/src/vamp_zero/config.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -68,6 +69,50 @@ auto optional_bool(boost::json::object const& object, std::string_view field, st return found->value().as_bool(); } +auto optional_uint32(boost::json::object const& object, std::string_view field, std::uint32_t fallback, std::uint32_t min_value, std::uint32_t max_value) -> std::expected { + auto found = object.find(field); + if (found == object.end()) { + return fallback; + } + auto value = std::uint64_t{}; + if (found->value().is_uint64()) { + value = found->value().as_uint64(); + } else if (found->value().is_int64()) { + auto signed_value = found->value().as_int64(); + if (signed_value < 0) { + return std::unexpected(invalid(std::string{field} + " must not be negative")); + } + value = static_cast(signed_value); + } else { + return std::unexpected(invalid(std::string{field} + " must be an integer")); + } + if (value < min_value || value > max_value) { + return std::unexpected(invalid(std::string{field} + " is out of range")); + } + return static_cast(value); +} + +auto optional_float(boost::json::object const& object, std::string_view field, std::float32_t fallback, std::float32_t min_value, std::float32_t max_value) -> std::expected { + auto found = object.find(field); + if (found == object.end()) { + return fallback; + } + auto value = std::float64_t{}; + if (found->value().is_double()) { + value = found->value().as_double(); + } else if (found->value().is_uint64()) { + value = static_cast(found->value().as_uint64()); + } else if (found->value().is_int64()) { + value = static_cast(found->value().as_int64()); + } else { + return std::unexpected(invalid(std::string{field} + " must be a number")); + } + if (!std::isfinite(value) || value < min_value || value > max_value) { + return std::unexpected(invalid(std::string{field} + " is out of range")); + } + return static_cast(value); +} + auto parse_hardware(std::string const& value) -> std::expected { if (value == "fake") { return vamp_zero::hardware_mode::FAKE; @@ -162,6 +207,55 @@ auto parse_control(boost::json::object const& root, vamp_zero::control_config fa return result; } +auto parse_audio(boost::json::object const& root, vamp_zero::audio_config fallback) -> std::expected { + auto object = optional_object(root, "audio"); + if (!object) { + return std::unexpected(object.error()); + } + if (*object == nullptr) { + return fallback; + } + auto enabled = optional_bool(**object, "enabled", fallback.enabled); + auto track_enabled = optional_bool(**object, "track_enabled", fallback.track_enabled); + auto metronome_enabled = optional_bool(**object, "metronome_enabled", fallback.metronome_enabled); + auto metronome_bpm = optional_uint32(**object, "metronome_bpm", fallback.metronome_bpm, 1U, 400U); + auto metronome_min_bpm = optional_uint32(**object, "metronome_min_bpm", fallback.metronome_min_bpm, 1U, 400U); + auto metronome_max_bpm = optional_uint32(**object, "metronome_max_bpm", fallback.metronome_max_bpm, 1U, 400U); + auto metronome_step = optional_uint32(**object, "metronome_step", fallback.metronome_step, 1U, 100U); + auto beats_per_bar = optional_uint32(**object, "beats_per_bar", fallback.beats_per_bar, 1U, 16U); + auto master_volume = optional_float(**object, "master_volume", fallback.master_volume, 0.0F, 1.0F); + auto metronome_volume = optional_float(**object, "metronome_volume", fallback.metronome_volume, 0.0F, 1.0F); + if (!enabled || !track_enabled || !metronome_enabled || !metronome_bpm || !metronome_min_bpm || !metronome_max_bpm || !metronome_step || !beats_per_bar || !master_volume || !metronome_volume) { + return std::unexpected(!enabled ? enabled.error() + : (!track_enabled ? track_enabled.error() + : (!metronome_enabled ? metronome_enabled.error() + : (!metronome_bpm ? metronome_bpm.error() + : (!metronome_min_bpm ? metronome_min_bpm.error() + : (!metronome_max_bpm ? metronome_max_bpm.error() + : (!metronome_step ? metronome_step.error() + : (!beats_per_bar ? beats_per_bar.error() + : (!master_volume ? master_volume.error() : metronome_volume.error()))))))))); + } + if (*metronome_min_bpm > *metronome_max_bpm) { + return std::unexpected(invalid("audio.metronome_min_bpm must be less than or equal to audio.metronome_max_bpm")); + } + if (*metronome_bpm < *metronome_min_bpm || *metronome_bpm > *metronome_max_bpm) { + return std::unexpected(invalid("audio.metronome_bpm must be within the configured metronome range")); + } + return vamp_zero::audio_config{ + .enabled = *enabled, + .track_enabled = *track_enabled, + .metronome_enabled = *metronome_enabled, + .metronome_bpm = *metronome_bpm, + .metronome_min_bpm = *metronome_min_bpm, + .metronome_max_bpm = *metronome_max_bpm, + .metronome_step = *metronome_step, + .beats_per_bar = *beats_per_bar, + .master_volume = *master_volume, + .metronome_volume = *metronome_volume, + }; +} + } auto vamp_zero::parse_config(boost::json::value const& value) -> std::expected { @@ -180,12 +274,14 @@ auto vamp_zero::parse_config(boost::json::value const& value) -> std::expectedfind("display"); found != (*root)->end()) { auto display = luma::parse_application_config(found->value()); if (!display) { diff --git a/src/vamp_zero/page_control.cpp b/src/vamp_zero/page_control.cpp index e0eb853..30278e5 100644 --- a/src/vamp_zero/page_control.cpp +++ b/src/vamp_zero/page_control.cpp @@ -18,7 +18,12 @@ namespace { -constexpr auto main_menu_count = std::size_t{2}; +constexpr auto main_menu_performance = std::size_t{0}; +constexpr auto main_menu_practice = std::size_t{1}; +constexpr auto main_menu_track = std::size_t{2}; +constexpr auto main_menu_metronome = std::size_t{3}; +constexpr auto main_menu_tempo = std::size_t{4}; +constexpr auto main_menu_count = std::size_t{5}; auto wrap_next(std::size_t value, std::size_t count) -> std::size_t { if (count == 0U) { @@ -38,7 +43,28 @@ auto menu_row_count(std::size_t item_count) -> std::size_t { return item_count + 1U; } +auto clamp_audio_state(vamp_zero::audio_state audio) -> vamp_zero::audio_state { + audio.metronome_min_bpm = std::clamp(audio.metronome_min_bpm, 1U, 400U); + audio.metronome_max_bpm = std::clamp(audio.metronome_max_bpm, audio.metronome_min_bpm, 400U); + audio.metronome_step = std::clamp(audio.metronome_step, 1U, 100U); + audio.metronome_bpm = std::clamp(audio.metronome_bpm, audio.metronome_min_bpm, audio.metronome_max_bpm); + return audio; +} + +auto increase_bpm(vamp_zero::audio_state& audio) -> void { + audio.metronome_bpm = audio.metronome_bpm > audio.metronome_max_bpm - std::min(audio.metronome_step, audio.metronome_max_bpm) + ? audio.metronome_max_bpm + : audio.metronome_bpm + audio.metronome_step; +} + +auto decrease_bpm(vamp_zero::audio_state& audio) -> void { + audio.metronome_bpm = audio.metronome_bpm < audio.metronome_min_bpm + audio.metronome_step + ? audio.metronome_min_bpm + : audio.metronome_bpm - audio.metronome_step; +} + auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { + state.audio = clamp_audio_state(state.audio); state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U); state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U); if (auto const* group = selected_group(state); group != nullptr) { @@ -96,6 +122,10 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { } } + if (state.mode != vamp_zero::app_mode::MAIN_MENU) { + state.audio.tempo_editing = false; + } + if (auto pages = active_pages(state); pages != 0U) { state.page_index = std::min(state.page_index, pages - 1U); } else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && state.mode != vamp_zero::app_mode::PRACTICE_SCORE) { @@ -114,6 +144,18 @@ auto command_json(vamp_zero::app_state const& state) -> boost::json::object { return commands; } +auto audio_json(vamp_zero::app_state const& state) -> boost::json::object { + auto audio = boost::json::object{}; + audio["track_enabled"] = state.audio.track_enabled; + audio["metronome_enabled"] = state.audio.metronome_enabled; + audio["metronome_bpm"] = static_cast(state.audio.metronome_bpm); + audio["metronome_min_bpm"] = static_cast(state.audio.metronome_min_bpm); + audio["metronome_max_bpm"] = static_cast(state.audio.metronome_max_bpm); + audio["metronome_step"] = static_cast(state.audio.metronome_step); + audio["tempo_editing"] = state.audio.tempo_editing; + return audio; +} + auto part_json(vamp_zero::library_part const& part) -> boost::json::object { auto result = boost::json::object{}; result["id"] = part.id; @@ -291,17 +333,25 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag switch (state.mode) { case vamp_zero::app_mode::MAIN_MENU: - if (command == vamp_protocol::page_command::NEXT) { + if (state.audio.tempo_editing) { + if (command == vamp_protocol::page_command::NEXT) { + increase_bpm(state.audio); + } else if (command == vamp_protocol::page_command::PREVIOUS) { + decrease_bpm(state.audio); + } else { + state.audio.tempo_editing = false; + } + } else if (command == vamp_protocol::page_command::NEXT) { state.selected_main_menu_index = wrap_next(state.selected_main_menu_index, main_menu_count); } else if (command == vamp_protocol::page_command::PREVIOUS) { state.selected_main_menu_index = wrap_previous(state.selected_main_menu_index, main_menu_count); - } else if (state.selected_main_menu_index == 0U) { + } else if (state.selected_main_menu_index == main_menu_performance) { state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU; state.selected_song_index = 0; state.selected_part_index = 0; state.active_part_id.clear(); state.page_index = 0; - } else { + } else if (state.selected_main_menu_index == main_menu_practice) { state.mode = vamp_zero::app_mode::PRACTICE_TYPE_MENU; state.selected_practice_type_index = 0; state.selected_practice_lesson_index = 0; @@ -309,6 +359,12 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.selected_practice_key_index = 0; state.active_practice_id.clear(); state.page_index = 0; + } else if (state.selected_main_menu_index == main_menu_track) { + state.audio.track_enabled = !state.audio.track_enabled; + } else if (state.selected_main_menu_index == main_menu_metronome) { + state.audio.metronome_enabled = !state.audio.metronome_enabled; + } else if (state.selected_main_menu_index == main_menu_tempo) { + state.audio.tempo_editing = true; } return clamp_state(std::move(state)); @@ -523,6 +579,7 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::jso root["practice"] = practice_json(state); root["selection"] = std::move(selection); root["active"] = std::move(active); + root["audio"] = audio_json(state); root["commands"] = command_json(state); root["remote"] = std::move(remote); return root; @@ -567,6 +624,21 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::ex if (state.selected_main_menu_index >= main_menu_count) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range")); } + if (state.audio.metronome_min_bpm == 0U || state.audio.metronome_min_bpm > 400U) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome minimum BPM is out of range")); + } + if (state.audio.metronome_max_bpm < state.audio.metronome_min_bpm || state.audio.metronome_max_bpm > 400U) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome maximum BPM is out of range")); + } + if (state.audio.metronome_step == 0U || state.audio.metronome_step > 100U) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM step is out of range")); + } + if (state.audio.metronome_bpm < state.audio.metronome_min_bpm || state.audio.metronome_bpm > state.audio.metronome_max_bpm) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM is out of range")); + } + if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::MAIN_MENU) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires the main menu")); + } if (state.selected_song_index >= menu_row_count(state.performance.groups.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range")); } diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 381d840..117c4c7 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -150,6 +150,13 @@ auto draw_main_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::a auto row_width = bounds.width - 72.0F; draw_row(context, {.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height}, "Performance Mode", std::to_string(state.performance.groups.size()) + " SONGS", state.selected_main_menu_index == 0U); draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height}, "Practice Mode", std::to_string(state.practice.exercises.size()) + " EXERCISES", state.selected_main_menu_index == 1U); + draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height}, "Track", state.audio.track_enabled ? "ON" : "OFF", state.selected_main_menu_index == 2U); + draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height}, "Metronome", state.audio.metronome_enabled ? "ON" : "OFF", state.selected_main_menu_index == 3U); + auto tempo = std::to_string(state.audio.metronome_bpm) + " BPM"; + if (state.audio.tempo_editing) { + tempo = "EDIT " + tempo; + } + draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 4.0F, .width = row_width, .height = row_height}, "Tempo", tempo, state.selected_main_menu_index == 4U); } auto draw_performance_song_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { diff --git a/tests/test.cpp b/tests/test.cpp index a2cc867..4a3fe80 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -22,12 +22,17 @@ #include #include #include +#include +#include #include +#include +#include #include #include #include +#include #include #include @@ -311,6 +316,65 @@ auto score_asset_json(std::string title, std::string key = "C") -> std::string { )json"; } +auto navigation_state() -> vamp_zero::app_state { + auto part = vamp_zero::library_part{ + .id = "part-lead", + .title = "Navigation Tune", + .composer = "Composer", + .key_signature = "C", + .part = "Lead", + .source_path = "navigation.json", + .source_json = score_asset_json("Navigation Tune"), + .measure_count = 1, + .total_pages = 2, + }; + auto variant = vamp_zero::practice_exercise::score_variant{ + .id = "exercise-c", + .key_signature = "C", + .source_path = "exercise.json", + .source_json = score_asset_json("Exercise"), + .measure_count = 1, + .total_pages = 1, + }; + auto exercise = vamp_zero::practice_exercise{ + .id = "exercise-1", + .practice_type_id = "scale", + .practice_type_title = "Scales", + .lesson_group_id = "lesson-1", + .lesson_group_title = "Major", + .title = "C Major", + .composer = "Composer", + .key_signature = "C", + .source_path = "exercise.json", + .source_json = score_asset_json("Exercise"), + .measure_count = 1, + .total_pages = 1, + .variants = {variant}, + }; + return vamp_zero::app_state{ + .performance = { + .parts = {part}, + .groups = {{ + .title = part.title, + .composer = part.composer, + .parts = {part}, + }}, + }, + .practice = { + .exercises = {exercise}, + .types = {{ + .id = "scale", + .title = "Scales", + .lesson_groups = {{ + .id = "lesson-1", + .title = "Major", + .exercises = {exercise}, + }}, + }}, + }, + }; +} + void write_text(std::filesystem::path const& path, std::string const& text) { std::filesystem::create_directories(path.parent_path()); auto output = std::ofstream{path, std::ios::binary | std::ios::trunc}; @@ -319,6 +383,126 @@ void write_text(std::filesystem::path const& path, std::string const& text) { } +BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) { + auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json")); + BOOST_REQUIRE(defaults); + BOOST_TEST(defaults->audio.enabled); + BOOST_TEST(!defaults->audio.track_enabled); + BOOST_TEST(!defaults->audio.metronome_enabled); + BOOST_TEST(defaults->audio.metronome_bpm == 120U); + BOOST_TEST(defaults->audio.metronome_min_bpm == 40U); + BOOST_TEST(defaults->audio.metronome_max_bpm == 240U); + BOOST_TEST(defaults->audio.metronome_step == 5U); + + auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ + "audio": { + "enabled": false, + "track_enabled": true, + "metronome_enabled": true, + "metronome_bpm": 90, + "metronome_min_bpm": 50, + "metronome_max_bpm": 180, + "metronome_step": 10, + "beats_per_bar": 3, + "master_volume": 0.5, + "metronome_volume": 0.25 + } + })json")); + BOOST_REQUIRE(parsed); + BOOST_TEST(!parsed->audio.enabled); + BOOST_TEST(parsed->audio.track_enabled); + BOOST_TEST(parsed->audio.metronome_enabled); + BOOST_TEST(parsed->audio.metronome_bpm == 90U); + BOOST_TEST(parsed->audio.metronome_min_bpm == 50U); + BOOST_TEST(parsed->audio.metronome_max_bpm == 180U); + BOOST_TEST(parsed->audio.metronome_step == 10U); + BOOST_TEST(parsed->audio.beats_per_bar == 3U); + BOOST_TEST(parsed->audio.master_volume == 0.5F); + BOOST_TEST(parsed->audio.metronome_volume == 0.25F); +} + +BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) { + auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ + "audio": { + "metronome_bpm": 120, + "metronome_min_bpm": 160, + "metronome_max_bpm": 80 + } + })json")); + BOOST_TEST(!parsed); +} + +BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) { + auto state = navigation_state(); + + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + BOOST_TEST(state.selected_main_menu_index == 2U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(state.audio.track_enabled); + BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); + + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + BOOST_TEST(state.selected_main_menu_index == 3U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(state.audio.metronome_enabled); + BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); + + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + BOOST_TEST(state.selected_main_menu_index == 4U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(state.audio.tempo_editing); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + BOOST_TEST(state.audio.metronome_bpm == 125U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); + BOOST_TEST(state.audio.metronome_bpm == 120U); + for (auto index = 0; index < 40; ++index) { + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); + } + BOOST_TEST(state.audio.metronome_bpm == 40U); + for (auto index = 0; index < 80; ++index) { + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + } + BOOST_TEST(state.audio.metronome_bpm == 240U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(!state.audio.tempo_editing); +} + +BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) { + auto state = navigation_state(); + state.audio.track_enabled = true; + state.audio.metronome_enabled = true; + state.audio.metronome_bpm = 135; + state.audio.tempo_editing = true; + + auto status = vamp_zero::app_status_json(state).as_object(); + auto const& audio = status.at("audio").as_object(); + BOOST_TEST(audio.at("track_enabled").as_bool()); + BOOST_TEST(audio.at("metronome_enabled").as_bool()); + BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U); + BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U); + BOOST_TEST(audio.at("metronome_max_bpm").as_uint64() == 240U); + BOOST_TEST(audio.at("metronome_step").as_uint64() == 5U); + BOOST_TEST(audio.at("tempo_editing").as_bool()); +} + +BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) { + auto state = navigation_state(); + state.audio.metronome_enabled = true; + + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SONG_MENU)); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_PART_MENU)); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + + BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SCORE)); + BOOST_TEST(state.audio.metronome_enabled); + BOOST_TEST(!state.audio.tempo_editing); +} + BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { auto parsed = vamp_zero::parse_score_document(R"json( {