This commit is contained in:
khodowany
2026-09-30 18:23:25 -05:00
parent 896de203b3
commit 2a1aa2c67a
40 changed files with 1197 additions and 610 deletions
+8 -3
View File
@@ -9,11 +9,16 @@ Python builds the complete SQLite catalog with the score assets. The Zero opens
The catalog stores shared, immutable menu metadata. Complete score bundles stay in SQLite and are loaded individually when selected; snapshots and Pico heartbeats do not copy the catalog's musical JSON.
Pico controls:
Device controls:
On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**.
On the Pico (and on the Zero while viewing a score):
- Tap: next menu item or next score page.
- Double tap: previous menu item or previous score page.
- Tap and hold: select the highlighted setting, song, or part, or return from score view to the previous menu.
- Tap and hold: select the focused item, enter/leave tempo editing, or return from a score to its part menu.
The HTTP service is device-only: Pico controls, title artwork, and health checks. There is no browser remote.
## Build
@@ -119,7 +124,7 @@ Electric melody prioritizes four-string chords, then melody on top. Every genera
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
The main menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. The main menu contains Songs, Playback, Metronome, and Tempo. Navigation is Song -> Part -> Score.
The Settings menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. The main menu contains Performance and Settings. Settings holds Playback, Metronome, and Tempo; its values apply for the current session. Navigation is Song -> Part -> Score.
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
+9 -6
View File
@@ -21,22 +21,23 @@ The Zero process can run headless for local/API quick checks or with Luma for th
The API is plain HTTP on the private AP:
- `GET /api/status`
- `GET /api/health`
- `GET /api/remote/display`
- `GET /api/remote/title`
- `POST /api/remote/title` (registered title image ID)
- `POST /api/remote/heartbeat`
- `POST /api/remote/next`
- `POST /api/remote/previous`
- `POST /api/remote/select`
There is no browser remote, HTTP font route, or full-catalog status dump. `control` owns serialized device actions; direct menu actions carry the displayed input revision and catalog identity so intervening remote navigation invalidates them. Pico display responses include only focused-item/settings values and the selected title artwork ID.
Page commands use `/api/remote/next` and `/api/remote/previous`; the old `/api/page/*` routes are removed.
The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP ciphers so the Pico W can join using `CYW43_AUTH_WPA2_AES_PSK`.
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
At startup the Zero validates the packaged `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Songs, Playback, Metronome, and Tempo. Song -> Part -> Score navigation offers Acoustic, Electric, and Vocal without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
At startup the Zero validates the packaged `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Performance and Settings. Settings holds Playback, Metronome, and Tempo; its values apply for the current session. Song -> Part -> Score navigation offers Acoustic, Electric, and Vocal without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
Runtime catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database in one transaction before deployment; metadata and embedded JSON are published together after an integrity check. The single `songs` table uses strict column types, primary-key and path uniqueness constraints, and JSON validity checks. Menu rows are ordered by title, composer, part, and ID with binary collation, using the matching `songs_menu` index; C++ groups the ordered rows in one pass without sorting them again. Noto provides read-only connections, bound score lookups, application-ID checks, and quick integrity checks. Product-specific schema and asset-path validation remain in the application. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
@@ -44,7 +45,9 @@ Runtime catalogs contain immutable metadata shared by navigation, the HTTP servi
Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again.
Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
The Zero menus use direct taps: release on a row to select it, tap switches to toggle them, and use the tempo minus/plus buttons to adjust BPM. Drags, canceled touches, multiple fingers, and stale menu actions do not activate items. Back clears tempo editing; returning to Settings restores the last setting's focus. Long song lists have previous/next focus buttons.
In score view, Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
## Score Generation
@@ -235,9 +238,9 @@ The display is rotated 90 degrees clockwise into a 320x240 landscape UI. The top
- Yellow: retrying or remote request failed.
- Green: connected to the Zero API.
The body shows a fixed Product Vamp/status label when no score is active, then the active title, selected part, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. All visible text uses Petaluma Script, matching score-page text and the Zero UI.
The body shows the focused menu item, song title, or setting and its current value. In score view it shows the active title, part, and page count. Both devices use a light, score-like palette. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. All visible text uses Petaluma Script, matching score-page text and the Zero UI.
`generator.pico_artwork` shapes text on the build host with the bundled `PetalumaScript.otf` and Pillow's Raqm support. Fixed labels (`VAMP`, connection/status messages, and `PAGE`), digits, and `/` become four-bit coverage sprites. The Pico CMake rule writes `generated/pico/static.p4` and embeds it through `generated/pico/artwork.S`. The Zero does not link this sprite atlas; Luma renders its dynamic UI text from the packaged OTF.
`generator.pico_artwork` shapes text on the build host with the bundled `PetalumaScript.otf` and Pillow's Raqm support. Fixed menu, part, connection/status, and settings labels, digits, and `/` become four-bit coverage sprites. The Pico CMake rule writes `generated/pico/static.p4` and embeds it through `generated/pico/artwork.S`. The Zero does not link this sprite atlas; Luma renders its dynamic UI text from the packaged OTF.
Score generation renders titles as 296x64 four-bit coverage images under `pico/titles/<sha256>.mask`. Each file is 9,480 bytes: an eight-byte `VPTI` header with little-endian width/height, followed by packed coverage. Identical image bytes share an asset without merging score identities. The generated `presentation.title_image` identifier is also stored in catalog metadata; no separate lookup manifest is required. These assets are generated and validated before staged output is published. Remote status includes `title_image=<sha256>`; `POST /api/remote/title` with `{"id":"<sha256>"}` serves validated bytes only for catalog-registered titles. Pico fetches a changed title, validates its fixed format and bounds, rejects stale responses, and retains the selected image. Part labels and page counts use fixed sprites without reshaping or refetching title text. Commands take priority over image requests, and failed title requests display `UNAVAILABLE` until a retry succeeds.
+1
View File
@@ -81,6 +81,7 @@ add_executable(product-vamp-pico2w
src/vamp_pico/input.cpp
src/vamp_pico/main.cpp
src/vamp_pico/remote_display.cpp
src/vamp_pico/screen.cpp
src/vamp_pico/touch.cpp
src/vamp_protocol/page_control.cpp
src/vamp_protocol/gesture.cpp
+3 -1
View File
@@ -83,11 +83,13 @@ set(VAMP_CORE_SOURCES
src/vamp_zero/errors.cpp
src/vamp_zero/health.cpp
src/vamp_zero/library.cpp
src/vamp_zero/menu.cpp
src/vamp_zero/menu_view.cpp
src/vamp_zero/navigation.cpp
src/vamp_zero/notation.cpp
src/vamp_zero/page_control.cpp
src/vamp_zero/playback.cpp
src/vamp_zero/portal.cpp
src/vamp_zero/control.cpp
src/vamp_zero/score.cpp
src/vamp_zero/score_loader.cpp
src/vamp_zero/types.cpp
+2 -1
View File
@@ -19,7 +19,8 @@ TITLE_HEIGHT = 64
TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
MAX_TITLES = 8192
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE')
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE',
'Main Menu', 'Performance', 'Settings', 'Playback', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM')
SPRITES = LABELS + tuple('0123456789/')
DIGIT_WIDTH = 22
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
+10
View File
@@ -39,6 +39,16 @@ enum class sprite : std::size_t {
ELECTRIC,
VOCAL,
UNAVAILABLE,
MAIN_MENU,
PERFORMANCE,
SETTINGS,
PLAYBACK,
METRONOME,
TEMPO,
BACK,
ON,
OFF,
BPM,
DIGIT_0,
DIGIT_1,
DIGIT_2,
+2 -15
View File
@@ -3,21 +3,11 @@
#include <array>
#include <cstdint>
#include <vamp_pico/artwork.hpp>
#include <vamp_pico/http_client.hpp>
#include <vamp_style.hpp>
#include <vamp_pico/screen.hpp>
namespace vamp_pico {
constexpr auto display_width = std::int32_t{320};
constexpr auto display_height = std::int32_t{240};
enum class link_status { CONNECTING,
OFFLINE,
CONNECTED,
ERROR };
class display {
public:
auto init() -> void;
@@ -32,11 +22,8 @@ private:
title_cache title_{};
std::array<std::bool_t, display_height> dirty_{};
link_status link_{link_status::CONNECTING};
std::bool_t active_{};
std::bool_t busy_{};
sprite part_{sprite::COUNT};
std::uint32_t page_{};
std::uint32_t pages_{};
remote_display_state state_{};
std::uint64_t last_write_ms_{};
std::size_t next_row_{};
};
+8
View File
@@ -19,9 +19,17 @@ struct remote_display_state {
std::char_t title[96]{};
std::char_t part[48]{};
std::char_t mode[24]{};
std::char_t focus[32]{};
std::bool_t playback{};
std::bool_t metronome{};
std::bool_t editing{};
std::uint32_t bpm{120U};
std::array<std::char_t, vamp_protocol::title_id_size + 1U> title_image{};
std::uint32_t page{};
std::uint32_t pages{};
auto operator==(remote_display_state const &other) const -> std::bool_t {
return active == other.active && std::string_view{title} == other.title && std::string_view{part} == other.part && std::string_view{mode} == other.mode && std::string_view{focus} == other.focus && playback == other.playback && metronome == other.metronome && editing == other.editing && bpm == other.bpm && title_image == other.title_image && page == other.page && pages == other.pages;
}
};
auto parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state &state) -> std::bool_t;
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <array>
#include <vamp_pico/artwork.hpp>
#include <vamp_pico/http_client.hpp>
#include <vamp_style.hpp>
namespace vamp_pico {
constexpr auto display_width = std::int32_t{320};
constexpr auto display_height = std::int32_t{240};
enum class link_status { CONNECTING,
OFFLINE,
CONNECTED,
ERROR };
auto render_scanline(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::remote_display_state const &state, vamp_pico::link_status link, std::bool_t busy, vamp_pico::title_cache const &title, std::string_view atlas) -> void;
}
-2
View File
@@ -8,8 +8,6 @@
namespace vamp_protocol {
constexpr std::char_t root_path[] = "/";
constexpr std::char_t status_path[] = "/api/status";
constexpr std::char_t remote_next_path[] = "/api/remote/next";
constexpr std::char_t remote_previous_path[] = "/api/remote/previous";
constexpr std::char_t remote_select_path[] = "/api/remote/select";
+9 -11
View File
@@ -17,18 +17,16 @@ struct color {
std::uint8_t a{255};
};
constexpr auto background = vamp_style::color{9, 12, 14, 255};
constexpr auto panel = vamp_style::color{21, 27, 30, 255};
constexpr auto ink = vamp_style::color{238, 244, 241, 255};
constexpr auto muted = vamp_style::color{137, 151, 149, 255};
constexpr auto rule = vamp_style::color{66, 78, 78, 255};
constexpr auto paper = vamp_style::color{2, 3, 3, 255};
constexpr auto paper_ink = vamp_style::color{246, 248, 244, 255};
constexpr auto accent = vamp_style::color{232, 190, 70, 255};
constexpr auto selection = vamp_style::color{41, 100, 111, 255};
constexpr auto background = vamp_style::color{255, 255, 255, 255};
constexpr auto panel = vamp_style::color{245, 247, 246, 255};
constexpr auto ink = vamp_style::color{29, 37, 35, 255};
constexpr auto muted = vamp_style::color{92, 104, 100, 255};
constexpr auto rule = vamp_style::color{211, 220, 217, 255};
constexpr auto accent = vamp_style::color{27, 112, 99, 255};
constexpr auto selection = vamp_style::color{230, 241, 237, 255};
constexpr auto connecting = vamp_style::color{45, 111, 185, 255};
constexpr auto retrying = vamp_style::color{229, 171, 37, 255};
constexpr auto connected = vamp_style::color{40, 170, 88, 255};
constexpr auto retrying = vamp_style::color{156, 104, 23, 255};
constexpr auto connected = vamp_style::color{27, 112, 99, 255};
constexpr auto danger = vamp_style::color{190, 63, 58, 255};
auto rgb565(vamp_style::color value) -> std::uint16_t;
@@ -5,12 +5,11 @@
#include <mutex>
#include <optional>
#include <boost/json/value.hpp>
#include <ligo.hpp>
#include <vamp_protocol/page_control.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/menu.hpp>
#include <vamp_zero/types.hpp>
namespace vamp_zero {
@@ -21,6 +20,7 @@ public:
auto config() const -> vamp_zero::config;
auto snapshot() const -> vamp_zero::app_state;
auto apply(vamp_protocol::page_command command) -> vamp_zero::app_state;
auto apply(vamp_zero::menu_action const &action) -> vamp_zero::app_state;
auto heartbeat() -> vamp_zero::app_state;
auto refresh_connection() -> std::optional<vamp_zero::app_state>;
auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void;
@@ -36,7 +36,6 @@ private:
};
auto make_router(vamp_zero::control_state &state) -> ligo::service_router;
auto portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value;
auto validated_title_path(std::string const &assets_path, std::string const &identifier) -> std::expected<std::string, modulus::error>;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vamp_zero/types.hpp>
namespace vamp_zero {
enum class menu_action_kind { ACTIVATE,
PREVIOUS,
NEXT,
TEMPO_DOWN,
TEMPO_UP };
struct menu_action {
vamp_zero::menu_action_kind kind{};
std::size_t row{};
std::uint64_t revision{};
std::shared_ptr<vamp_zero::catalog const> catalog{};
};
struct menu_item {
std::string label{};
std::string detail{};
};
auto menu_count(vamp_zero::app_state const &state) -> std::size_t;
auto menu_selection(vamp_zero::app_state const &state) -> std::size_t;
auto menu_title(vamp_zero::app_state const &state) -> std::string;
auto menu_row(vamp_zero::app_state const &state, std::size_t row) -> vamp_zero::menu_item;
auto apply_menu_action(vamp_zero::app_state state, vamp_zero::menu_action const &action) -> vamp_zero::app_state;
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <chrono>
#include <optional>
#include <set>
#include <vector>
#include <luma.hpp>
#include <vamp_style.hpp>
#include <vamp_zero/menu.hpp>
namespace vamp_zero {
struct menu_target {
luma::rect bounds{};
vamp_zero::menu_action action{};
};
auto menu_layout(luma::rect bounds, vamp_zero::app_state const &state) -> std::vector<vamp_zero::menu_target>;
auto draw_label(luma::draw_context &context, luma::rect bounds, std::string text, std::float32_t size, vamp_style::color color) -> void;
auto draw_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void;
auto draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void;
class menu_touch {
public:
auto handle(luma::touch_event const &event, luma::rect bounds, vamp_zero::app_state const &state) -> std::optional<vamp_zero::menu_action>;
private:
std::set<std::int32_t> fingers_{};
std::optional<vamp_zero::menu_target> target_{};
luma::point origin_{};
std::chrono::steady_clock::time_point pressed_{};
};
}
-3
View File
@@ -3,8 +3,6 @@
#include <expected>
#include <string>
#include <boost/json/value.hpp>
#include <modulus.hpp>
#include <vamp_protocol/page_control.hpp>
@@ -14,7 +12,6 @@
namespace vamp_zero {
auto apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state;
auto app_status_json(vamp_zero::app_state const &state) -> boost::json::value;
auto remote_display_text(vamp_zero::app_state const &state) -> std::string;
auto validate_app_state(vamp_zero::app_state const &state) -> std::expected<void, modulus::error>;
+1
View File
@@ -19,6 +19,7 @@ public:
touch_input(touch_input const &) = delete;
auto operator=(touch_input const &) -> touch_input & = delete;
auto handle(luma::touch_event const &event) -> std::bool_t;
auto reset() -> void;
auto stop() -> void;
private:
struct state;
+4 -5
View File
@@ -62,6 +62,7 @@ struct score_reference {
enum class app_mode {
MAIN_MENU,
SETTINGS_MENU,
PERFORMANCE_SONG_MENU,
PERFORMANCE_PART_MENU,
PERFORMANCE_SCORE,
@@ -83,16 +84,14 @@ struct app_state {
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_settings_index{1U};
std::size_t remembered_settings_index{1U};
std::size_t selected_song_index{};
std::size_t selected_part_index{};
std::string active_part_id{};
std::size_t page_index{};
std::uint64_t score_session{};
std::uint64_t next_count{};
std::uint64_t previous_count{};
std::uint64_t select_count{};
std::uint64_t heartbeat_count{};
std::string last_command{"startup"};
std::uint64_t input_revision{};
std::bool_t pico_connected{false};
};
+3 -12
View File
@@ -14,6 +14,7 @@
#include <vamp_protocol/page_control.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/types.hpp>
#include <vamp_zero/menu.hpp>
namespace vamp_zero {
@@ -21,23 +22,15 @@ struct ui_runtime;
class score_loader;
struct ui_state {
vamp_zero::app_state app{};
std::string ap_ssid{};
std::string zero_ip{};
std::uint16_t port{8080};
};
class zero_ui {
public:
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {});
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {}, std::function<void(vamp_zero::menu_action)> menu_handler = {});
~zero_ui();
zero_ui(zero_ui const &) = delete;
auto operator=(zero_ui const &) -> zero_ui & = delete;
auto run() -> std::expected<void, modulus::error>;
auto stop() -> void;
auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>;
auto snapshot() const -> vamp_zero::ui_state;
auto publish(vamp_zero::app_state state) -> std::expected<void, modulus::error>;
auto selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader>;
private:
std::shared_ptr<luma::application> application_;
@@ -45,6 +38,4 @@ private:
std::shared_ptr<vamp_zero::score_loader> score_loader_;
};
auto make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state;
}
+7 -101
View File
@@ -73,49 +73,6 @@ auto set_window(std::int32_t x0, std::int32_t y0, std::int32_t x1, std::int32_t
extern "C" std::char_t const vamp_pico_artwork_begin[];
extern "C" std::char_t const vamp_pico_artwork_end[];
auto sprite_image(vamp_pico::sprite value) -> vamp_pico::coverage_image {
return vamp_pico::static_sprite({vamp_pico_artwork_begin,
static_cast<std::size_t>(vamp_pico_artwork_end - vamp_pico_artwork_begin)},
value);
}
auto channel(std::uint8_t fg, std::uint8_t bg, std::uint8_t alpha) -> std::uint8_t {
return static_cast<std::uint8_t>((fg * alpha + bg * (15U - alpha) + 7U) / 15U);
}
auto blend(vamp_style::color fg, std::uint8_t alpha) -> std::uint16_t {
auto bg = vamp_style::background;
return vamp_style::rgb565({channel(fg.r, bg.r, alpha), channel(fg.g, bg.g, alpha), channel(fg.b, bg.b, alpha), 255});
}
auto overlay(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::coverage_image image, std::size_t x, std::size_t top, vamp_style::color ink) -> void {
if (y < top || y - top >= image.height) {
return;
}
for (auto column = std::size_t{}; column < image.width && x + column < row.size(); ++column) {
row[x + column] = blend(ink, image.at(column, y - top));
}
}
auto centered(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::sprite value, std::size_t top, vamp_style::color ink) -> void {
auto image = sprite_image(value);
overlay(row, y, image, (row.size() - image.width) / 2U, top, ink);
}
auto status_color(vamp_pico::link_status link) -> vamp_style::color {
switch (link) {
case vamp_pico::link_status::CONNECTING:
return vamp_style::connecting;
case vamp_pico::link_status::CONNECTED:
return vamp_style::connected;
case vamp_pico::link_status::ERROR:
return vamp_style::danger;
case vamp_pico::link_status::OFFLINE:
return vamp_style::retrying;
}
return vamp_style::retrying;
}
}
auto vamp_pico::display::init() -> void {
@@ -157,32 +114,11 @@ auto vamp_pico::display::dirty(std::size_t begin, std::size_t end) -> void {
}
auto vamp_pico::display::show_remote(link_status link, remote_display_state const &state, std::bool_t busy) -> void {
if (link != link_ || busy != busy_) {
dirty(0U, 24U);
dirty(144U, 172U);
if (link != link_ || busy != busy_ || !(state == state_)) {
dirty(0U, display_height);
}
auto title_changed = title_.select(state.active ? std::string_view{state.title_image.data()} : std::string_view{});
if (active_ != state.active || title_changed) {
dirty(56U, 120U);
dirty(144U, 172U);
}
if (state.active && state.title_image[0] == 0 && !title_.failed()) {
reject_title("");
}
if (active_ != state.active || page_ != state.page || pages_ != state.pages) {
dirty(188U, 216U);
}
auto part = std::string_view{state.part};
auto part_sprite = part == "Acoustic" ? sprite::ACOUSTIC : part == "Electric" ? sprite::ELECTRIC
: part == "Vocal" ? sprite::VOCAL
: sprite::COUNT;
if (part_ != part_sprite) {
dirty(144U, 172U);
}
part_ = part_sprite;
active_ = state.active;
page_ = state.page;
pages_ = state.pages;
title_.select(state.title_image.data());
state_ = state;
link_ = link;
busy_ = busy;
}
@@ -191,8 +127,7 @@ auto vamp_pico::display::accept_title(std::string_view identifier, std::string_v
if (!title_.accept(identifier, image)) {
return false;
}
dirty(56U, 120U);
dirty(144U, 172U);
dirty(60U, 124U);
return true;
}
@@ -202,7 +137,7 @@ auto vamp_pico::display::title_ready() const -> std::bool_t {
auto vamp_pico::display::reject_title(std::string_view identifier) -> void {
if (title_.fail(identifier)) {
dirty(144U, 172U);
dirty(60U, 124U);
}
}
@@ -219,36 +154,7 @@ auto vamp_pico::display::poll(std::uint64_t now_ms) -> void {
continue;
}
static auto row = std::array<std::uint16_t, display_width>{};
row.fill(vamp_style::rgb565(y < 24U ? (busy_ ? vamp_style::danger : status_color(link_)) : vamp_style::background));
if (active_ && title_.ready()) {
overlay(row, y, title_.image(), 12U, 56U, vamp_style::ink);
} else if (!active_) {
centered(row, y, sprite::VAMP, 74U, vamp_style::ink);
}
auto label = busy_ ? sprite::BUSY : link_ == link_status::ERROR ? sprite::ERROR
: link_ == link_status::CONNECTING ? sprite::CONNECTING
: link_ == link_status::OFFLINE ? sprite::OFFLINE
: active_ && title_.failed() ? sprite::UNAVAILABLE
: active_ && !title_.ready() ? sprite::LOADING
: active_ && part_ != sprite::COUNT ? part_
: sprite::READY;
centered(row, y, label, 144U, vamp_style::muted);
if (active_ && pages_ != 0U && y >= 188U && y < 216U) {
auto digits = counter_text(page_, pages_);
auto size = std::strlen(digits.data());
auto label_image = sprite_image(sprite::PAGE);
auto width = label_image.width + 8U + static_cast<std::size_t>(size) * digit_width;
auto x = (row.size() - width) / 2U;
if (size != 0U) {
overlay(row, y, label_image, x, 188U, vamp_style::muted);
}
x += label_image.width + 8U;
for (auto index = std::size_t{}; index < size; ++index) {
auto value = digits[index] == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + digits[index] - '0');
overlay(row, y, sprite_image(value), x, 188U, vamp_style::muted);
x += digit_width;
}
}
render_scanline(row, y, state_, link_, busy_, title_, {vamp_pico_artwork_begin, static_cast<std::size_t>(vamp_pico_artwork_end - vamp_pico_artwork_begin)});
static auto bytes = std::array<std::uchar_t, display_width * 2>{};
for (auto x = std::size_t{}; x < row.size(); ++x) {
bytes[x * 2U] = static_cast<std::uchar_t>(row[x] >> 8U);
+1 -1
View File
@@ -86,7 +86,7 @@ auto main() -> int {
remote = result->display;
status = vamp_pico::link_status::CONNECTED;
display.show_remote(status, remote);
if (remote.active && !display.title_ready()) {
if (remote.title_image[0] != 0 && !display.title_ready()) {
scheduler.want_title(remote.title_image.data());
}
} else {
+16
View File
@@ -66,6 +66,22 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
copy_value(value, parsed.part, sizeof(parsed.part));
} else if (key == "mode") {
copy_value(value, parsed.mode, sizeof(parsed.mode));
} else if (key == "focus") {
if (value.size() >= sizeof(parsed.focus)) {
return false;
}
copy_value(value, parsed.focus, sizeof(parsed.focus));
} else if (key == "playback" || key == "metronome" || key == "editing") {
if (value != "0" && value != "1") {
return false;
}
auto &flag = key == "playback" ? parsed.playback : key == "metronome" ? parsed.metronome
: parsed.editing;
flag = value == "1";
} else if (key == "bpm") {
if (!parse_uint(value, parsed.bpm) || parsed.bpm == 0U || parsed.bpm > 400U) {
return false;
}
} else if (key == "page") {
if (!parse_uint(value, parsed.page)) {
return false;
+105
View File
@@ -0,0 +1,105 @@
#include <vamp_pico/screen.hpp>
#include <cstring>
namespace {
auto blend(std::uint16_t background, vamp_style::color foreground, std::uint8_t alpha) -> std::uint16_t {
auto channel = [alpha](std::uint8_t fg, std::uint32_t bg) { return static_cast<std::uint8_t>((fg * alpha + bg * (15U - alpha) + 7U) / 15U); };
return vamp_style::rgb565({channel(foreground.r, ((background >> 11U) & 31U) * 255U / 31U), channel(foreground.g, ((background >> 5U) & 63U) * 255U / 63U), channel(foreground.b, (background & 31U) * 255U / 31U), 255});
}
auto label_sprite(std::string_view text) -> vamp_pico::sprite {
constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Playback", "Metronome", "Tempo", "Back", "Main Menu"};
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::PLAYBACK, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
for (auto i = std::size_t{}; i < names.size(); ++i) {
if (names[i] == text) {
return sprites[i];
}
}
return vamp_pico::sprite::READY;
}
}
auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::remote_display_state const &state, vamp_pico::link_status link, std::bool_t busy, vamp_pico::title_cache const &title, std::string_view atlas) -> void {
row.fill(vamp_style::rgb565(y < 44U ? vamp_style::panel : vamp_style::background));
auto fill = [&](std::size_t left, std::size_t right, vamp_style::color color) {
std::fill(row.begin() + left, row.begin() + right, vamp_style::rgb565(color));
};
auto overlay = [&](coverage_image image, std::size_t x, std::size_t top, vamp_style::color ink) {
if (y < top || y - top >= image.height) {
return;
}
for (auto column = std::size_t{}; column < image.width && x + column < row.size(); ++column) {
row[x + column] = blend(row[x + column], ink, image.at(column, y - top));
}
};
auto centered = [&](sprite value, std::size_t top, vamp_style::color ink) {
auto image = static_sprite(atlas, value);
overlay(image, (row.size() - image.width) / 2U, top, ink);
};
auto number = [&](std::string_view text, sprite suffix) {
auto label = suffix == sprite::COUNT ? coverage_image{} : static_sprite(atlas, suffix);
auto width = text.size() * digit_width + (label.width == 0U ? 0U : label.width + 8U);
if (text.empty() || width > row.size() - 16U) {
return;
}
auto x = (row.size() - width) / 2U;
for (auto character : text) {
auto value = character == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + character - '0');
overlay(static_sprite(atlas, value), x, 192U, vamp_style::ink);
x += digit_width;
}
overlay(label, x + 8U, 192U, vamp_style::muted);
};
centered(sprite::VAMP, 8U, vamp_style::ink);
if (y >= 18U && y < 26U) {
fill(292U, 300U, link == link_status::CONNECTED ? vamp_style::connected : link == link_status::ERROR ? vamp_style::danger
: vamp_style::retrying);
}
if (y == 43U) {
fill(16U, 304U, vamp_style::rule);
}
auto has_title = state.title_image[0] != 0;
auto mode = std::string_view{state.mode};
if (has_title || state.active) {
if (title.ready()) {
overlay(title.image(), 12U, 60U, vamp_style::ink);
} else {
centered(title.failed() || !has_title ? sprite::UNAVAILABLE : sprite::LOADING, 78U, vamp_style::muted);
}
} else {
centered(mode == "settings_menu" ? sprite::SETTINGS : mode == "performance_song_menu" ? sprite::PERFORMANCE
: sprite::MAIN_MENU,
78U, vamp_style::muted);
}
if (!state.active && y >= 132U && y < 172U) {
fill(16U, 304U, vamp_style::selection);
fill(16U, 19U, vamp_style::accent);
}
centered(label_sprite(state.active ? std::string_view{state.part} : std::string_view{state.focus}), 138U, vamp_style::ink);
if (busy || link != link_status::CONNECTED) {
centered(busy ? sprite::BUSY : link == link_status::CONNECTING ? sprite::CONNECTING
: link == link_status::ERROR ? sprite::ERROR
: sprite::OFFLINE,
192U, vamp_style::muted);
} else if (state.active) {
auto text = counter_text(state.page, state.pages);
number(text.data(), sprite::COUNT);
} else if (mode == "settings_menu") {
auto focus = std::string_view{state.focus};
if (focus == "Tempo") {
auto text = std::array<std::char_t, 4>{};
auto value = std::clamp<std::uint32_t>(state.bpm, 1U, 400U);
std::to_chars(text.data(), text.data() + 3U, value);
number(text.data(), sprite::BPM);
if (state.editing && (y == 184U || y == 226U)) {
fill(64U, 256U, vamp_style::accent);
}
} else if (focus == "Playback" || focus == "Metronome") {
centered((focus == "Playback" ? state.playback : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
}
}
}
+5 -7
View File
@@ -37,7 +37,7 @@
#include <vamp_zero/health.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/ui.hpp>
@@ -350,11 +350,9 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
if (!endpoint) {
return std::unexpected(endpoint.error());
}
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) {
static_cast<void>(state.apply(command));
}};
state.set_observer([&ui, &config](vamp_zero::app_state app) {
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }};
state.set_observer([&ui](vamp_zero::app_state app) {
auto status = ui.publish(std::move(app));
if (!status) {
modulus::log_error("failed to publish UI state: " + status.error().describe());
}
@@ -362,7 +360,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
if (audio != nullptr) {
audio->sync(state.snapshot());
}
auto published = ui.publish(vamp_zero::make_ui_state(config, state.snapshot()));
auto published = ui.publish(state.snapshot());
if (!published) {
return std::unexpected(published.error());
}
@@ -1,4 +1,4 @@
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
#include <chrono>
#include <array>
@@ -99,24 +99,6 @@ private:
std::set<std::string> identifiers_{};
};
auto portal_html() -> std::string {
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title><style>@font-face{font-family:PetalumaScript;src:url('/assets/PetalumaScript.otf') format('opentype');font-display:swap}body,button,pre{font-family:PetalumaScript;font-size:22px}pre{white-space:pre-wrap;overflow-wrap:anywhere}button{font:inherit}</style></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);}
document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();};
document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();};
document.getElementById('next').onclick=async()=>{await fetch('/api/remote/next',{method:'POST'});refresh();};
refresh();
</script></main></body></html>)html";
}
auto endpoint_port(std::string const &endpoint) -> std::string {
auto separator = endpoint.rfind(':');
if (separator == std::string::npos || separator + 1U >= endpoint.size()) {
return "8080";
}
return endpoint.substr(separator + 1U);
}
auto remote_response(vamp_zero::app_state const &state) -> ligo::service_response {
return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state));
}
@@ -170,13 +152,30 @@ auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vam
return updated;
}
auto vamp_zero::control_state::apply(vamp_zero::menu_action const &action) -> vamp_zero::app_state {
auto observer = std::function<void(vamp_zero::app_state)>{};
auto updated = vamp_zero::app_state{};
{
auto lock = std::scoped_lock{mutex_};
auto revision = state_.input_revision;
state_ = vamp_zero::apply_menu_action(std::move(state_), action);
updated = snapshot_locked();
if (revision != state_.input_revision) {
observer = observer_;
}
}
if (observer) {
observer(updated);
}
return updated;
}
auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state {
auto observer = std::function<void(vamp_zero::app_state)>{};
auto updated = vamp_zero::app_state{};
{
auto lock = std::scoped_lock{mutex_};
last_heartbeat_ = std::chrono::steady_clock::now();
++state_.heartbeat_count;
auto notify = !state_.pico_connected;
state_.pico_connected = true;
updated = state_;
@@ -223,10 +222,7 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
return;
}
if (error.empty()) {
auto count = state_.select_count;
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
state_.select_count = count;
state_.last_command = "playback-completed";
} else {
state_.audio.playback_error = std::move(error);
}
@@ -238,30 +234,10 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
}
}
auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value {
auto control = boost::json::object{};
control["ap_ssid"] = config.control.ap_ssid;
control["zero_ip"] = config.control.zero_ip;
control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind);
auto root = boost::json::object{};
root["device"] = "zero";
root["git_sha"] = VAMP_GIT_SHA;
root["control"] = std::move(control);
root["state"] = vamp_zero::app_status_json(state);
return root;
}
auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router {
auto router = ligo::service_router{};
auto assets = state.config().runtime.assets_path;
auto artwork = std::make_shared<title_assets const>(assets, *state.snapshot().catalog);
router.add_response_route("GET", "/assets/PetalumaScript.otf", [assets](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto font = read_asset(assets, "fonts/petaluma/PetalumaScript.otf", 1024U * 1024U);
if (!font) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "PetalumaScript font unavailable"));
}
co_return ligo::text_response("font/otf", std::move(*font));
});
router.add_response_route("POST", vamp_protocol::remote_title_path, [artwork](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto const *id = payload.is_object() && payload.as_object().size() == 1U ? payload.as_object().if_contains("id") : nullptr;
if (id == nullptr || !id->is_string() || !vamp_protocol::valid_title_id(id->as_string())) {
@@ -276,12 +252,6 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
router.add_route("GET", "/api/health", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
co_return boost::json::object{{"product", "product-vamp"}, {"device", "zero"}, {"status", "ready"}};
});
router.add_response_route("GET", vamp_protocol::root_path, [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
co_return ligo::text_response("text/html; charset=utf-8", portal_html());
});
router.add_route("GET", vamp_protocol::status_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
co_return vamp_zero::portal_status_json(state.config(), state.snapshot());
});
router.add_response_route("GET", vamp_protocol::remote_display_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto updated = state.heartbeat();
co_return remote_response(updated);
+131
View File
@@ -0,0 +1,131 @@
#include <vamp_zero/menu.hpp>
#include <utility>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/page_control.hpp>
auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t {
switch (state.mode) {
case vamp_zero::app_mode::MAIN_MENU:
return 2U;
case vamp_zero::app_mode::SETTINGS_MENU:
return 4U;
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
return state.catalog->groups.size() + 1U;
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
if (auto group = selected_group(state)) {
return group->parts.size() + 1U;
}
return 1U;
case vamp_zero::app_mode::PERFORMANCE_SCORE:
return 0U;
}
return 0U;
}
auto vamp_zero::menu_selection(vamp_zero::app_state const &state) -> std::size_t {
switch (state.mode) {
case vamp_zero::app_mode::MAIN_MENU:
return state.selected_main_menu_index;
case vamp_zero::app_mode::SETTINGS_MENU:
return state.selected_settings_index;
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
return state.selected_song_index;
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
return state.selected_part_index;
case vamp_zero::app_mode::PERFORMANCE_SCORE:
return 0U;
}
return 0U;
}
auto vamp_zero::menu_title(vamp_zero::app_state const &state) -> std::string {
switch (state.mode) {
case vamp_zero::app_mode::MAIN_MENU:
return "Main Menu";
case vamp_zero::app_mode::SETTINGS_MENU:
return "Settings";
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
return "Performance";
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
if (auto group = selected_group(state)) {
return group->title;
}
return "Parts";
case vamp_zero::app_mode::PERFORMANCE_SCORE:
return {};
}
return {};
}
auto vamp_zero::menu_row(vamp_zero::app_state const &state, std::size_t row) -> vamp_zero::menu_item {
if (row >= menu_count(state)) {
return {};
}
if (state.mode == vamp_zero::app_mode::MAIN_MENU) {
return {row == 0U ? "Performance" : "Settings", row == 0U ? std::to_string(state.catalog->groups.size()) + " songs" : ""};
}
if (row == 0U) {
return {"Back", ""};
}
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) {
if (row == 1U) {
return {"Playback", state.audio.playback_enabled ? "On" : "Off"};
}
if (row == 2U) {
return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"};
}
return {"Tempo", std::to_string(state.audio.metronome_bpm) + " BPM"};
}
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
auto const &group = state.catalog->groups[row - 1U];
return {group.title, group.composer};
}
if (auto group = selected_group(state)) {
auto const &part = state.catalog->parts[group->parts[row - 1U]];
return {part.part, std::to_string(part.total_pages) + " pages"};
}
return {};
}
auto vamp_zero::apply_menu_action(vamp_zero::app_state state, vamp_zero::menu_action const &action) -> vamp_zero::app_state {
if (state.input_revision != action.revision || state.catalog != action.catalog || menu_count(state) == 0U) {
return state;
}
if (action.kind == vamp_zero::menu_action_kind::NEXT || action.kind == vamp_zero::menu_action_kind::PREVIOUS) {
return apply_app_command(std::move(state), action.kind == vamp_zero::menu_action_kind::NEXT ? vamp_protocol::page_command::NEXT : vamp_protocol::page_command::PREVIOUS);
}
if (action.row >= menu_count(state)) {
return state;
}
if (action.kind == vamp_zero::menu_action_kind::TEMPO_DOWN || action.kind == vamp_zero::menu_action_kind::TEMPO_UP) {
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU || action.row != 3U) {
return state;
}
state.selected_settings_index = 3U;
state.audio.tempo_editing = true;
state = apply_app_command(std::move(state), action.kind == vamp_zero::menu_action_kind::TEMPO_UP ? vamp_protocol::page_command::NEXT : vamp_protocol::page_command::PREVIOUS);
state.audio.tempo_editing = false;
return state;
}
switch (state.mode) {
case vamp_zero::app_mode::MAIN_MENU:
state.selected_main_menu_index = action.row;
break;
case vamp_zero::app_mode::SETTINGS_MENU:
state.selected_settings_index = action.row;
state.audio.tempo_editing = false;
break;
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
state.selected_song_index = action.row;
break;
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
state.selected_part_index = action.row;
break;
case vamp_zero::app_mode::PERFORMANCE_SCORE:
return state;
}
return apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
}
+194
View File
@@ -0,0 +1,194 @@
#include <vamp_zero/menu_view.hpp>
#include <algorithm>
#include <cmath>
#include <vamp_zero/color.hpp>
#include <vamp_zero/navigation.hpp>
namespace {
auto chevron(luma::draw_context &context, luma::rect bounds, std::bool_t back, vamp_style::color color) -> void {
auto x = bounds.x + bounds.width / 2.0F;
auto y = bounds.y + bounds.height / 2.0F;
auto direction = back ? -1.0F : 1.0F;
context.stroke_line({x - 4.0F * direction, y - 7.0F}, {x + 3.0F * direction, y}, 2.0F, vamp_zero::to_luma(color));
context.stroke_line({x + 3.0F * direction, y}, {x - 4.0F * direction, y + 7.0F}, 2.0F, vamp_zero::to_luma(color));
}
auto draw_switch(luma::draw_context &context, luma::rect row, std::bool_t on) -> void {
auto track = luma::rect{row.x + row.width - 88.0F, row.y + 20.0F, 60.0F, 32.0F};
auto color = vamp_zero::to_luma(on ? vamp_style::accent : vamp_style::muted);
context.fill_rect({track.x + 16.0F, track.y, 28.0F, 32.0F}, color);
context.fill_ellipse({track.x, track.y, 32.0F, 32.0F}, color);
context.fill_ellipse({track.x + 28.0F, track.y, 32.0F, 32.0F}, color);
context.fill_ellipse({track.x + (on ? 32.0F : 4.0F), track.y + 4.0F, 24.0F, 24.0F}, vamp_zero::to_luma(vamp_style::background));
}
}
auto vamp_zero::menu_layout(luma::rect bounds, vamp_zero::app_state const &state) -> std::vector<vamp_zero::menu_target> {
auto targets = std::vector<vamp_zero::menu_target>{};
auto count = menu_count(state);
if (count == 0U) {
return targets;
}
auto add = [&](luma::rect rect, vamp_zero::menu_action_kind kind, std::size_t row) {
targets.push_back({rect, {kind, row, state.input_revision, state.catalog}});
};
auto main = state.mode == vamp_zero::app_mode::MAIN_MENU;
if (!main) {
add({bounds.x + 36.0F, bounds.y + 20.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::ACTIVATE, 0U);
}
auto start = main ? 0U : 1U;
auto capacity = std::max<std::size_t>(1U, static_cast<std::size_t>(std::max<std::float32_t>(72.0F, bounds.height - 172.0F) / 72.0F));
auto visible = std::min(count - start, capacity);
auto selected = menu_selection(state);
auto first = std::max<std::size_t>(start, selected >= visible && visible != 0U ? selected - visible + 1U : start);
for (auto i = std::size_t{}; i < visible; ++i) {
auto row = first + i;
auto rectangle = luma::rect{bounds.x + 36.0F, bounds.y + 112.0F + static_cast<std::float32_t>(i) * 72.0F, bounds.width - 72.0F, 72.0F};
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && row == 3U) {
add({rectangle.x + rectangle.width - 240.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_DOWN, row);
add({rectangle.x + rectangle.width - 64.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_UP, row);
}
add(rectangle, vamp_zero::menu_action_kind::ACTIVATE, row);
}
if (count - start > visible) {
if (selected > start) {
add({bounds.x + bounds.width - 156.0F, bounds.y + bounds.height - 56.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::PREVIOUS, 0U);
}
if (selected + 1U < count) {
add({bounds.x + bounds.width - 92.0F, bounds.y + bounds.height - 56.0F, 56.0F, 48.0F}, vamp_zero::menu_action_kind::NEXT, 0U);
}
}
return targets;
}
auto vamp_zero::draw_label(luma::draw_context &context, luma::rect bounds, std::string text, std::float32_t size, vamp_style::color color) -> void {
if (text.empty() || bounds.width <= 0.0F || bounds.height <= 0.0F) {
return;
}
auto style = luma::text_style{.font = std::string{vamp_style::ui_font_path}, .size = size, .line_height = size + 5.0F};
if (context.measure_text(text, style).width > bounds.width) {
do {
auto end = text.size() - 1U;
while (end > 0U && (static_cast<std::uint8_t>(text[end]) & 0xc0U) == 0x80U) {
--end;
}
text.resize(end);
} while (!text.empty() && context.measure_text(text + "...", style).width > bounds.width);
text += "...";
}
context.push_clip(bounds);
context.draw_text(text, {bounds.x, bounds.y + (bounds.height - style.line_height) / 2.0F}, style, vamp_zero::to_luma(color));
context.pop_clip();
}
auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::panel));
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
auto summary = part->part + " " + std::to_string(state.page_index + 1U) + " / " + std::to_string(part->total_pages);
draw_label(context, {bounds.x + 310.0F, bounds.y + 14.0F, bounds.width - 550.0F, 48.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
}
context.fill_ellipse({bounds.x + bounds.width - 184.0F, bounds.y + 33.0F, 10.0F, 10.0F}, vamp_zero::to_luma(state.pico_connected ? vamp_style::connected : vamp_style::muted));
draw_label(context, {bounds.x + bounds.width - 162.0F, bounds.y + 12.0F, 132.0F, 52.0F}, state.pico_connected ? "Pico connected" : "Pico offline", 17.0F, vamp_style::muted);
}
auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
auto main = state.mode == vamp_zero::app_mode::MAIN_MENU;
auto title_x = bounds.x + (main ? 36.0F : 112.0F);
draw_label(context, {title_x, bounds.y + 14.0F, bounds.x + bounds.width - 36.0F - title_x, 60.0F}, menu_title(state), 34.0F, vamp_style::ink);
auto targets = menu_layout(bounds, state);
// Paint full rows first; stepper buttons sit within the tempo row.
for (auto const &target : targets) {
auto const &action = target.action;
if (action.kind != vamp_zero::menu_action_kind::ACTIVATE) {
continue;
}
auto row = target.bounds;
auto selected = menu_selection(state) == action.row;
if (selected) {
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
}
if (!main && action.row == 0U) {
chevron(context, row, true, vamp_style::ink);
continue;
}
if (selected) {
context.fill_rect({row.x, row.y + 12.0F, 3.0F, row.height - 24.0F}, vamp_zero::to_luma(vamp_style::accent));
}
context.stroke_line({row.x, row.y + row.height}, {row.x + row.width, row.y + row.height}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
auto item = menu_row(state, action.row);
auto settings = state.mode == vamp_zero::app_mode::SETTINGS_MENU;
auto songs = state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
draw_label(context, {row.x + 22.0F, row.y + (songs ? 4.0F : 12.0F), row.width - (songs ? 90.0F : 290.0F), songs ? 36.0F : 48.0F}, item.label, 26.0F, vamp_style::ink);
if (songs) {
draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted);
} else if (settings && action.row < 3U) {
draw_switch(context, row, action.row == 1U ? state.audio.playback_enabled : state.audio.metronome_enabled);
} else {
draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted);
}
if (!settings) {
chevron(context, {row.x + row.width - 64.0F, row.y + 12.0F, 48.0F, 48.0F}, false, vamp_style::muted);
}
}
for (auto const &target : targets) {
auto kind = target.action.kind;
auto rect = target.bounds;
if (kind == vamp_zero::menu_action_kind::ACTIVATE) {
continue;
}
if (kind == vamp_zero::menu_action_kind::NEXT || kind == vamp_zero::menu_action_kind::PREVIOUS) {
context.stroke_rect(rect, 1.0F, vamp_zero::to_luma(vamp_style::rule));
chevron(context, rect, kind == vamp_zero::menu_action_kind::PREVIOUS, vamp_style::ink);
continue;
}
auto up = kind == vamp_zero::menu_action_kind::TEMPO_UP;
auto enabled = up ? state.audio.metronome_bpm < state.audio.metronome_max_bpm : state.audio.metronome_bpm > state.audio.metronome_min_bpm;
auto color = vamp_zero::to_luma(enabled ? vamp_style::accent : vamp_style::rule);
context.stroke_rect(rect, 1.0F, color);
context.stroke_line({rect.x + 16.0F, rect.y + 24.0F}, {rect.x + 32.0F, rect.y + 24.0F}, 2.0F, color);
if (up) {
context.stroke_line({rect.x + 24.0F, rect.y + 16.0F}, {rect.x + 24.0F, rect.y + 32.0F}, 2.0F, color);
}
}
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU && state.catalog->groups.empty()) {
draw_label(context, {bounds.x + 36.0F, bounds.y + 112.0F, bounds.width - 72.0F, 72.0F}, "No songs available", 26.0F, vamp_style::muted);
}
}
auto vamp_zero::menu_touch::handle(luma::touch_event const &event, luma::rect bounds, vamp_zero::app_state const &state) -> std::optional<vamp_zero::menu_action> {
auto result = std::optional<vamp_zero::menu_action>{};
for (auto const &point : event.points) {
if (point.phase == luma::touch_phase::DOWN) {
fingers_.insert(point.id);
target_.reset();
if (fingers_.size() == 1U) {
auto targets = menu_layout(bounds, state);
auto found = std::find_if(targets.begin(), targets.end(), [&](auto const &target) { return luma::contains(target.bounds, point.position); });
if (found != targets.end()) {
target_ = *found;
origin_ = point.position;
pressed_ = event.time;
}
}
} else if (fingers_.contains(point.id)) {
if (point.phase == luma::touch_phase::CANCEL || std::abs(point.position.x - origin_.x) > 14.0F || std::abs(point.position.y - origin_.y) > 14.0F) {
target_.reset();
}
if (point.phase == luma::touch_phase::UP || point.phase == luma::touch_phase::CANCEL) {
if (target_ && fingers_.size() == 1U && luma::contains(target_->bounds, point.position) && event.time - pressed_ < std::chrono::milliseconds{750} && target_->action.revision == state.input_revision && target_->action.catalog == state.catalog) {
result = target_->action;
}
fingers_.erase(point.id);
target_.reset();
}
}
}
return result;
}
+53 -131
View File
@@ -9,21 +9,18 @@
#include <string_view>
#include <utility>
#include <boost/json/array.hpp>
#include <boost/json/object.hpp>
#include <boost/json/value.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/menu.hpp>
namespace {
constexpr auto main_menu_performance = std::size_t{0};
constexpr auto main_menu_playback = std::size_t{1};
constexpr auto main_menu_metronome = std::size_t{2};
constexpr auto main_menu_tempo = std::size_t{3};
constexpr auto main_menu_count = std::size_t{4};
constexpr auto settings_playback = std::size_t{1};
constexpr auto settings_metronome = std::size_t{2};
constexpr auto settings_tempo = std::size_t{3};
constexpr auto main_menu_count = std::size_t{2};
auto wrap_next(std::size_t value, std::size_t count) -> std::size_t {
if (count == 0U) {
@@ -69,6 +66,11 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
state.catalog = vamp_zero::empty_catalog();
}
state.audio = clamp_audio_state(state.audio);
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U);
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U);
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) {
state.remembered_settings_index = state.selected_settings_index;
}
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.catalog->groups.size()) - 1U);
if (auto const *group = selected_group(state); group != nullptr) {
@@ -85,7 +87,7 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
state.active_part_id.clear();
state.page_index = 0;
}
if (state.mode != vamp_zero::app_mode::MAIN_MENU) {
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
state.audio.tempo_editing = false;
}
if (auto pages = active_pages(state); pages != 0U) {
@@ -96,62 +98,6 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
return state;
}
auto command_json(vamp_zero::app_state const &state) -> boost::json::object {
auto commands = boost::json::object{};
commands["last"] = state.last_command;
commands["next_count"] = state.next_count;
commands["previous_count"] = state.previous_count;
commands["select_count"] = state.select_count;
commands["heartbeat_count"] = state.heartbeat_count;
return commands;
}
auto audio_json(vamp_zero::app_state const &state) -> boost::json::object {
auto audio = boost::json::object{};
audio["playback_enabled"] = state.audio.playback_enabled;
audio["playback_error"] = state.audio.playback_error;
audio["metronome_enabled"] = state.audio.metronome_enabled;
audio["metronome_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_bpm);
audio["metronome_min_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_min_bpm);
audio["metronome_max_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_max_bpm);
audio["metronome_step"] = static_cast<std::uint64_t>(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;
result["title"] = part.title;
result["composer"] = part.composer;
result["key_signature"] = part.key_signature;
result["part"] = part.part;
result["source_path"] = part.source_path;
result["measures"] = static_cast<std::uint64_t>(part.measure_count);
result["pages"] = static_cast<std::uint64_t>(part.total_pages);
return result;
}
auto performance_json(vamp_zero::app_state const &state) -> boost::json::object {
auto groups = boost::json::array{};
for (auto const &group : state.catalog->groups) {
auto parts = boost::json::array{};
for (auto const &part : group.parts) {
parts.push_back(part_json(state.catalog->parts[part]));
}
auto item = boost::json::object{};
item["title"] = group.title;
item["composer"] = group.composer;
item["parts"] = std::move(parts);
groups.push_back(std::move(item));
}
auto library = boost::json::object{};
library["song_count"] = static_cast<std::uint64_t>(state.catalog->groups.size());
library["part_count"] = static_cast<std::uint64_t>(state.catalog->parts.size());
library["groups"] = std::move(groups);
return library;
}
auto sanitize_line_value(std::string value) -> std::string {
for (auto &ch : value) {
if (ch == '\r' || ch == '\n') {
@@ -165,16 +111,23 @@ auto sanitize_line_value(std::string value) -> std::string {
auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state {
state = clamp_state(std::move(state));
if (command == vamp_protocol::page_command::NEXT) {
++state.next_count;
} else if (command == vamp_protocol::page_command::PREVIOUS) {
++state.previous_count;
} else {
++state.select_count;
}
state.last_command = vamp_protocol::page_command_name(command);
++state.input_revision;
switch (state.mode) {
case vamp_zero::app_mode::MAIN_MENU:
if (command == vamp_protocol::page_command::NEXT || command == vamp_protocol::page_command::PREVIOUS) {
state.selected_main_menu_index = 1U - state.selected_main_menu_index;
} else if (state.selected_main_menu_index == main_menu_performance) {
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
state.selected_song_index = 0U;
state.selected_part_index = 0U;
state.active_part_id.clear();
state.page_index = 0U;
} else {
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.selected_settings_index = state.remembered_settings_index;
}
return clamp_state(std::move(state));
case vamp_zero::app_mode::SETTINGS_MENU:
if (state.audio.tempo_editing) {
if (command == vamp_protocol::page_command::NEXT) {
increase_bpm(state.audio);
@@ -184,20 +137,16 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
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);
state.selected_settings_index = wrap_next(state.selected_settings_index, 4U);
} 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 == 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 if (state.selected_main_menu_index == main_menu_playback) {
state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U);
} else if (state.selected_settings_index == 0U) {
state.mode = vamp_zero::app_mode::MAIN_MENU;
} else if (state.selected_settings_index == settings_playback) {
state.audio.playback_enabled = !state.audio.playback_enabled;
} else if (state.selected_main_menu_index == main_menu_metronome) {
} else if (state.selected_settings_index == settings_metronome) {
state.audio.metronome_enabled = !state.audio.metronome_enabled;
} else if (state.selected_main_menu_index == main_menu_tempo) {
} else if (state.selected_settings_index == settings_tempo) {
state.audio.tempo_editing = true;
}
return clamp_state(std::move(state));
@@ -252,51 +201,6 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
return clamp_state(std::move(state));
}
auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::json::value {
auto selection = boost::json::object{};
selection["main_menu_index"] = static_cast<std::uint64_t>(state.selected_main_menu_index);
selection["song_row_index"] = static_cast<std::uint64_t>(state.selected_song_index);
selection["part_row_index"] = static_cast<std::uint64_t>(state.selected_part_index);
if (auto const *group = selected_group(state); group != nullptr) {
selection["title"] = group->title;
selection["composer"] = group->composer;
}
if (auto const *part = selected_part(state); part != nullptr) {
selection["part"] = part_json(*part);
}
auto active = boost::json::object{};
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
if (auto const *part = active_part(state); part != nullptr) {
active["selected"] = true;
active["kind"] = "performance";
active["part"] = part_json(*part);
active["page_index"] = static_cast<std::uint64_t>(state.page_index);
active["page_number"] = static_cast<std::uint64_t>(state.page_index + 1U);
active["page_total"] = static_cast<std::uint64_t>(part->total_pages);
}
}
if (active.empty()) {
active["selected"] = false;
active["page_index"] = 0;
active["page_number"] = 0;
active["page_total"] = 0;
}
auto remote = boost::json::object{};
remote["pico_connected"] = state.pico_connected;
remote["heartbeat_count"] = state.heartbeat_count;
auto performance = performance_json(state);
auto root = boost::json::object{};
root["mode"] = vamp_zero::to_string(state.mode);
root["library"] = performance;
root["performance"] = std::move(performance);
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;
}
auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string {
auto title = std::string{};
auto title_image = std::string{};
@@ -314,8 +218,23 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
pages = selected->total_pages;
}
}
auto focus = menu_row(state, menu_selection(state)).label;
if (!active && (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU || state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU)) {
if (auto group = selected_group(state); group != nullptr && !group->parts.empty()) {
title = group->title;
title_image = state.catalog->parts[group->parts.front()].title_image;
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
focus = "Performance";
}
}
}
auto output = std::ostringstream{};
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "focus=" << sanitize_line_value(focus) << "\n";
output << "playback=" << (state.audio.playback_enabled ? "1" : "0") << "\n";
output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n";
output << "bpm=" << state.audio.metronome_bpm << "\n";
output << "editing=" << (state.audio.tempo_editing ? "1" : "0") << "\n";
output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n";
output << "title_image=" << title_image << "\n";
@@ -332,6 +251,9 @@ 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.selected_settings_index >= 4U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings 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"));
}
@@ -345,8 +267,8 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
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.audio.tempo_editing && state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires settings"));
}
if (state.selected_song_index >= menu_row_count(state.catalog->groups.size())) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range"));
+12 -2
View File
@@ -26,14 +26,14 @@ struct vamp_zero::touch_input::state : std::enable_shared_from_this<state> {
if (!command) {
return;
}
boost::asio::post(timer.get_executor(), [weak = weak_from_this(), command = *command] {
boost::asio::post(timer.get_executor(), [weak = weak_from_this(), command = *command, generation = input_generation] {
auto self = weak.lock();
if (!self) {
return;
}
{
auto lock = std::scoped_lock{self->mutex};
if (self->stopped) {
if (self->stopped || generation != self->input_generation) {
return;
}
}
@@ -67,6 +67,7 @@ struct vamp_zero::touch_input::state : std::enable_shared_from_this<state> {
vamp_protocol::gesture_state gesture{};
std::optional<std::int32_t> active_touch{};
std::uint64_t timer_generation{};
std::uint64_t input_generation{};
std::bool_t stopped{};
};
@@ -108,6 +109,15 @@ auto vamp_zero::touch_input::handle(luma::touch_event const &event) -> std::bool
auto vamp_zero::touch_input::stop() -> void {
auto lock = std::scoped_lock{state_->mutex};
state_->stopped = true;
++state_->input_generation;
state_->active_touch.reset();
state_->gesture.reset();
state_->schedule();
}
auto vamp_zero::touch_input::reset() -> void {
auto lock = std::scoped_lock{state_->mutex};
++state_->input_generation;
state_->active_touch.reset();
state_->gesture.reset();
state_->schedule();
+2
View File
@@ -10,6 +10,8 @@ auto vamp_zero::to_string(vamp_zero::app_mode value) -> std::string {
switch (value) {
case vamp_zero::app_mode::MAIN_MENU:
return "main_menu";
case vamp_zero::app_mode::SETTINGS_MENU:
return "settings_menu";
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
return "performance_song_menu";
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
+52 -206
View File
@@ -6,11 +6,13 @@
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <boost/asio/any_io_executor.hpp>
#include <boost/asio/post.hpp>
#include <luma.hpp>
@@ -19,18 +21,20 @@
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/parse.hpp>
#include <vamp_zero/menu_view.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/score_loader.hpp>
#include <vamp_zero/touch_input.hpp>
struct vamp_zero::ui_runtime {
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
: touch_{std::move(executor), std::move(handler)} {}
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler, std::function<void(vamp_zero::menu_action)> menu_handler)
: executor_{executor}, touch_{executor, std::move(handler)}, menu_handler_{std::move(menu_handler)} {}
boost::asio::any_io_executor executor_;
vamp_zero::touch_input touch_;
std::function<void(vamp_zero::menu_action)> menu_handler_;
std::mutex mutex_{};
vamp_zero::ui_state state_{};
vamp_zero::app_state state_{};
};
namespace {
@@ -78,177 +82,18 @@ struct score_draw_cache {
std::weak_ptr<vamp_zero::score_document const> document{};
};
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
auto const &bind = config.control.endpoint.bind;
auto colon = bind.rfind(':');
if (colon == std::string::npos) {
return 8080;
}
return vamp_zero::parse_integer<std::uint16_t>(std::string_view{bind}.substr(colon + 1U), "port").value_or(8080);
}
auto status_color(vamp_zero::app_state const &state) -> vamp_style::color {
return state.pico_connected ? vamp_style::connected : vamp_style::retrying;
}
auto text_style(std::int32_t scale) -> luma::text_style {
auto size = 13.0F;
if (scale == 2) {
size = 20.0F;
} else if (scale == 3) {
size = 26.0F;
} else if (scale >= 4) {
size = 34.0F;
}
return {
.font = std::string{vamp_style::ui_font_path},
.size = size,
.line_height = size + 5.0F,
};
}
auto draw_text(luma::draw_context &context, std::float32_t x, std::float32_t y, std::string const &text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void {
if (text.empty() || max_width <= 0.0F) {
return;
}
auto style = text_style(scale);
context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height});
context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color));
context.pop_clip();
}
auto draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::ui_state const &state) -> void {
context.fill_rect(
{.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel));
context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F},
vamp_zero::to_luma(vamp_style::accent));
draw_text(context, bounds.x + 28.0F, bounds.y + 18.0F, "PRODUCT VAMP", 4, vamp_style::ink, 360.0F);
draw_text(context, bounds.x + 30.0F, bounds.y + 55.0F, "AP " + state.ap_ssid + " " + state.zero_ip + ":" + std::to_string(state.port), 1, vamp_style::muted, 520.0F);
auto indicator = status_color(state.app);
auto x = bounds.x + bounds.width - 148.0F;
context.fill_rect({.x = x, .y = bounds.y + 22.0F, .width = 24.0F, .height = 24.0F}, vamp_zero::to_luma(indicator));
draw_text(context, x + 36.0F, bounds.y + 26.0F, "PICO", 2, vamp_style::ink, 88.0F);
}
auto draw_row(luma::draw_context &context, luma::rect row, std::string const &primary, std::string const &secondary, std::bool_t selected) -> void {
if (selected) {
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
}
context.stroke_line({.x = row.x, .y = row.y + row.height},
{.x = row.x + row.width, .y = row.y + row.height},
1.0F,
vamp_zero::to_luma(vamp_style::rule));
draw_text(context, row.x + 16.0F, row.y + 10.0F, primary, 2, vamp_style::ink, row.width * 0.62F);
if (!secondary.empty()) {
draw_text(context, row.x + row.width - 250.0F, row.y + 17.0F, secondary, 1, selected ? vamp_style::ink : vamp_style::muted, 230.0F);
}
}
auto first_visible_row(std::size_t selected, std::size_t total, std::size_t max_rows) -> std::size_t {
if (total <= max_rows || selected < max_rows) {
return 0U;
}
return selected - max_rows + 1U;
}
auto draw_menu_rows(luma::draw_context &context, luma::rect bounds, std::size_t count, std::size_t selected, auto const &draw_item) -> void {
auto total = count + 1U;
auto visible = std::min<std::size_t>(total, 7U);
auto first = first_visible_row(selected, total, visible);
for (auto row = std::size_t{}; row < visible; ++row) {
auto index = first + row;
auto rectangle = luma::rect{.x = bounds.x + 36.0F,
.y = bounds.y + 112.0F + static_cast<std::float32_t>(row) * 54.0F,
.width = bounds.width - 72.0F,
.height = 54.0F};
if (index == 0U) {
draw_row(context, rectangle, "BACK", "", selected == 0U);
} else {
draw_item(index - 1U, rectangle, index == selected);
}
}
}
auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "MENU", 4, vamp_style::ink, 260.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "PRODUCT VAMP", 1, vamp_style::muted, 320.0F);
auto list_y = bounds.y + 112.0F;
auto row_height = std::float32_t{54.0F};
auto row_width = bounds.width - 72.0F;
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height},
"Songs",
std::to_string(state.catalog->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},
"Playback",
state.audio.playback_enabled ? "ON" : "OFF",
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},
"Metronome",
state.audio.metronome_enabled ? "ON" : "OFF",
state.selected_main_menu_index == 2U);
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 * 3.0F, .width = row_width, .height = row_height},
"Tempo",
tempo,
state.selected_main_menu_index == 3U);
}
auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PERFORMANCE", 4, vamp_style::ink, 360.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->groups.size()) + " SONGS " + std::to_string(state.catalog->parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F);
draw_menu_rows(context, bounds, state.catalog->groups.size(), state.selected_song_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
auto const &group = state.catalog->groups[index];
draw_row(context, row, group.title, std::to_string(group.parts.size()) + " PARTS", selected);
});
if (state.catalog->groups.empty()) {
draw_text(context, bounds.x + 38.0F, bounds.y + 194.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F);
}
}
auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
auto const *group = selected_group(state);
draw_text(context,
bounds.x + 36.0F,
bounds.y + 28.0F,
group == nullptr ? std::string{"PARTS"} : group->title,
4,
vamp_style::ink,
bounds.width - 72.0F);
if (group == nullptr) {
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO PARTS FOUND", 3, vamp_style::retrying, 420.0F);
return;
}
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, group->composer, 1, vamp_style::muted, bounds.width - 72.0F);
draw_menu_rows(context, bounds, group->parts.size(), state.selected_part_index, [&](std::size_t index, luma::rect row, std::bool_t selected) {
auto part_index = group->parts[index];
if (part_index >= state.catalog->parts.size()) {
return;
}
auto const &part = state.catalog->parts[part_index];
draw_row(context, row, part.part, std::to_string(part.total_pages) + " PAGES", selected);
});
}
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void {
if (active_part(state) == nullptr) {
draw_performance_song_menu(context, bounds, state);
vamp_zero::draw_menu(context, bounds, state);
return;
}
if (loaded.loading) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "LOADING SCORE", 4, vamp_style::ink, bounds.width - 72.0F);
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "LOADING SCORE", 34.0F, vamp_style::ink);
return;
}
if (loaded.error || !loaded.document) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, loaded.error ? loaded.error->describe() : "Score unavailable", 1, vamp_style::muted, bounds.width - 72.0F);
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, 360.0F, 48.0F}, "SCORE ERROR", 34.0F, vamp_style::danger);
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, loaded.error ? loaded.error->describe() : "Score unavailable", 17.0F, vamp_style::muted);
return;
}
auto paper_bounds = luma::rect{
@@ -257,14 +102,9 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
.width = bounds.width - 48.0F,
.height = bounds.height - 36.0F,
};
if (!state.audio.playback_error.empty()) {
draw_text(context, bounds.x + 24.0F, bounds.y + 12.0F, state.audio.playback_error, 1, vamp_style::danger, bounds.width - 48.0F);
paper_bounds.y += 48.0F;
paper_bounds.height -= 48.0F;
}
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, status.error().describe(), 17.0F, vamp_style::muted);
}
}
@@ -276,7 +116,7 @@ public:
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
}
auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
auto state = vamp_zero::ui_state{};
auto state = vamp_zero::app_state{};
auto loaded = vamp_zero::score_load_state{};
{
auto lock = std::scoped_lock{runtime_->mutex_};
@@ -286,39 +126,53 @@ public:
score_cache_.select(loaded.document);
auto area = bounds();
context.fill_rect(area, vamp_zero::to_luma(vamp_style::background));
draw_header(context, area, state);
vamp_zero::draw_header(context, area, state);
auto content = luma::rect{
.x = area.x,
.y = area.y + 80.0F,
.width = area.width,
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
};
if (state.app.mode == vamp_zero::app_mode::MAIN_MENU) {
draw_main_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
draw_performance_song_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
draw_performance_part_menu(context, content, state.app);
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
draw_score(context, content, state.app, loaded, score_cache_);
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
draw_score(context, content, state, loaded, score_cache_);
} else {
draw_main_menu(context, content, state.app);
vamp_zero::draw_menu(context, content, state);
}
displayed_ = std::move(state);
}
auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
return runtime_->touch_.handle(event);
if (!displayed_) {
return false;
}
auto area = bounds();
auto action = menu_touch_.handle(event, {area.x, area.y + 80.0F, area.width, std::max<std::float32_t>(0.0F, area.height - 80.0F)}, *displayed_);
if (action && runtime_->menu_handler_) {
boost::asio::post(runtime_->executor_, [weak = std::weak_ptr{runtime_}, action = *action] {
if (auto runtime = weak.lock()) {
runtime->menu_handler_(action);
}
});
}
auto lock = std::scoped_lock{runtime_->mutex_};
auto const &current = runtime_->state_;
if (displayed_->mode == vamp_zero::app_mode::PERFORMANCE_SCORE && current.mode == displayed_->mode && current.score_session == displayed_->score_session && current.catalog == displayed_->catalog) {
return runtime_->touch_.handle(event);
}
return true;
}
private:
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
std::shared_ptr<vamp_zero::score_loader> loader_;
mutable score_draw_cache score_cache_;
mutable std::optional<vamp_zero::app_state> displayed_{};
vamp_zero::menu_touch menu_touch_{};
};
}
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler)
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler)
: application_{std::make_shared<luma::application>(std::move(config))},
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler), std::move(menu_handler))},
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
if (auto application = weak.lock()) {
static_cast<void>(application->request_layout());
@@ -343,32 +197,24 @@ auto vamp_zero::zero_ui::stop() -> void {
application_->request_stop();
}
auto vamp_zero::zero_ui::publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error> {
auto vamp_zero::zero_ui::publish(vamp_zero::app_state state) -> std::expected<void, modulus::error> {
{
auto lock = std::scoped_lock{runtime_->mutex_};
if (!state.app.catalog) {
state.app.catalog = vamp_zero::empty_catalog();
if (state.catalog == runtime_->state_.catalog && state.input_revision < runtime_->state_.input_revision) {
return {};
}
score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app));
if (!state.catalog) {
state.catalog = vamp_zero::empty_catalog();
}
if (runtime_->state_.mode != state.mode || runtime_->state_.score_session != state.score_session || runtime_->state_.catalog != state.catalog) {
runtime_->touch_.reset();
}
score_loader_->select(state.catalog, vamp_zero::selected_score_reference(state));
runtime_->state_ = std::move(state);
}
return application_->request_layout();
}
auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
auto lock = std::scoped_lock{runtime_->mutex_};
return runtime_->state_;
}
auto vamp_zero::zero_ui::selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader> {
return score_loader_;
}
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
return vamp_zero::ui_state{
.app = std::move(app),
.ap_ssid = config.control.ap_ssid,
.zero_ip = config.control.zero_ip,
.port = bind_port(config),
};
}
+14 -1
View File
@@ -2,14 +2,17 @@ add_executable(product-vamp-test
../src/vamp_pico/http_client.cpp
../src/vamp_pico/input.cpp
../src/vamp_pico/remote_display.cpp
../src/vamp_pico/screen.cpp
test.cpp
test_catalog_runtime.cpp
test_health.cpp
test_menu.cpp
test_pico.cpp
test_portal_artwork.cpp
test_control_artwork.cpp
test_playback.cpp
test_score_loader.cpp
test_touch_input.cpp
test_ui.cpp
)
target_include_directories(product-vamp-test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
@@ -27,7 +30,17 @@ target_compile_definitions(product-vamp-test PRIVATE
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback-assets"
VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui-previews"
)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4
COMMAND ${Python3_EXECUTABLE} -m generator.pico_artwork --static-output ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS ${CMAKE_SOURCE_DIR}/generator/pico_artwork.py ${CMAKE_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf
VERBATIM
)
add_custom_target(vamp-ui-test-assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui-previews/static.p4)
add_dependencies(product-vamp-test vamp-ui-test-assets)
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json
+21 -21
View File
@@ -34,7 +34,7 @@
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/parse.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/app.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/types.hpp>
@@ -92,9 +92,6 @@ BOOST_AUTO_TEST_CASE(build_identification_uses_git_without_an_application_releas
auto once = std::string{"--once"};
args.push_back(once.data());
BOOST_TEST(!vamp_zero::parse_command_line(3, args.data()));
auto status = vamp_zero::portal_status_json({}, {}).as_object();
BOOST_TEST(status.at("git_sha").as_string() == VAMP_GIT_SHA);
BOOST_TEST(!status.contains("version"));
}
BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
@@ -155,20 +152,23 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
BOOST_TEST(!parsed);
}
BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
BOOST_AUTO_TEST_CASE(settings_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);
BOOST_TEST(state.selected_main_menu_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 2U);
BOOST_TEST(state.selected_settings_index == 2U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_TEST(state.audio.metronome_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 3U);
BOOST_TEST(state.selected_settings_index == 3U);
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);
@@ -187,21 +187,22 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
BOOST_TEST(!state.audio.tempo_editing);
}
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
auto state = navigation_state();
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.selected_settings_index = 3U;
state.audio.playback_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("playback_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());
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
BOOST_TEST(std::string{remote.focus} == "Tempo");
BOOST_TEST(std::string{remote.mode} == "settings_menu");
BOOST_TEST(remote.playback);
BOOST_TEST(remote.metronome);
BOOST_TEST(remote.bpm == 135U);
BOOST_TEST(remote.editing);
}
BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
@@ -240,7 +241,7 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalog) {
BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
auto state = vamp_zero::app_state{};
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
BOOST_TEST(state.selected_main_menu_index == 3U);
BOOST_TEST(state.selected_main_menu_index == 1U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_TEST(state.selected_main_menu_index == 0U);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
@@ -250,7 +251,6 @@ BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
BOOST_TEST(!vamp_zero::app_status_json(state).as_object().contains("practice"));
}
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
+4 -4
View File
@@ -27,7 +27,7 @@
#include <vamp_zero/errors.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
#include "catalog_fixture.hpp"
@@ -156,7 +156,7 @@ BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_
BOOST_TEST(vamp_zero::selected_part(before) == &catalog->parts[1]);
auto heartbeat = control.heartbeat();
BOOST_TEST(heartbeat.catalog.get() == catalog.get());
BOOST_TEST(heartbeat.heartbeat_count == 1U);
BOOST_TEST(heartbeat.input_revision == before.input_revision);
BOOST_TEST(heartbeat.pico_connected);
auto playing = control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(vamp_zero::to_string(playing.mode) == "performance_score");
@@ -165,8 +165,8 @@ BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_
BOOST_TEST(control.apply(vamp_protocol::page_command::PREVIOUS).page_index == 0U);
control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(control.snapshot().catalog.get() == catalog.get());
BOOST_TEST(before.heartbeat_count == 0U);
BOOST_TEST(before.select_count == 0U);
BOOST_TEST(!before.pico_connected);
BOOST_TEST(before.input_revision == 0U);
BOOST_TEST(before.active_part_id.empty());
BOOST_TEST(original.catalog.get() == catalog.get());
BOOST_REQUIRE(!observed.empty());
@@ -15,7 +15,7 @@
#include <openssl/sha.h>
#include <vamp_pico/http_client.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
namespace {
@@ -72,7 +72,7 @@ auto call(ligo::service_router const &router, std::string method, std::string ro
}
BOOST_FIXTURE_TEST_CASE(portal_title_route_serves_registered_binary_and_remote_identity, artwork_fixture) {
BOOST_FIXTURE_TEST_CASE(control_title_route_serves_registered_binary_and_remote_identity, artwork_fixture) {
auto control = vamp_zero::control_state{config(), state()};
auto router = vamp_zero::make_router(control);
auto status = call(router, "GET", vamp_protocol::remote_display_path);
@@ -92,7 +92,7 @@ BOOST_FIXTURE_TEST_CASE(portal_title_route_serves_registered_binary_and_remote_i
BOOST_CHECK(!call(router, "GET", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
}
BOOST_FIXTURE_TEST_CASE(portal_title_route_rejects_arbitrary_unregistered_and_corrupt_assets, artwork_fixture) {
BOOST_FIXTURE_TEST_CASE(control_title_route_rejects_arbitrary_unregistered_and_corrupt_assets, artwork_fixture) {
auto control = vamp_zero::control_state{config(), state()};
auto router = vamp_zero::make_router(control);
for (auto const &id : {std::string{"../title"}, std::string(64U, 'f'), std::string(64U, 'A'), identifier + "a"}) {
@@ -115,24 +115,20 @@ BOOST_FIXTURE_TEST_CASE(portal_title_route_rejects_arbitrary_unregistered_and_co
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
}
BOOST_FIXTURE_TEST_CASE(portal_title_validation_rejects_symlink_outside_title_directory, artwork_fixture) {
BOOST_FIXTURE_TEST_CASE(control_title_validation_rejects_symlink_outside_title_directory, artwork_fixture) {
auto other = root / "other.mask";
std::filesystem::rename(path(), other);
std::filesystem::create_symlink(other, path());
BOOST_CHECK(!vamp_zero::validated_title_path(root.string(), identifier));
}
BOOST_FIXTURE_TEST_CASE(portal_uses_and_serves_only_the_packaged_petaluma_font, artwork_fixture) {
std::filesystem::create_directories(root / "fonts/petaluma");
std::filesystem::copy_file(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf", root / "fonts/petaluma/PetalumaScript.otf");
BOOST_FIXTURE_TEST_CASE(control_has_no_browser_remote_or_catalog_dump, artwork_fixture) {
auto control = vamp_zero::control_state{config(), {}};
auto router = vamp_zero::make_router(control);
auto html = call(router, "GET", "/");
BOOST_REQUIRE(html);
BOOST_CHECK(html->body.find("font-family:PetalumaScript") != std::string::npos);
auto font = call(router, "GET", "/assets/PetalumaScript.otf");
BOOST_REQUIRE(font);
BOOST_TEST(font->content_type == "font/otf");
BOOST_TEST(font->body.substr(0U, 4U) == "OTTO");
BOOST_CHECK(!call(router, "GET", "/"));
BOOST_CHECK(!call(router, "GET", "/api/status"));
BOOST_CHECK(!call(router, "GET", "/assets/PetalumaScript.otf"));
BOOST_CHECK(!call(router, "GET", "/assets/other.otf"));
BOOST_REQUIRE(call(router, "GET", "/api/health"));
BOOST_REQUIRE(call(router, "GET", vamp_protocol::remote_display_path));
}
+6 -6
View File
@@ -27,7 +27,7 @@
#include <vamp_zero/app.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/health.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
#include "catalog_fixture.hpp"
@@ -310,17 +310,17 @@ BOOST_AUTO_TEST_CASE(health_command_never_mutates_database_assets_or_runtime) {
require_no_probe(config, true);
}
BOOST_AUTO_TEST_CASE(portal_health_route_reports_readiness_without_changing_control_state) {
BOOST_AUTO_TEST_CASE(control_health_route_reports_readiness_without_changing_control_state) {
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
auto service = http_service{vamp_zero::make_router(state)};
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
auto snapshot = state.snapshot();
BOOST_TEST(snapshot.heartbeat_count == 0U);
BOOST_TEST(snapshot.input_revision == 0U);
BOOST_TEST(!snapshot.pico_connected);
BOOST_TEST(snapshot.last_command == "startup");
BOOST_CHECK(snapshot.mode == vamp_zero::app_mode::MAIN_MENU);
}
BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_background_polls) {
BOOST_AUTO_TEST_CASE(control_remote_commands_refresh_connection_without_background_polls) {
for (auto const *route : {vamp_protocol::remote_next_path, vamp_protocol::remote_previous_path, vamp_protocol::remote_select_path}) {
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
auto catalog = state.snapshot().catalog;
@@ -331,7 +331,7 @@ BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_backgroun
BOOST_REQUIRE(response.get());
auto snapshot = state.snapshot();
BOOST_TEST(snapshot.pico_connected);
BOOST_TEST(snapshot.heartbeat_count == 1U);
BOOST_TEST(snapshot.input_revision == 1U);
BOOST_TEST(snapshot.catalog.get() == catalog.get());
}
}
+174
View File
@@ -0,0 +1,174 @@
#include <boost/test/unit_test.hpp>
#include <algorithm>
#include <chrono>
#include <string>
#include <vamp_pico/http_client.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/menu_view.hpp>
#include <vamp_zero/page_control.hpp>
namespace {
constexpr auto menu_bounds = luma::rect{0.0F, 80.0F, 1024.0F, 520.0F};
auto action(vamp_zero::app_state const &state, std::size_t row, vamp_zero::menu_action_kind kind = vamp_zero::menu_action_kind::ACTIVATE) -> vamp_zero::menu_action {
return {kind, row, state.input_revision, state.catalog};
}
auto point_event(luma::touch_phase phase, luma::point point, std::chrono::steady_clock::time_point time, std::int32_t id = 1) -> luma::touch_event {
return {.points = {{.id = id, .position = point, .phase = phase}}, .time = time};
}
auto row_center(vamp_zero::app_state const &state, std::size_t row, vamp_zero::menu_action_kind kind = vamp_zero::menu_action_kind::ACTIVATE) -> luma::point {
auto targets = vamp_zero::menu_layout(menu_bounds, state);
auto found = std::find_if(targets.begin(), targets.end(), [&](auto const &target) { return target.action.row == row && target.action.kind == kind; });
BOOST_REQUIRE(found != targets.end());
return {found->bounds.x + found->bounds.width / 2.0F, found->bounds.y + found->bounds.height / 2.0F};
}
}
BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus) {
auto state = vamp_zero::app_state{};
BOOST_TEST(vamp_zero::menu_count(state) == 2U);
BOOST_TEST(vamp_zero::menu_row(state, 0U).label == "Performance");
BOOST_TEST(vamp_zero::menu_row(state, 1U).label == "Settings");
state = vamp_zero::apply_menu_action(state, action(state, 1U));
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U);
state = vamp_zero::apply_menu_action(state, action(state, 1U));
BOOST_TEST(state.audio.playback_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 2U));
BOOST_TEST(state.audio.metronome_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 3U));
BOOST_TEST(state.audio.tempo_editing);
state = vamp_zero::apply_menu_action(state, action(state, 0U));
BOOST_CHECK(state.mode == vamp_zero::app_mode::MAIN_MENU);
BOOST_TEST(!state.audio.tempo_editing);
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 3U);
BOOST_TEST(!state.audio.tempo_editing);
BOOST_TEST(state.audio.playback_enabled);
BOOST_TEST(state.audio.metronome_enabled);
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
}
BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priority) {
auto state = vamp_zero::app_state{};
auto touch = vamp_zero::menu_touch{};
auto now = std::chrono::steady_clock::now();
auto point = row_center(state, 1U);
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state));
auto tapped = touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state);
BOOST_REQUIRE(tapped);
state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(!state.audio.playback_enabled);
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state));
point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP);
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
tapped = touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state);
BOOST_REQUIRE(tapped);
BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP);
state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_TEST(state.audio.metronome_bpm == 125U);
BOOST_TEST(!state.audio.tempo_editing);
for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN));
}
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_min_bpm);
for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP));
}
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_max_bpm);
}
BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) {
for (auto scenario = 0U; scenario < 5U; ++scenario) {
auto state = vamp_zero::app_state{};
auto touch = vamp_zero::menu_touch{};
auto now = std::chrono::steady_clock::now();
auto point = row_center(state, 1U);
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
if (scenario == 0U) {
touch.handle(point_event(luma::touch_phase::MOVE, {point.x + 30.0F, point.y}, now), menu_bounds, state);
} else if (scenario == 1U) {
now += std::chrono::seconds{1};
} else if (scenario == 2U) {
touch.handle(point_event(luma::touch_phase::DOWN, point, now, 2), menu_bounds, state);
touch.handle(point_event(luma::touch_phase::UP, point, now, 2), menu_bounds, state);
} else if (scenario == 3U) {
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::NEXT);
} else {
touch.handle(point_event(luma::touch_phase::CANCEL, point, now), menu_bounds, state);
}
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state));
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
BOOST_REQUIRE(touch.handle(point_event(luma::touch_phase::UP, point, now + std::chrono::milliseconds{40}), menu_bounds, state));
}
}
BOOST_AUTO_TEST_CASE(control_rejects_delayed_actions_after_remote_navigation_or_catalog_change) {
auto state = vamp_zero::app_state{};
auto control = vamp_zero::control_state{{}, state};
auto tapped = action(state, 1U);
control.heartbeat();
control.apply(tapped);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU);
control.apply(tapped);
BOOST_TEST(!control.snapshot().audio.playback_enabled);
auto settings = control.snapshot();
tapped = action(settings, 1U);
control.apply(vamp_protocol::page_command::NEXT);
auto before = control.snapshot();
control.apply(tapped);
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
BOOST_TEST(!control.snapshot().audio.playback_enabled);
tapped = action(before, 1U);
tapped.catalog = std::make_shared<vamp_zero::catalog const>();
control.apply(tapped);
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
}
BOOST_AUTO_TEST_CASE(menu_scroll_reaches_every_song_and_remote_shows_menu_titles) {
auto catalog = vamp_zero::catalog{};
for (auto i = 0U; i < 20U; ++i) {
catalog.parts.push_back({.id = std::to_string(i), .title = "Song " + std::to_string(i), .part = "Electric", .total_pages = 3U, .title_image = std::string(64U, 'a')});
catalog.groups.push_back({catalog.parts.back().title, "Composer", {i}});
}
auto state = vamp_zero::app_state{.catalog = std::make_shared<vamp_zero::catalog const>(std::move(catalog)), .mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU};
for (auto index = 1U; index <= 20U; ++index) {
state = vamp_zero::apply_menu_action(state, action(state, 0U, vamp_zero::menu_action_kind::NEXT));
BOOST_TEST(state.selected_song_index == index);
auto targets = vamp_zero::menu_layout(menu_bounds, state);
BOOST_TEST(std::any_of(targets.begin(), targets.end(), [&](auto const &target) { return target.action.kind == vamp_zero::menu_action_kind::ACTIVATE && target.action.row == index; }));
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
BOOST_TEST(!remote.active);
BOOST_TEST(std::string{remote.title} == "Song " + std::to_string(index - 1U));
BOOST_TEST(std::string{remote.title_image.data()} == std::string(64U, 'a'));
}
state = vamp_zero::apply_menu_action(state, action(state, 20U));
state = vamp_zero::apply_menu_action(state, action(state, 1U));
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
BOOST_TEST(state.active_part_id == "19");
BOOST_TEST(vamp_zero::menu_layout(menu_bounds, state).empty());
state = vamp_zero::apply_app_command(state, vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.selected_part_index == 1U);
}
BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) {
auto state = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Playback\nplayback=1\nbpm=135\n", state));
auto before = state;
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "playback=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state));
BOOST_CHECK(state == before);
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
#include <vamp_zero/audio.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/control.hpp>
namespace {
@@ -79,7 +79,7 @@ BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
state.finish_playback(42U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.snapshot().selected_part_index == 1U);
BOOST_TEST(state.snapshot().select_count == 0U);
BOOST_TEST(state.snapshot().score_session == 42U);
state.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(state.snapshot().score_session == 43U);
state.finish_playback(42U);
+3 -3
View File
@@ -39,9 +39,9 @@ class PicoArtworkTests(unittest.TestCase):
output = pathlib.Path(directory) / 'generated' / 'static.p4'
subprocess.run([sys.executable, '-m', 'generator.pico_artwork', '--static-output', str(output)], check=True)
atlas = output.read_bytes()
self.assertLess(len(atlas), 24000)
self.assertLess(len(atlas), 40000)
magic, count, reserved = struct.unpack_from('<4sHH', atlas)
self.assertEqual((magic, count, reserved), (b'VPSA', 24, 0))
self.assertEqual((magic, count, reserved), (b'VPSA', len(generator.pico_artwork.SPRITES), 0))
digits = []
for index in range(count):
width, height, offset = struct.unpack_from('<HHI', atlas, 8 + index * 8)
@@ -54,7 +54,7 @@ class PicoArtworkTests(unittest.TestCase):
levels = [value for byte in coverage for value in (byte >> 4, byte & 15)][:width * height]
self.assertGreater(sum(value > 0 for value in levels), 25)
self.assertTrue(any(0 < value < 15 for value in levels))
if index >= 13:
if index >= len(generator.pico_artwork.LABELS):
self.assertEqual(width, 22)
digits.append(coverage)
ink_rows = {pixel // width for pixel, value in enumerate(levels) if value}
+18
View File
@@ -150,3 +150,21 @@ BOOST_AUTO_TEST_CASE(zero_touch_stop_and_destruction_suppress_queued_commands) {
BOOST_TEST(commands.empty());
}
}
BOOST_AUTO_TEST_CASE(zero_touch_reset_discards_old_score_gestures_but_accepts_new_input) {
auto io = boost::asio::io_context{};
auto commands = std::vector<std::string>{};
auto touch = vamp_zero::touch_input{io.get_executor(), [&](auto command) { commands.emplace_back(vamp_protocol::page_command_name(command)); }};
auto base = std::chrono::steady_clock::now() - std::chrono::seconds{10};
touch.handle(touch_event(luma::touch_phase::DOWN, base));
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{700}));
touch.reset();
io.run();
BOOST_TEST(commands.empty());
io.restart();
touch.handle(touch_event(luma::touch_phase::DOWN, base));
touch.handle(touch_event(luma::touch_phase::UP, base + std::chrono::milliseconds{30}));
io.run();
BOOST_REQUIRE_EQUAL(commands.size(), 1U);
BOOST_TEST(commands.front() == "next");
}
+194
View File
@@ -0,0 +1,194 @@
#include <boost/test/unit_test.hpp>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <map>
#include <string>
#include <boost/json/parse.hpp>
#include <boost/json/object.hpp>
#include <raylib.h>
#include <vamp_pico/screen.hpp>
#include <vamp_zero/menu_view.hpp>
#include <vamp_zero/page_control.hpp>
namespace {
auto read_bytes(std::filesystem::path const &path) -> std::string {
auto input = std::ifstream{path, std::ios::binary};
BOOST_REQUIRE(input.good());
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
}
auto color(luma::color value) -> Color {
return {value.r, value.g, value.b, value.a};
}
auto rectangle(luma::rect value) -> Rectangle {
return {value.x, value.y, value.width, value.height};
}
// Uses the same raylib glyph rasterizer and metrics as Luma, without a GPU/window.
class image_context final : public luma::draw_context {
public:
image_context() = default;
~image_context() override {
UnloadImage(image);
for (auto const &[size, font] : fonts_) {
UnloadFontData(font.glyphs, font.glyphCount);
MemFree(font.recs);
}
}
auto fill_rect(luma::rect bounds, luma::color value) -> void override {
ImageDrawRectangleRec(&image, rectangle(bounds), color(value));
}
auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color value) -> void override {
ImageDrawRectangleLines(&image, rectangle(bounds), static_cast<std::int32_t>(thickness), color(value));
}
auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color value) -> void override {
ImageDrawLineEx(&image, {from.x, from.y}, {to.x, to.y}, static_cast<std::int32_t>(thickness), color(value));
}
auto fill_ellipse(luma::rect bounds, luma::color value) -> void override {
BOOST_TEST(bounds.width == bounds.height);
ImageDrawCircle(&image, static_cast<std::int32_t>(bounds.x + bounds.width / 2.0F), static_cast<std::int32_t>(bounds.y + bounds.height / 2.0F), static_cast<std::int32_t>(bounds.width / 2.0F), color(value));
}
auto stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void override {
BOOST_FAIL("unexpected ellipse outline");
}
auto draw_texture(luma::texture const &, luma::rect, luma::rect, luma::color) -> void override {
BOOST_FAIL("menu must not load score textures");
}
auto draw_text(std::string_view text, luma::point origin, luma::text_style const &style, luma::color value) -> void override {
BOOST_TEST(style.font == vamp_style::ui_font_path);
auto measured = measure_text(text, style);
BOOST_REQUIRE(clip_.has_value());
BOOST_TEST(measured.width <= clip_->width);
BOOST_TEST(origin.y >= clip_->y);
BOOST_TEST(origin.y + measured.height <= clip_->y + clip_->height);
auto face = font(style.size);
auto owned = std::string{text};
auto x = origin.x;
for (auto i = std::size_t{}; i < owned.size();) {
auto bytes = std::int32_t{};
auto codepoint = GetCodepointNext(owned.c_str() + i, &bytes);
auto index = GetGlyphIndex(face, codepoint);
auto const &glyph = face.glyphs[index];
if (codepoint != ' ') {
ImageDraw(&image, glyph.image, {0.0F, 0.0F, face.recs[index].width, face.recs[index].height}, {x + static_cast<std::float32_t>(glyph.offsetX), origin.y + static_cast<std::float32_t>(glyph.offsetY), face.recs[index].width, face.recs[index].height}, color(value));
}
x += (glyph.advanceX == 0 ? face.recs[index].width : static_cast<std::float32_t>(glyph.advanceX)) + 1.0F;
i += static_cast<std::size_t>(bytes);
}
}
auto push_clip(luma::rect bounds) -> void override {
BOOST_TEST(bounds.x >= 0.0F);
BOOST_TEST(bounds.y >= 0.0F);
BOOST_TEST(bounds.x + bounds.width <= 1024.0F);
BOOST_TEST(bounds.y + bounds.height <= 600.0F);
clip_ = bounds;
}
auto pop_clip() -> void override {
clip_.reset();
}
auto measure_text(std::string_view text, luma::text_style const &style) const -> luma::size override {
auto size = MeasureTextEx(font(style.size), std::string{text}.c_str(), style.size, 1.0F);
return {size.x, size.y};
}
Image image{GenImageColor(1024, 600, WHITE)};
private:
auto font(std::float32_t size) const -> Font {
auto key = static_cast<std::int32_t>(std::ceil(size));
auto found = fonts_.find(key);
if (found != fonts_.end()) {
return found->second;
}
auto bytes = read_bytes(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf");
auto face = Font{};
face.baseSize = key;
face.glyphs = LoadFontData(reinterpret_cast<std::uchar_t const *>(bytes.data()), static_cast<std::int32_t>(bytes.size()), key, nullptr, 95, FONT_DEFAULT, &face.glyphCount);
BOOST_REQUIRE(face.glyphs != nullptr);
auto atlas = GenImageFontAtlas(face.glyphs, &face.recs, face.glyphCount, key, 4, 0);
BOOST_REQUIRE(atlas.data != nullptr);
for (auto i = std::int32_t{}; i < face.glyphCount; ++i) {
UnloadImage(face.glyphs[i].image);
face.glyphs[i].image = ImageFromImage(atlas, face.recs[i]);
}
UnloadImage(atlas);
face.texture.id = 1U;
fonts_.emplace(key, face);
return face;
}
mutable std::map<std::int32_t, Font> fonts_{};
std::optional<luma::rect> clip_{};
};
auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pico::link_status link = vamp_pico::link_status::CONNECTED, std::bool_t busy = false) -> void {
auto directory = std::filesystem::path{VAMP_TEST_UI_DIR};
std::filesystem::create_directories(directory);
auto context = image_context{};
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero-" + name + ".png")).c_str()));
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
auto title = vamp_pico::title_cache{};
if (remote.title_image[0] != 0) {
title.select(remote.title_image.data());
BOOST_REQUIRE(title.accept(remote.title_image.data(), read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "pico/titles" / (std::string{remote.title_image.data()} + ".mask"))));
}
auto atlas = read_bytes(directory / "static.p4");
auto image = GenImageColor(320, 240, WHITE);
auto row = std::array<std::uint16_t, vamp_pico::display_width>{};
auto dark = std::size_t{};
for (auto y = 0U; y < 240U; ++y) {
vamp_pico::render_scanline(row, y, remote, link, busy, title, atlas);
for (auto x = 0U; x < 320U; ++x) {
auto pixel = row[x];
auto value = Color{static_cast<std::uint8_t>((pixel >> 11U) * 255U / 31U), static_cast<std::uint8_t>(((pixel >> 5U) & 63U) * 255U / 63U), static_cast<std::uint8_t>((pixel & 31U) * 255U / 31U), 255};
ImageDrawPixel(&image, x, y, value);
dark += value.r < 128U && value.g < 128U && value.b < 128U ? 1U : 0U;
}
}
BOOST_TEST(dark > 200U);
BOOST_REQUIRE(ExportImage(image, (directory / ("pico-" + name + ".png")).c_str()));
UnloadImage(image);
}
}
BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays) {
auto state = vamp_zero::app_state{};
preview(state, "main");
preview(state, "offline", vamp_pico::link_status::OFFLINE);
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.pico_connected = true;
state.audio.playback_enabled = true;
state.audio.metronome_enabled = true;
preview(state, "settings");
state.selected_settings_index = 3U;
state.audio.tempo_editing = true;
preview(state, "tempo");
state.audio.tempo_editing = false;
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
preview(state, "empty");
auto catalog = std::make_shared<vamp_zero::catalog>();
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn-leaves-electric.json"));
auto title = std::string{score.at("presentation").at("title_image").as_string()};
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = 32U, .title_image = title});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
state.catalog = catalog;
state.selected_song_index = 1U;
preview(state, "songs");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
state.selected_part_index = 1U;
preview(state, "parts");
catalog->groups[0].title = std::string(200U, 'M');
catalog->groups[0].composer = std::string(200U, 'W');
preview(state, "long-title");
}