Shared Catalog

This commit is contained in:
khodowany
2026-09-17 00:44:19 -05:00
parent aa1273b07d
commit 5941a4cd49
44 changed files with 3613 additions and 733 deletions
+4
View File
@@ -7,6 +7,8 @@ Product Vamp is a two-device sheet-music appliance.
The Zero ensures the current SQLite schema through Noto, then imports generated score assets at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities. The Zero ensures the current SQLite schema through Noto, then imports generated score assets at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities.
The catalogue 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 catalogue's musical JSON.
Pico controls: Pico controls:
- Tap: next menu item or next score page. - Tap: next menu item or next score page.
@@ -29,6 +31,8 @@ python3 modulus.py configure --target x86_64_gcc12_release
python3 modulus.py build --target x86_64_gcc12_release python3 modulus.py build --target x86_64_gcc12_release
``` ```
`python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp-zero --once` initializes/imports the database, validates page assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files.
Configure/build can read public packages anonymously from the khodo Conan registry. Configure/build can read public packages anonymously from the khodo Conan registry.
Refresh clangd compile databases, including the Pico firmware database, with: Refresh clangd compile databases, including the Pico firmware database, with:
+9
View File
@@ -18,6 +18,7 @@ Product Vamp follows the Modulus product layout used by the khodo repos:
The Zero process can run headless for local/API quick checks or with Luma for the Waveshare Zero-DISP-7A display. The API is plain HTTP on the private AP: The Zero process can run headless for local/API quick checks or with Luma for the Waveshare Zero-DISP-7A display. The API is plain HTTP on the private AP:
- `GET /api/status` - `GET /api/status`
- `GET /api/health`
- `GET /api/remote/display` - `GET /api/remote/display`
- `POST /api/remote/heartbeat` - `POST /api/remote/heartbeat`
- `POST /api/remote/next` - `POST /api/remote/next`
@@ -32,6 +33,12 @@ On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts wi
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures. At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures.
Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups and practice lessons reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Imports process one file at a time in a transaction; failed imports roll back, and unmanaged rows remain untouched. Legacy metadata is backfilled one score at a time. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it imports and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only.
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.
## Score Generation ## Score Generation
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code. The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code.
@@ -81,6 +88,8 @@ A bundle has three top-level fields:
- `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans. - `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans.
- `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums. - `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums.
Practice metadata includes optional `authored_key_signature`, captured before transposition and shared by all twelve variants. The menu's `SOURCE` label uses this value, not the first variant. Existing bundles without it remain supported and omit the source label. Explicit key-cancellation signatures transpose with the same interval as the music; the engraver cancels only altered pitches that actually change.
Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice. Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice.
The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, and twelve melody/chord practice keys remain automatic. Generated guitar voicings and chord-practice validation share standard six-string tuning (E2 A2 D3 G3 B3 E4), frets 0-15 inclusive, and a maximum four-fret difference between fretted positions; open strings do not count toward that span. The arranger searches all candidates in that range and retains its existing deterministic scoring and voicing policy. Unplayable performance harmonies still produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones. The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, and twelve melody/chord practice keys remain automatic. Generated guitar voicings and chord-practice validation share standard six-string tuning (E2 A2 D3 G3 B3 E4), frets 0-15 inclusive, and a maximum four-fret difference between fretted positions; open strings do not count toward that span. The arranger searches all candidates in that range and retains its existing deterministic scoring and voicing policy. Unplayable performance harmonies still produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones.
+1
View File
@@ -79,6 +79,7 @@ add_executable(product-vamp-pico2w
${VAMP_FONT_SOURCE} ${VAMP_FONT_SOURCE}
src/vamp_pico/display.cpp src/vamp_pico/display.cpp
src/vamp_pico/http_client.cpp src/vamp_pico/http_client.cpp
src/vamp_pico/input.cpp
src/vamp_pico/main.cpp src/vamp_pico/main.cpp
src/vamp_pico/remote_display.cpp src/vamp_pico/remote_display.cpp
src/vamp_pico/touch.cpp src/vamp_pico/touch.cpp
+2
View File
@@ -59,12 +59,14 @@ set(VAMP_CORE_SOURCES
src/vamp_zero/config.cpp src/vamp_zero/config.cpp
src/vamp_zero/database.cpp src/vamp_zero/database.cpp
src/vamp_zero/errors.cpp src/vamp_zero/errors.cpp
src/vamp_zero/health.cpp
src/vamp_zero/library.cpp src/vamp_zero/library.cpp
src/vamp_zero/navigation.cpp src/vamp_zero/navigation.cpp
src/vamp_zero/notation.cpp src/vamp_zero/notation.cpp
src/vamp_zero/page_control.cpp src/vamp_zero/page_control.cpp
src/vamp_zero/portal.cpp src/vamp_zero/portal.cpp
src/vamp_zero/score.cpp src/vamp_zero/score.cpp
src/vamp_zero/score_loader.cpp
src/vamp_zero/types.cpp src/vamp_zero/types.cpp
src/vamp_zero/ui.cpp src/vamp_zero/ui.cpp
) )
+3 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fractions import Fraction from fractions import Fraction
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction, key_from_fifths
from generator.engraving.fonts import Fonts from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, accidental from generator.engraving.notation import ScoreIndex, accidental
from generator.engraving.types import Primitive, bounds, line from generator.engraving.types import Primitive, bounds, line
@@ -32,7 +32,8 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
cancellation = key.get('cancel', 0) cancellation = key.get('cancel', 0)
if cancellation: if cancellation:
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf' order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
steps = [{'letter': c, 'alter': 0} for c in order[:min(7, abs(cancellation))]] + steps old = key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if alteration(old[LETTERS.index(c)]) and alteration(old[LETTERS.index(c)]) != alteration(key['alterations'][LETTERS.index(c)])] + steps
for step in steps: for step in steps:
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source} pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
position = index.pitch_position(pitch, time) position = index.pitch_position(pitch, time)
+3 -1
View File
@@ -24,7 +24,7 @@ def practice_document(document: dict) -> dict:
output_measure['voices'] = voices output_measure['voices'] = voices
measures.append(output_measure) measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', [])) tuplets = reidentify(measures, staff.get('tuplets', []))
return make_arranged_document( result = make_arranged_document(
document, document,
staff, staff,
staff_id="staff-arranged-practice", staff_id="staff-arranged-practice",
@@ -35,6 +35,8 @@ def practice_document(document: dict) -> dict:
tuplets=tuplets, tuplets=tuplets,
octave_shifts=staff.get('octave_shifts', []), octave_shifts=staff.get('octave_shifts', []),
) )
result['metadata'].setdefault('authored_key_signature', document['metadata']['key_signature'])
return result
def key_file_name(key: str) -> str: def key_file_name(key: str) -> str:
return slugify(key.replace("#", "sharp")) + ".json" return slugify(key.replace("#", "sharp")) + ".json"
+6
View File
@@ -78,6 +78,12 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
break break
else: else:
result['custom'] = True result['custom'] = True
if 'cancel' in key:
# A cancellation references the old signature, not a count of naturals.
cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
if cancelled.get('custom'):
raise ValueError('transposed key cancellation cannot be represented by fifths')
result['cancel'] = cancelled['fifths']
return result return result
def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]: def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]:
+2 -1
View File
@@ -23,11 +23,12 @@ public:
auto fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void; auto fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void;
auto draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale = 2) -> void; auto draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale = 2) -> void;
auto show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void; auto show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void;
auto show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void; auto show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active, std::bool_t busy = false) -> void;
private: private:
std::bool_t remote_valid_{}; std::bool_t remote_valid_{};
vamp_style::color last_status_{}; vamp_style::color last_status_{};
std::bool_t last_active_{}; std::bool_t last_active_{};
std::bool_t last_busy_{};
std::array<std::char_t, 96> last_title_{}; std::array<std::char_t, 96> last_title_{};
std::array<std::char_t, 48> last_part_{}; std::array<std::char_t, 48> last_part_{};
std::uint32_t last_page_{}; std::uint32_t last_page_{};
+51 -4
View File
@@ -1,12 +1,17 @@
#pragma once #pragma once
#include <array>
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <optional>
#include <string_view> #include <string_view>
#include <vamp_protocol/page_control.hpp> #include <vamp_pico/input.hpp>
#include <modulus/types.hpp> #include <modulus/types.hpp>
struct tcp_pcb;
namespace vamp_pico { namespace vamp_pico {
struct remote_display_state { struct remote_display_state {
@@ -19,8 +24,50 @@ struct remote_display_state {
}; };
auto parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state &state) -> std::bool_t; auto parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state &state) -> std::bool_t;
auto post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state &state) -> std::bool_t;
auto heartbeat() -> std::bool_t; enum class http_status {
auto fetch_remote_display(vamp_pico::remote_display_state &state) -> std::bool_t; IDLE,
IN_FLIGHT,
READY,
};
enum class http_start_result {
STARTED,
BUSY,
};
struct http_result {
remote_request request{};
std::bool_t success{};
remote_display_state display{};
};
class http_client {
public:
http_client() = default;
~http_client();
http_client(http_client const &) = delete;
auto operator=(http_client const &) -> http_client & = delete;
http_client(http_client &&) = delete;
auto operator=(http_client &&) -> http_client & = delete;
// Accepted requests, including immediate failures, produce exactly one result.
auto start(remote_request request, std::uint64_t now_ms) -> http_start_result;
auto poll(std::uint64_t now_ms) -> http_status;
auto take_result() -> std::optional<http_result>;
// Discards the request/result and returns to IDLE. Call before Wi-Fi deinit.
auto cancel() -> void;
private:
friend struct http_client_callbacks;
tcp_pcb *pcb_{};
http_status status_{http_status::IDLE};
remote_request request_{};
std::bool_t success_{};
std::uint64_t started_ms_{};
std::uint64_t timeout_ms_{};
std::array<std::char_t, 384> request_buffer_{};
std::size_t request_size_{};
std::array<std::char_t, 1024> response_buffer_{};
std::size_t response_size_{};
};
} }
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <vamp_protocol/page_control.hpp>
namespace vamp_pico {
enum class request_kind {
DISPLAY,
COMMAND,
HEARTBEAT,
};
struct remote_request {
request_kind kind{request_kind::DISPLAY};
vamp_protocol::page_command command{vamp_protocol::page_command::NEXT};
};
class gesture_state {
public:
auto update(std::bool_t touching, std::uint64_t now_ms) -> std::optional<vamp_protocol::page_command>;
private:
std::bool_t touching_{};
std::bool_t hold_sent_{};
std::bool_t pending_tap_{};
std::uint64_t down_ms_{};
std::uint64_t last_up_ms_{};
};
enum class enqueue_result {
QUEUED,
BUSY,
};
class request_scheduler {
public:
static constexpr auto command_capacity = std::size_t{16};
auto enqueue(vamp_protocol::page_command command) -> enqueue_result;
auto next(std::uint64_t now_ms) -> std::optional<remote_request>;
auto complete(std::uint64_t now_ms) -> void;
auto queued() const -> std::size_t;
private:
std::array<vamp_protocol::page_command, command_capacity> commands_{};
std::size_t head_{};
std::size_t size_{};
std::bool_t in_flight_{};
std::bool_t completed_{};
std::uint64_t completed_ms_{};
};
}
+1
View File
@@ -18,5 +18,6 @@ auto async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::dat
auto async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>>; auto async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
auto async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>>; auto async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>>;
auto async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>>; auto async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>>;
auto async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>;
} }
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <expected>
#include <vamp_zero/config.hpp>
namespace vamp_zero {
auto check_health(vamp_zero::config const &config) -> std::expected<void, modulus::error>;
}
+6 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <expected> #include <expected>
#include <filesystem>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
@@ -12,9 +13,13 @@
namespace vamp_zero { namespace vamp_zero {
auto parse_score_document(std::string_view json, std::string source_path = {}) -> std::expected<vamp_zero::score_document, modulus::error>; auto parse_score_document(std::string_view json, std::string source_path = {}) -> std::expected<vamp_zero::score_document, modulus::error>;
auto read_score_document(std::filesystem::path const &path) -> std::expected<vamp_zero::score_document, modulus::error>;
auto score_paths(std::string const &assets_path, vamp_zero::score_category category) -> std::expected<std::vector<std::filesystem::path>, modulus::error>;
auto performance_metadata(vamp_zero::score_document const &document) -> vamp_zero::library_part;
auto practice_metadata(vamp_zero::score_document const &document, std::string const &assets_path) -> std::expected<vamp_zero::practice_exercise, modulus::error>;
auto load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error>; auto load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error>;
auto load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error>; auto load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error>;
auto group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state; auto group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state;
auto group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> vamp_zero::practice_library; auto group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> std::expected<vamp_zero::practice_library, modulus::error>;
} }
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <expected>
#include <functional>
#include <memory>
#include <optional>
#include <boost/asio/any_io_executor.hpp>
#include <boost/asio/awaitable.hpp>
#include <modulus.hpp>
#include <vamp_zero/types.hpp>
namespace vamp_zero {
struct score_load_state {
std::shared_ptr<vamp_zero::score_document const> document{};
std::optional<modulus::error> error{};
std::bool_t loading{};
};
class score_loader {
public:
// An already-running completion may overlap stop; capture its consumer weakly.
score_loader(boost::asio::any_io_executor executor,
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load,
std::function<void()> completed = {});
~score_loader();
score_loader(score_loader const &) = delete;
auto operator=(score_loader const &) -> score_loader & = delete;
auto select(std::shared_ptr<vamp_zero::catalogue const> catalog, std::optional<vamp_zero::score_reference> reference) -> void;
auto snapshot() const -> vamp_zero::score_load_state;
auto stop() -> void;
private:
struct state;
std::shared_ptr<state> state_;
};
auto selected_score_reference(vamp_zero::app_state const &state) -> std::optional<vamp_zero::score_reference>;
}
+24 -11
View File
@@ -3,6 +3,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <array> #include <array>
#include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include <string_view> #include <string_view>
@@ -28,6 +29,7 @@ struct score_document {
std::string title{}; std::string title{};
std::string composer{}; std::string composer{};
std::string key_signature{}; std::string key_signature{};
std::string authored_key_signature{};
std::string time_signature{}; std::string time_signature{};
std::string part{}; std::string part{};
std::string arrangement_part_type{}; std::string arrangement_part_type{};
@@ -44,7 +46,6 @@ struct library_part {
std::string key_signature{}; std::string key_signature{};
std::string part{}; std::string part{};
std::string source_path{}; std::string source_path{};
std::string source_json{};
std::size_t measure_count{}; std::size_t measure_count{};
std::size_t total_pages{}; std::size_t total_pages{};
}; };
@@ -52,7 +53,7 @@ struct library_part {
struct song_group { struct song_group {
std::string title{}; std::string title{};
std::string composer{}; std::string composer{};
std::vector<vamp_zero::library_part> parts{}; std::vector<std::size_t> parts{};
}; };
struct library_state { struct library_state {
@@ -65,7 +66,6 @@ struct practice_exercise {
std::string id{}; std::string id{};
std::string key_signature{}; std::string key_signature{};
std::string source_path{}; std::string source_path{};
std::string source_json{};
std::size_t measure_count{}; std::size_t measure_count{};
std::size_t total_pages{}; std::size_t total_pages{};
}; };
@@ -76,18 +76,14 @@ struct practice_exercise {
std::string lesson_group_title{}; std::string lesson_group_title{};
std::string title{}; std::string title{};
std::string composer{}; std::string composer{};
std::string key_signature{}; std::string authored_key_signature{};
std::string source_path{};
std::string source_json{};
std::size_t measure_count{};
std::size_t total_pages{};
std::vector<vamp_zero::practice_exercise::score_variant> variants{}; std::vector<vamp_zero::practice_exercise::score_variant> variants{};
}; };
struct practice_lesson_group { struct practice_lesson_group {
std::string id{}; std::string id{};
std::string title{}; std::string title{};
std::vector<vamp_zero::practice_exercise> exercises{}; std::vector<std::size_t> exercises{};
}; };
struct practice_type_group { struct practice_type_group {
@@ -101,6 +97,24 @@ struct practice_library {
std::vector<vamp_zero::practice_type_group> types{}; std::vector<vamp_zero::practice_type_group> types{};
}; };
struct catalogue {
vamp_zero::library_state performance{};
vamp_zero::practice_library practice{};
};
auto empty_catalogue() -> std::shared_ptr<vamp_zero::catalogue const> const &;
enum class score_category {
PERFORMANCE,
PRACTICE,
};
struct score_reference {
vamp_zero::score_category category{vamp_zero::score_category::PERFORMANCE};
std::string id{};
auto operator==(vamp_zero::score_reference const &) const -> std::bool_t = default;
};
enum class app_mode { enum class app_mode {
MAIN_MENU, MAIN_MENU,
PERFORMANCE_SONG_MENU, PERFORMANCE_SONG_MENU,
@@ -124,8 +138,7 @@ struct audio_state {
}; };
struct app_state { struct app_state {
vamp_zero::library_state performance{}; std::shared_ptr<vamp_zero::catalogue const> catalog{vamp_zero::empty_catalogue()};
vamp_zero::practice_library practice{};
vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU}; vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU};
vamp_zero::audio_state audio{}; vamp_zero::audio_state audio{};
std::size_t selected_main_menu_index{}; std::size_t selected_main_menu_index{};
+10 -24
View File
@@ -1,16 +1,12 @@
#pragma once #pragma once
#include <expected> #include <expected>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <mutex>
#include <optional>
#include <string> #include <string>
#include <boost/asio/any_io_executor.hpp> #include <boost/asio/any_io_executor.hpp>
#include <boost/asio/steady_timer.hpp>
#include <luma.hpp> #include <luma.hpp>
#include <modulus.hpp> #include <modulus.hpp>
@@ -21,7 +17,9 @@
namespace vamp_zero { namespace vamp_zero {
struct score_draw_cache; struct ui_runtime;
class score_loader;
struct ui_state { struct ui_state {
vamp_zero::app_state app{}; vamp_zero::app_state app{};
@@ -32,30 +30,18 @@ struct ui_state {
class zero_ui { class zero_ui {
public: public:
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, 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::function<void(vamp_protocol::page_command)> command_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 run() -> std::expected<void, modulus::error>;
auto stop() -> void; auto stop() -> void;
auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>; auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>;
auto snapshot() const -> vamp_zero::ui_state; auto snapshot() const -> vamp_zero::ui_state;
private: private:
auto handle_touch(luma::touch_event const &event) -> std::bool_t; std::shared_ptr<luma::application> application_;
auto send_touch_command(vamp_protocol::page_command command) -> void; std::shared_ptr<vamp_zero::ui_runtime> runtime_;
auto arm_pending_tap() -> void; std::shared_ptr<vamp_zero::score_loader> score_loader_;
auto cancel_pending_tap_locked() -> void;
auto make_root(vamp_zero::ui_state const &state) -> std::expected<std::unique_ptr<luma::control>, modulus::error>;
boost::asio::any_io_executor executor_;
boost::asio::steady_timer tap_timer_;
luma::application application_;
std::function<void(vamp_protocol::page_command)> command_handler_{};
std::shared_ptr<vamp_zero::score_draw_cache> score_cache_{};
mutable std::mutex mutex_{};
vamp_zero::ui_state state_{};
mutable std::mutex touch_mutex_{};
std::optional<std::int32_t> active_touch_id_{};
std::chrono::steady_clock::time_point touch_down_time_{};
std::chrono::steady_clock::time_point last_tap_time_{};
std::bool_t hold_sent_{};
std::bool_t pending_tap_{};
}; };
auto make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state; auto make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state;
+1 -1
View File
@@ -94,7 +94,7 @@
"executable": "vamp-zero", "executable": "vamp-zero",
"args": [ "args": [
"--config", "--config",
"package/config/vamp.example.json", "package/config/vamp.development.json",
"--once" "--once"
], ],
"ports": [ "ports": [
+20
View File
@@ -0,0 +1,20 @@
{
"runtime": {
"headless": true,
"assets_path": "build/build_x86_debug/generated-assets",
"heartbeat_path": "build/build_x86_debug/runtime/heartbeat.json"
},
"database": {
"path": "build/build_x86_debug/runtime/vamp.db"
},
"control": {
"endpoint": {
"name": "product-vamp-control",
"protocol": "http",
"bind": "127.0.0.1:8080"
}
},
"audio": {
"enabled": false
}
}
+8 -5
View File
@@ -250,23 +250,23 @@ auto vamp_pico::display::show_boot(std::char_t const *line1, std::char_t const *
} }
} }
auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void { auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active, std::bool_t busy) -> void {
auto const *title_text = title == nullptr ? "" : title; auto const *title_text = title == nullptr ? "" : title;
auto const *part_text = part == nullptr ? "" : part; auto const *part_text = part == nullptr ? "" : part;
auto status_changed = !remote_valid_ || !same_color(last_status_, status); auto status_changed = !remote_valid_ || !same_color(last_status_, status) || last_busy_ != busy;
auto content_changed = !remote_valid_ || last_active_ != active || !same_text(last_title_, title_text) || auto content_changed = !remote_valid_ || last_active_ != active || last_busy_ != busy || !same_text(last_title_, title_text) ||
!same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages; !same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages;
if (!remote_valid_) { if (!remote_valid_) {
fill(vamp_style::rgb565(vamp_style::background)); fill(vamp_style::rgb565(vamp_style::background));
} }
if (status_changed) { if (status_changed) {
fill_rect(0, 0, vamp_pico::display_width, 24, vamp_style::rgb565(status)); fill_rect(0, 0, vamp_pico::display_width, 24, vamp_style::rgb565(busy ? vamp_style::danger : status));
} }
if (content_changed) { if (content_changed) {
fill_rect(0, 24, vamp_pico::display_width, vamp_pico::display_height - 24, vamp_style::rgb565(vamp_style::background)); fill_rect(0, 24, vamp_pico::display_width, vamp_pico::display_height - 24, vamp_style::rgb565(vamp_style::background));
if (active && title_text[0] != '\0') { if (active && title_text[0] != '\0') {
draw_centered_text(*this, 66, title_text, vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24); draw_centered_text(*this, 66, title_text, vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24);
draw_centered_text(*this, 118, part_text, vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24); draw_centered_text(*this, 118, busy ? "BUSY" : part_text, vamp_style::rgb565(busy ? vamp_style::ink : vamp_style::muted), 1, vamp_pico::display_width - 24);
if (pages != 0U) { if (pages != 0U) {
auto page_text = std::array<std::char_t, 32>{}; auto page_text = std::array<std::char_t, 32>{};
auto written = std::snprintf(page_text.data(), page_text.size(), "PAGE %" PRIu32 "/%" PRIu32, page, pages); auto written = std::snprintf(page_text.data(), page_text.size(), "PAGE %" PRIu32 "/%" PRIu32, page, pages);
@@ -274,10 +274,13 @@ auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const
draw_centered_text(*this, 168, page_text.data(), vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24); draw_centered_text(*this, 168, page_text.data(), vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24);
} }
} }
} else if (busy) {
draw_centered_text(*this, 118, "BUSY", vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24);
} }
} }
last_status_ = status; last_status_ = status;
last_active_ = active; last_active_ = active;
last_busy_ = busy;
copy_text(last_title_, title_text); copy_text(last_title_, title_text);
copy_text(last_part_, part_text); copy_text(last_part_, part_text);
last_page_ = page; last_page_ = page;
+127 -151
View File
@@ -1,33 +1,33 @@
#include <algorithm>
#include <array>
#include <cstdio> #include <cstdio>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <lwip/ip_addr.h> #include <lwip/ip_addr.h>
#include <lwip/pbuf.h> #include <lwip/pbuf.h>
#include <lwip/tcp.h> #include <lwip/tcp.h>
#include <pico/cyw43_arch.h> #include <pico/cyw43_arch.h>
#include <pico/stdlib.h>
#include <vamp_pico/http_client.hpp> #include <vamp_pico/http_client.hpp>
namespace { namespace {
struct request_state { class lwip_lock {
tcp_pcb *pcb{}; public:
std::bool_t done{}; lwip_lock() {
std::bool_t status_known{}; cyw43_arch_lwip_begin();
std::bool_t success{}; }
std::char_t request[384]{}; ~lwip_lock() {
std::size_t request_size{}; cyw43_arch_lwip_end();
std::char_t response[1024]{}; }
std::size_t response_size{}; lwip_lock(lwip_lock const &) = delete;
auto operator=(lwip_lock const &) -> lwip_lock & = delete;
}; };
auto response_is_success(std::char_t const *data, std::size_t size) -> std::bool_t { auto response_is_success(std::string_view response) -> std::bool_t {
return size >= 10U && (std::memcmp(data, "HTTP/1.1 2", 10U) == 0 || std::memcmp(data, "HTTP/1.0 2", 10U) == 0); return response.size() >= 13U &&
(response.substr(0U, 10U) == "HTTP/1.1 2" || response.substr(0U, 10U) == "HTTP/1.0 2") &&
response[10] >= '0' && response[10] <= '9' && response[11] >= '0' && response[11] <= '9' &&
response[12] == ' ' && response.find("\r\n\r\n") != std::string_view::npos;
} }
auto clear_callbacks(tcp_pcb *pcb) -> void { auto clear_callbacks(tcp_pcb *pcb) -> void {
@@ -38,166 +38,142 @@ auto clear_callbacks(tcp_pcb *pcb) -> void {
tcp_err(pcb, nullptr); tcp_err(pcb, nullptr);
} }
auto close_request(request_state &state, std::bool_t success) -> err_t {
state.success = success;
state.done = true;
if (state.pcb == nullptr) {
return ERR_OK;
}
auto *pcb = state.pcb;
state.pcb = nullptr;
clear_callbacks(pcb);
if (tcp_close(pcb) == ERR_OK) {
return ERR_OK;
}
tcp_abort(pcb);
return ERR_ABRT;
} }
auto append_response(request_state &state, pbuf *buffer) -> void { namespace vamp_pico {
auto remaining = sizeof(state.response) - state.response_size - 1U;
if (remaining != 0U) {
auto copied = pbuf_copy_partial(buffer, state.response + state.response_size, std::min<std::uint16_t>(buffer->tot_len, static_cast<std::uint16_t>(remaining)), 0);
state.response_size += copied;
state.response[state.response_size] = '\0';
}
if (!state.status_known && state.response_size >= 10U) {
state.status_known = true;
state.success = response_is_success(state.response, state.response_size);
}
}
auto handle_receive(void *argument, tcp_pcb *pcb, pbuf *buffer, err_t error) -> err_t { struct http_client_callbacks {
auto *state = static_cast<request_state *>(argument); static auto finish(http_client &client, std::bool_t success) -> std::int8_t {
if (state == nullptr) { client.success_ = success;
if (buffer != nullptr) { client.status_ = http_status::READY;
pbuf_free(buffer); if (client.pcb_ == nullptr) {
return ERR_OK;
}
auto *pcb = client.pcb_;
client.pcb_ = nullptr;
clear_callbacks(pcb);
if (success && tcp_close(pcb) == ERR_OK) {
return ERR_OK;
}
tcp_abort(pcb);
return ERR_ABRT;
}
static auto receive(void *argument, tcp_pcb *pcb, pbuf *buffer, std::int8_t error) -> std::int8_t {
auto &client = *static_cast<http_client *>(argument);
if (buffer == nullptr) {
auto response = std::string_view{client.response_buffer_.data(), client.response_size_};
return finish(client, error == ERR_OK && response_is_success(response));
}
auto size = buffer->tot_len;
auto remaining = client.response_buffer_.size() - client.response_size_;
auto valid = error == ERR_OK && size <= remaining;
if (valid) {
auto copied = pbuf_copy_partial(buffer, client.response_buffer_.data() + client.response_size_, size, 0);
client.response_size_ += copied;
valid = copied == size;
}
tcp_recved(pcb, size);
pbuf_free(buffer);
return valid ? ERR_OK : finish(client, false);
}
static auto connected(void *argument, tcp_pcb *pcb, std::int8_t error) -> std::int8_t {
auto &client = *static_cast<http_client *>(argument);
if (error != ERR_OK) {
return finish(client, false);
}
tcp_recv(pcb, receive);
if (tcp_write(pcb, client.request_buffer_.data(), static_cast<std::uint16_t>(client.request_size_), TCP_WRITE_FLAG_COPY) != ERR_OK) {
return finish(client, false);
}
if (tcp_output(pcb) != ERR_OK) {
return finish(client, false);
} }
return ERR_OK; return ERR_OK;
} }
if (buffer == nullptr) { static auto error(void *argument, std::int8_t) -> void {
return close_request(*state, state->status_known && state->success); auto &client = *static_cast<http_client *>(argument);
client.pcb_ = nullptr;
client.success_ = false;
client.status_ = http_status::READY;
} }
if (error == ERR_OK) { };
append_response(*state, buffer);
}
tcp_recved(pcb, buffer->tot_len);
pbuf_free(buffer);
return ERR_OK;
} }
auto handle_connected(void *argument, tcp_pcb *pcb, err_t error) -> err_t { vamp_pico::http_client::~http_client() {
auto *state = static_cast<request_state *>(argument); cancel();
if (state == nullptr) {
return ERR_ARG;
}
if (error != ERR_OK) {
auto closed = close_request(*state, false);
return closed == ERR_ABRT ? closed : error;
}
tcp_arg(pcb, state);
tcp_recv(pcb, handle_receive);
auto written = tcp_write(pcb, state->request, static_cast<std::uint16_t>(state->request_size), TCP_WRITE_FLAG_COPY);
if (written != ERR_OK) {
auto closed = close_request(*state, false);
return closed == ERR_ABRT ? closed : written;
}
auto output = tcp_output(pcb);
if (output != ERR_OK) {
auto closed = close_request(*state, false);
return closed == ERR_ABRT ? closed : output;
}
return ERR_OK;
} }
auto handle_error(void *argument, err_t) -> void { auto vamp_pico::http_client::start(remote_request request, std::uint64_t now_ms) -> http_start_result {
auto *state = static_cast<request_state *>(argument); auto lock = lwip_lock{};
if (state == nullptr) { if (status_ != http_status::IDLE) {
return; return http_start_result::BUSY;
}
request_ = request;
status_ = http_status::IN_FLIGHT;
success_ = false;
response_size_ = 0U;
started_ms_ = now_ms;
timeout_ms_ = request.kind == request_kind::DISPLAY ? 1000U : 700U;
auto const *path = vamp_protocol::remote_display_path;
if (request.kind == request_kind::COMMAND) {
path = vamp_protocol::page_command_path(request.command);
} else if (request.kind == request_kind::HEARTBEAT) {
path = vamp_protocol::remote_heartbeat_path;
} }
state->pcb = nullptr;
state->success = false;
state->done = true;
}
auto run_request(std::char_t const *method, std::char_t const *path, std::char_t const *body, std::char_t *response, std::size_t response_capacity, std::uint32_t timeout_ms) -> std::bool_t {
auto state = request_state{};
auto body_size = body == nullptr ? std::size_t{} : std::strlen(body);
auto written = std::int32_t{}; auto written = std::int32_t{};
if (body_size == 0U) { if (request.kind == request_kind::DISPLAY) {
written = std::snprintf(state.request, sizeof(state.request), "%s %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", method, path, VAMP_ZERO_HOST, VAMP_ZERO_PORT); written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "GET %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT);
} else { } else {
written = std::snprintf(state.request, sizeof(state.request), "%s %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: %zu\r\nContent-Type: application/json\r\nConnection: " written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: 2\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT);
"close\r\n\r\n%s",
method, path, VAMP_ZERO_HOST, VAMP_ZERO_PORT, body_size, body);
} }
if (written <= 0 || static_cast<std::size_t>(written) >= sizeof(state.request)) {
return false;
}
state.request_size = static_cast<std::size_t>(written);
auto address = ip_addr_t{}; auto address = ip_addr_t{};
if (ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) { if (written <= 0 || static_cast<std::size_t>(written) >= request_buffer_.size() || ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) {
return false; http_client_callbacks::finish(*this, false);
return http_start_result::STARTED;
} }
cyw43_arch_lwip_begin(); request_size_ = static_cast<std::size_t>(written);
state.pcb = tcp_new_ip_type(IPADDR_TYPE_V4); pcb_ = tcp_new_ip_type(IPADDR_TYPE_V4);
if (state.pcb == nullptr) { if (pcb_ == nullptr) {
cyw43_arch_lwip_end(); http_client_callbacks::finish(*this, false);
return false; return http_start_result::STARTED;
} }
tcp_arg(state.pcb, &state); tcp_arg(pcb_, this);
tcp_err(state.pcb, handle_error); tcp_err(pcb_, http_client_callbacks::error);
auto connected = tcp_connect(state.pcb, &address, static_cast<std::uint16_t>(VAMP_ZERO_PORT), handle_connected); if (tcp_connect(pcb_, &address, static_cast<std::uint16_t>(VAMP_ZERO_PORT), http_client_callbacks::connected) != ERR_OK) {
if (connected != ERR_OK) { http_client_callbacks::finish(*this, false);
close_request(state, false);
} }
cyw43_arch_lwip_end(); return http_start_result::STARTED;
if (connected != ERR_OK) {
return false;
}
auto deadline = make_timeout_time_ms(timeout_ms);
while (!state.done && !time_reached(deadline)) {
sleep_ms(5);
}
if (!state.done) {
cyw43_arch_lwip_begin();
close_request(state, false);
cyw43_arch_lwip_end();
}
if (response != nullptr && response_capacity != 0U) {
auto count = std::min(response_capacity - 1U, state.response_size);
std::memcpy(response, state.response, count);
response[count] = '\0';
}
return state.success;
} }
auto body_start(std::char_t const *response) -> std::char_t const * { auto vamp_pico::http_client::poll(std::uint64_t now_ms) -> http_status {
auto *marker = std::strstr(response, "\r\n\r\n"); auto lock = lwip_lock{};
return marker == nullptr ? response : marker + 4; if (status_ == http_status::IN_FLIGHT && now_ms - started_ms_ >= timeout_ms_) {
} http_client_callbacks::finish(*this, false);
}
auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state &state) -> std::bool_t {
auto response = std::array<std::char_t, 1024>{};
if (!run_request("POST", vamp_protocol::page_command_path(command), "{}", response.data(), response.size(), 700)) {
return false;
} }
if (vamp_pico::parse_remote_display_body(body_start(response.data()), state)) { return status_;
return true;
}
return vamp_pico::fetch_remote_display(state);
} }
auto vamp_pico::heartbeat() -> std::bool_t { auto vamp_pico::http_client::take_result() -> std::optional<http_result> {
return run_request("POST", vamp_protocol::remote_heartbeat_path, "{}", nullptr, 0, 700); auto lock = lwip_lock{};
if (status_ != http_status::READY) {
return std::nullopt;
}
auto result = http_result{};
result.request = request_;
result.success = success_;
if (result.success && result.request.kind != request_kind::HEARTBEAT) {
auto text = std::string_view{response_buffer_.data(), response_size_};
auto marker = text.find("\r\n\r\n");
result.success = marker != std::string_view::npos && parse_remote_display_body(text.substr(marker + 4U), result.display);
}
status_ = http_status::IDLE;
return result;
} }
auto vamp_pico::fetch_remote_display(vamp_pico::remote_display_state &state) -> std::bool_t { auto vamp_pico::http_client::cancel() -> void {
auto response = std::array<std::char_t, 1024>{}; auto lock = lwip_lock{};
if (!run_request("GET", vamp_protocol::remote_display_path, nullptr, response.data(), response.size(), 1000)) { http_client_callbacks::finish(*this, false);
return false; status_ = http_status::IDLE;
} response_size_ = 0U;
return vamp_pico::parse_remote_display_body(body_start(response.data()), state);
} }
+77
View File
@@ -0,0 +1,77 @@
#include <vamp_pico/input.hpp>
auto vamp_pico::gesture_state::update(std::bool_t touching, std::uint64_t now_ms) -> std::optional<vamp_protocol::page_command> {
constexpr auto hold_ms = std::uint64_t{700};
constexpr auto double_tap_ms = std::uint64_t{220};
auto command = std::optional<vamp_protocol::page_command>{};
if (pending_tap_ && !touching_ && now_ms - last_up_ms_ > double_tap_ms) {
pending_tap_ = false;
command = vamp_protocol::page_command::NEXT;
}
if (touching && !touching_) {
touching_ = true;
hold_sent_ = false;
down_ms_ = now_ms;
}
if (touching_ && !hold_sent_ && now_ms - down_ms_ >= hold_ms) {
hold_sent_ = true;
pending_tap_ = false;
command = vamp_protocol::page_command::SELECT;
}
if (!touching && touching_) {
touching_ = false;
if (!hold_sent_) {
if (pending_tap_ && now_ms - last_up_ms_ <= double_tap_ms) {
pending_tap_ = false;
command = vamp_protocol::page_command::PREVIOUS;
} else {
if (pending_tap_) {
command = vamp_protocol::page_command::NEXT;
}
pending_tap_ = true;
last_up_ms_ = now_ms;
}
}
}
return command;
}
auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) -> enqueue_result {
if (size_ == commands_.size()) {
return enqueue_result::BUSY;
}
commands_[(head_ + size_) % commands_.size()] = command;
++size_;
return enqueue_result::QUEUED;
}
auto vamp_pico::request_scheduler::next(std::uint64_t now_ms) -> std::optional<remote_request> {
if (in_flight_) {
return std::nullopt;
}
auto request = remote_request{};
if (size_ != 0U) {
request.kind = request_kind::COMMAND;
request.command = commands_[head_];
head_ = (head_ + 1U) % commands_.size();
--size_;
} else if (completed_ && now_ms - completed_ms_ < 1000U) {
return std::nullopt;
}
in_flight_ = true;
return request;
}
auto vamp_pico::request_scheduler::complete(std::uint64_t now_ms) -> void {
if (!in_flight_) {
return;
}
in_flight_ = false;
completed_ = true;
completed_ms_ = now_ms;
}
auto vamp_pico::request_scheduler::queued() const -> std::size_t {
return size_;
}
+29 -68
View File
@@ -3,6 +3,7 @@
#include <vamp_pico/display.hpp> #include <vamp_pico/display.hpp>
#include <vamp_pico/http_client.hpp> #include <vamp_pico/http_client.hpp>
#include <vamp_pico/input.hpp>
#include <vamp_pico/touch.hpp> #include <vamp_pico/touch.hpp>
#include <vamp_protocol/page_control.hpp> #include <vamp_protocol/page_control.hpp>
@@ -15,14 +16,6 @@ enum class link_status {
CONNECTED, CONNECTED,
}; };
struct gesture_state {
std::bool_t touching{};
std::bool_t hold_sent{};
std::bool_t pending_tap{};
absolute_time_t down_time{};
absolute_time_t last_up_time{};
};
auto color_for(link_status status) -> vamp_style::color { auto color_for(link_status status) -> vamp_style::color {
switch (status) { switch (status) {
case link_status::CONNECTING: case link_status::CONNECTING:
@@ -35,12 +28,8 @@ auto color_for(link_status status) -> vamp_style::color {
return vamp_style::retrying; return vamp_style::retrying;
} }
auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote) -> void { auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote, std::bool_t busy = false) -> void {
display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active); display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active, busy);
}
auto clear_remote(vamp_pico::remote_display_state &remote) -> void {
remote = vamp_pico::remote_display_state{};
} }
auto connect_wifi(vamp_pico::display &display) -> std::bool_t { auto connect_wifi(vamp_pico::display &display) -> std::bool_t {
@@ -60,49 +49,6 @@ auto connect_wifi(vamp_pico::display &display) -> std::bool_t {
return true; return true;
} }
auto send_command(vamp_protocol::page_command command, link_status &status, vamp_pico::remote_display_state &remote, vamp_pico::display &display) -> void {
if (!vamp_pico::post_remote_command(command, remote)) {
status = link_status::RETRYING;
clear_remote(remote);
render(display, status, remote);
return;
}
status = link_status::CONNECTED;
render(display, status, remote);
}
auto update_gesture(gesture_state &gesture, std::bool_t touching, link_status &status, vamp_pico::remote_display_state &remote, vamp_pico::display &display) -> void {
constexpr auto hold_us = std::int64_t{700000};
constexpr auto double_tap_us = std::int64_t{220000};
auto now = get_absolute_time();
if (touching && !gesture.touching) {
gesture.touching = true;
gesture.hold_sent = false;
gesture.down_time = now;
}
if (touching && !gesture.hold_sent && absolute_time_diff_us(gesture.down_time, now) >= hold_us) {
gesture.hold_sent = true;
gesture.pending_tap = false;
send_command(vamp_protocol::page_command::SELECT, status, remote, display);
}
if (!touching && gesture.touching) {
gesture.touching = false;
if (!gesture.hold_sent) {
if (gesture.pending_tap && absolute_time_diff_us(gesture.last_up_time, now) <= double_tap_us) {
gesture.pending_tap = false;
send_command(vamp_protocol::page_command::PREVIOUS, status, remote, display);
} else {
gesture.pending_tap = true;
gesture.last_up_time = now;
}
}
}
if (!touching && gesture.pending_tap && absolute_time_diff_us(gesture.last_up_time, now) > double_tap_us) {
gesture.pending_tap = false;
send_command(vamp_protocol::page_command::NEXT, status, remote, display);
}
}
} }
auto main() -> int { auto main() -> int {
@@ -118,25 +64,40 @@ auto main() -> int {
} }
auto remote = vamp_pico::remote_display_state{}; auto remote = vamp_pico::remote_display_state{};
auto status = link_status::RETRYING; auto status = link_status::RETRYING;
auto gesture = gesture_state{}; auto gesture = vamp_pico::gesture_state{};
auto last_poll = get_absolute_time(); auto scheduler = vamp_pico::request_scheduler{};
auto poll_now = std::bool_t{true}; // Keep the owned request buffers off the Pico's small main stack.
static auto client = vamp_pico::http_client{};
auto busy_until_ms = std::uint64_t{};
render(display, status, remote); render(display, status, remote);
while (true) { while (true) {
auto point = vamp_pico::touch_point{}; auto point = vamp_pico::touch_point{};
update_gesture(gesture, touch.read(point), status, remote, display); auto touching = touch.read(point);
auto now = get_absolute_time(); auto now_ms = time_us_64() / 1000U;
if (poll_now || absolute_time_diff_us(last_poll, now) >= 1000000) { if (auto command = gesture.update(touching, now_ms); command) {
poll_now = false; if (scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) {
last_poll = now; busy_until_ms = now_ms + 1000U;
if (vamp_pico::fetch_remote_display(remote)) { }
}
auto network = client.poll(now_ms);
if (network == vamp_pico::http_status::READY) {
auto result = client.take_result();
scheduler.complete(now_ms);
network = vamp_pico::http_status::IDLE;
if (result && result->success) {
remote = result->display;
status = link_status::CONNECTED; status = link_status::CONNECTED;
} else { } else {
status = link_status::RETRYING; status = link_status::RETRYING;
clear_remote(remote); remote = vamp_pico::remote_display_state{};
} }
render(display, status, remote);
} }
if (network == vamp_pico::http_status::IDLE) {
if (auto request = scheduler.next(now_ms); request) {
client.start(*request, now_ms);
}
}
render(display, status, remote, now_ms < busy_until_ms);
sleep_ms(8); sleep_ms(8);
} }
} }
+133 -17
View File
@@ -1,6 +1,8 @@
#include <csignal> #include <csignal>
#include <cerrno>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <cstdlib>
#include <expected> #include <expected>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -10,11 +12,13 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <system_error>
#include <thread> #include <thread>
#include <utility> #include <utility>
#include <boost/asio/co_spawn.hpp> #include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp> #include <boost/asio/io_context.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/signal_set.hpp> #include <boost/asio/signal_set.hpp>
#include <boost/asio/steady_timer.hpp> #include <boost/asio/steady_timer.hpp>
#include <boost/asio/use_future.hpp> #include <boost/asio/use_future.hpp>
@@ -29,9 +33,11 @@
#include <vamp_zero/audio.hpp> #include <vamp_zero/audio.hpp>
#include <vamp_zero/database.hpp> #include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/health.hpp>
#include <vamp_zero/library.hpp> #include <vamp_zero/library.hpp>
#include <vamp_zero/page_control.hpp> #include <vamp_zero/page_control.hpp>
#include <vamp_zero/portal.hpp> #include <vamp_zero/portal.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/ui.hpp> #include <vamp_zero/ui.hpp>
@@ -65,17 +71,19 @@ auto write_text_atomic(std::filesystem::path const &destination, std::string con
temporary += ".tmp"; temporary += ".tmp";
auto output = std::ofstream{temporary, std::ios::binary | std::ios::trunc}; auto output = std::ofstream{temporary, std::ios::binary | std::ios::trunc};
if (!output) { if (!output) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create heartbeat: " + temporary.string())); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime file: " + temporary.string()));
} }
output << text; output << text;
output.close(); output.close();
if (!output) { if (!output) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to write heartbeat: " + temporary.string())); std::filesystem::remove(temporary, error);
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to write runtime file: " + temporary.string()));
} }
std::filesystem::rename(temporary, destination, error); std::filesystem::rename(temporary, destination, error);
if (error) { if (error) {
auto message = error.message();
std::filesystem::remove(temporary, error); std::filesystem::remove(temporary, error);
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish heartbeat: " + error.message())); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish runtime file: " + message));
} }
return {}; return {};
} }
@@ -128,8 +136,10 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
return std::unexpected(practice.error()); return std::unexpected(practice.error());
} }
auto state = vamp_zero::app_state{ auto state = vamp_zero::app_state{
.performance = std::move(*performance), .catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{
.practice = std::move(*practice), .performance = std::move(*performance),
.practice = std::move(*practice),
}),
.audio = .audio =
{ {
.track_enabled = config.audio.track_enabled, .track_enabled = config.audio.track_enabled,
@@ -146,6 +156,102 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
return state; return state;
} }
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto score = co_await vamp_zero::async_load_score(executor, config, std::move(reference));
if (!score) {
co_return std::unexpected(score.error());
}
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) {
auto path = vamp_zero::validated_page_path(*score, page);
if (!path) {
co_return std::unexpected(path.error());
}
}
co_return std::expected<void, modulus::error>{};
}
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (auto const &part : catalog.performance.parts) {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
if (!valid) {
co_return valid;
}
}
for (auto const &exercise : catalog.practice.exercises) {
for (auto const &variant : exercise.variants) {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = variant.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
if (!valid) {
co_return valid;
}
}
}
co_return std::expected<void, modulus::error>{};
}
auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
if (directory.empty()) {
directory = ".";
}
auto error = std::error_code{};
std::filesystem::create_directories(directory, error);
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message()));
}
auto pattern = (directory / ".vamp-readiness-XXXXXX").string();
if (::mkdtemp(pattern.data()) == nullptr) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE,
"failed to create runtime probe: " + std::error_code{errno, std::generic_category()}.message()));
}
struct cleanup {
std::filesystem::path path;
~cleanup() {
auto ignored = std::error_code{};
std::filesystem::remove_all(path, ignored);
}
} probe{pattern};
auto written = write_text_atomic(probe.path / "probe", "ready\n");
if (!written) {
return written;
}
std::filesystem::remove_all(probe.path, error);
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to remove runtime probe: " + error.message()));
}
return {};
}
auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config.database, catalog), boost::asio::use_future);
io.run();
auto valid = future.get();
if (!valid) {
return valid;
}
auto runtime = probe_runtime_directory(config);
if (!runtime) {
return runtime;
}
auto endpoint = make_server_endpoint(config);
if (!endpoint) {
return std::unexpected(endpoint.error());
}
io.restart();
auto server = ligo::http::server{io, *endpoint, {}, ligo::http::server_options_from_config(config.control.endpoint)};
auto started = server.start();
if (!started) {
return started;
}
server.stop();
io.run();
return {};
}
auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> { auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto endpoint = make_server_endpoint(config); auto endpoint = make_server_endpoint(config);
@@ -183,7 +289,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
if (!endpoint) { if (!endpoint) {
return std::unexpected(endpoint.error()); return std::unexpected(endpoint.error());
} }
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, [&state](vamp_protocol::page_command command) { auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, [&state](vamp_protocol::page_command command) {
static_cast<void>(state.apply(command)); static_cast<void>(state.apply(command));
}}; }};
state.set_observer([&ui, &config, audio](vamp_zero::app_state app) { state.set_observer([&ui, &config, audio](vamp_zero::app_state app) {
@@ -237,9 +343,11 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
io.run(); io.run();
}}; }};
auto ui_status = ui.run(); auto ui_status = ui.run();
server.stop(); boost::asio::post(io, [&] {
connection_timer.cancel(); connection_timer.cancel();
io.stop(); server.stop();
io.stop();
});
if (io_thread.joinable()) { if (io_thread.joinable()) {
io_thread.join(); io_thread.join();
} }
@@ -308,22 +416,22 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
if (!config) { if (!config) {
return std::unexpected(config.error()); return std::unexpected(config.error());
} }
if (command.health_check) {
auto healthy = vamp_zero::check_health(*config);
if (!healthy) {
return healthy;
}
std::cout << "ok\n";
return {};
}
modulus::configure(config->modulus); modulus::configure(config->modulus);
if (command.check_config) { if (command.check_config) {
std::cout << "ok\n"; std::cout << "ok\n";
return {}; return {};
} }
if (command.health_check) {
std::cout << "ok\n";
return {};
}
if (command.ensure_database) { if (command.ensure_database) {
return ensure_database(*config); return ensure_database(*config);
} }
if (command.exit_after_startup || config->runtime.exit_after_startup) {
std::cout << "ok\n";
return {};
}
auto ensured = ensure_database(*config); auto ensured = ensure_database(*config);
if (!ensured) { if (!ensured) {
return ensured; return ensured;
@@ -336,6 +444,14 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
if (!initial) { if (!initial) {
return std::unexpected(initial.error()); return std::unexpected(initial.error());
} }
if (command.exit_after_startup || config->runtime.exit_after_startup) {
auto ready = check_readiness(*config, *initial->catalog);
if (!ready) {
return ready;
}
std::cout << "ok\n";
return {};
}
auto state = vamp_zero::control_state{*config, std::move(*initial)}; auto state = vamp_zero::control_state{*config, std::move(*initial)};
auto audio_controller = std::unique_ptr<vamp_zero::audio_controller>{}; auto audio_controller = std::unique_ptr<vamp_zero::audio_controller>{};
auto audio = vamp_zero::create_audio_controller(config->audio); auto audio = vamp_zero::create_audio_controller(config->audio);
+155 -37
View File
@@ -4,12 +4,15 @@
#include <filesystem> #include <filesystem>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <map>
#include <utility> #include <utility>
#include <variant> #include <variant>
#include <vector> #include <vector>
#include <boost/asio/any_io_executor.hpp> #include <boost/asio/any_io_executor.hpp>
#include <boost/asio/awaitable.hpp> #include <boost/asio/awaitable.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <noto.hpp> #include <noto.hpp>
@@ -189,6 +192,18 @@ CREATE TABLE IF NOT EXISTS practice_exercises (
"NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)", "NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)", "CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)",
}; };
for (auto table : {"songs", "practice_exercises"}) {
for (auto column : {"measure_count", "total_pages", "authored_key_signature"}) {
if (!ensured) {
break;
}
auto type = std::string_view{column} == "authored_key_signature" ? " TEXT" : " INTEGER CHECK(" + std::string{column} + " > 0)";
ensured = co_await ensure_column(connection, table, column, "ALTER TABLE " + std::string{table} + " ADD COLUMN " + column + type);
}
if (ensured) {
ensured = co_await execute_schema_statement(connection, "CREATE TRIGGER IF NOT EXISTS invalidate_" + std::string{table} + "_metadata AFTER UPDATE OF source_json ON " + table + " WHEN NEW.source_json != OLD.source_json BEGIN UPDATE " + table + " SET measure_count = NULL, total_pages = NULL, authored_key_signature = NULL WHERE id = NEW.id; END");
}
}
for (auto &statement : index_statements) { for (auto &statement : index_statements) {
if (!ensured) { if (!ensured) {
break; break;
@@ -240,6 +255,39 @@ auto now_ms() -> std::int64_t {
.count(); .count();
} }
auto backfill_metadata(noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (;;) {
auto row = co_await connection.async_execute("SELECT id, source_path, source_json FROM " + table + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
if (!row) {
co_return std::unexpected(row.error());
}
if (row->rows.empty()) {
co_return std::expected<void, modulus::error>{};
}
auto const &values = row->rows.front();
auto document = vamp_zero::parse_score_document(text_value(values[2]), text_value(values[1]));
if (!document) {
co_return std::unexpected(document.error());
}
auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{static_cast<std::int64_t>(document->measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(document->pages.size())},
noto::sqlite_value{document->authored_key_signature},
noto::sqlite_value{text_value(values[0])},
noto::sqlite_value{text_value(values[2])},
};
auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND source_json = ?5", std::move(params));
if (!updated) {
co_return std::unexpected(updated.error());
}
}
}
auto count_value(noto::sqlite_value const &value) -> std::size_t {
auto count = std::get_if<std::int64_t>(&value.value);
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
}
auto execute_delete_stale_catalog_rows(noto::connection &connection, std::string_view table, std::vector<std::string> const &ids) -> boost::asio::awaitable<std::expected<void, modulus::error>> { auto execute_delete_stale_catalog_rows(noto::connection &connection, std::string_view table, std::vector<std::string> const &ids) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto sql = std::string{"DELETE FROM "}; auto sql = std::string{"DELETE FROM "};
sql += table; sql += table;
@@ -321,11 +369,11 @@ auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vam
} }
auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>> { auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto performance = vamp_zero::load_performance_library(assets_path); auto performance = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
if (!performance) { if (!performance) {
co_return std::unexpected(performance.error()); co_return std::unexpected(performance.error());
} }
auto practice = vamp_zero::load_practice_library(assets_path); auto practice = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
if (!practice) { if (!practice) {
co_return std::unexpected(practice.error()); co_return std::unexpected(practice.error());
} }
@@ -341,11 +389,17 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
auto imported = std::expected<void, modulus::error>{}; auto imported = std::expected<void, modulus::error>{};
auto timestamp = now_ms(); auto timestamp = now_ms();
auto song_ids = std::vector<std::string>{}; auto song_ids = std::vector<std::string>{};
song_ids.reserve(performance->parts.size()); song_ids.reserve(performance->size());
for (auto const &part : performance->parts) { for (auto const &path : *performance) {
if (!imported) { if (!imported) {
break; break;
} }
auto document = vamp_zero::read_score_document(path);
if (!document) {
imported = std::unexpected(document.error());
break;
}
auto const &part = *document;
song_ids.push_back(part.id); song_ids.push_back(part.id);
auto params = std::vector<noto::sqlite_value>{ auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{part.id}, noto::sqlite_value{part.id},
@@ -357,11 +411,14 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
noto::sqlite_value{part.part}, noto::sqlite_value{part.part},
noto::sqlite_value{part.source_path}, noto::sqlite_value{part.source_path},
noto::sqlite_value{part.key_signature}, noto::sqlite_value{part.key_signature},
noto::sqlite_value{static_cast<std::int64_t>(part.measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(part.pages.size())},
noto::sqlite_value{part.authored_key_signature},
}; };
auto inserted = co_await connection.async_execute( auto inserted = co_await connection.async_execute(
R"sql( R"sql(
INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed, measure_count, total_pages, authored_key_signature)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?11, ?12)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = excluded.title, title = excluded.title,
composer = excluded.composer, composer = excluded.composer,
@@ -370,6 +427,9 @@ ON CONFLICT(id) DO UPDATE SET
source_path = excluded.source_path, source_path = excluded.source_path,
key_signature = excluded.key_signature, key_signature = excluded.key_signature,
catalog_managed = 1, catalog_managed = 1,
measure_count = excluded.measure_count,
total_pages = excluded.total_pages,
authored_key_signature = excluded.authored_key_signature,
updated_at_ms = CASE updated_at_ms = CASE
WHEN songs.title != excluded.title WHEN songs.title != excluded.title
OR songs.composer != excluded.composer OR songs.composer != excluded.composer
@@ -388,10 +448,27 @@ WHERE songs.catalog_managed = 1
} }
} }
auto practice_ids = std::vector<std::string>{}; auto practice_ids = std::vector<std::string>{};
for (auto const &exercise : practice->exercises) { auto authored_keys = std::map<std::string, std::string>{};
for (auto const &path : *practice) {
if (!imported) { if (!imported) {
break; break;
} }
auto document = vamp_zero::read_score_document(path);
if (!document) {
imported = std::unexpected(document.error());
break;
}
auto metadata = vamp_zero::practice_metadata(*document, assets_path);
if (!metadata) {
imported = std::unexpected(metadata.error());
break;
}
auto const &exercise = *metadata;
auto [origin, added] = authored_keys.emplace(exercise.id, exercise.authored_key_signature);
if (!added && origin->second != exercise.authored_key_signature) {
imported = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice variants disagree on authored key: " + exercise.id));
break;
}
for (auto const &variant : exercise.variants) { for (auto const &variant : exercise.variants) {
practice_ids.push_back(variant.id); practice_ids.push_back(variant.id);
auto params = std::vector<noto::sqlite_value>{ auto params = std::vector<noto::sqlite_value>{
@@ -403,15 +480,18 @@ WHERE songs.catalog_managed = 1
noto::sqlite_value{exercise.title}, noto::sqlite_value{exercise.title},
noto::sqlite_value{exercise.composer}, noto::sqlite_value{exercise.composer},
noto::sqlite_value{variant.key_signature}, noto::sqlite_value{variant.key_signature},
noto::sqlite_value{variant.source_json}, noto::sqlite_value{document->source_json},
noto::sqlite_value{variant.source_path}, noto::sqlite_value{variant.source_path},
noto::sqlite_value{timestamp}, noto::sqlite_value{timestamp},
noto::sqlite_value{timestamp}, noto::sqlite_value{timestamp},
noto::sqlite_value{static_cast<std::int64_t>(variant.measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(variant.total_pages)},
noto::sqlite_value{exercise.authored_key_signature},
}; };
auto inserted = co_await connection.async_execute( auto inserted = co_await connection.async_execute(
R"sql( R"sql(
INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed) INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, ?13, ?14, ?15)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
practice_type_id = excluded.practice_type_id, practice_type_id = excluded.practice_type_id,
practice_type_title = excluded.practice_type_title, practice_type_title = excluded.practice_type_title,
@@ -423,6 +503,9 @@ ON CONFLICT(id) DO UPDATE SET
source_json = excluded.source_json, source_json = excluded.source_json,
source_path = excluded.source_path, source_path = excluded.source_path,
catalog_managed = 1, catalog_managed = 1,
measure_count = excluded.measure_count,
total_pages = excluded.total_pages,
authored_key_signature = excluded.authored_key_signature,
updated_at_ms = CASE updated_at_ms = CASE
WHEN practice_exercises.practice_type_id != excluded.practice_type_id WHEN practice_exercises.practice_type_id != excluded.practice_type_id
OR practice_exercises.practice_type_title != excluded.practice_type_title OR practice_exercises.practice_type_title != excluded.practice_type_title
@@ -482,8 +565,13 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
if (!connected) { if (!connected) {
co_return std::unexpected(connected.error()); co_return std::unexpected(connected.error());
} }
auto filled = co_await backfill_metadata(connection, "songs");
if (!filled) {
static_cast<void>(co_await connection.async_close());
co_return std::unexpected(filled.error());
}
auto selected = auto selected =
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, source_json, key_signature FROM " co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM "
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); "songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
if (!selected) { if (!selected) {
auto close = co_await connection.async_close(); auto close = co_await connection.async_close();
@@ -504,13 +592,8 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
break; break;
} }
auto const &row = **next; auto const &row = **next;
if (row.size() < 7U) { if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) {
continue; status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
}
auto source_json = text_value(row[5]);
auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[4]));
if (!parsed) {
status = std::unexpected(parsed.error());
break; break;
} }
parts.push_back({ parts.push_back({
@@ -520,9 +603,8 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
.key_signature = text_value(row[6]), .key_signature = text_value(row[6]),
.part = text_value(row[3]), .part = text_value(row[3]),
.source_path = text_value(row[4]), .source_path = text_value(row[4]),
.source_json = std::move(source_json), .measure_count = count_value(row[5]),
.measure_count = parsed->measure_count, .total_pages = count_value(row[7]),
.total_pages = vamp_zero::score_page_count(*parsed),
}); });
} }
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
@@ -539,8 +621,13 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
if (!connected) { if (!connected) {
co_return std::unexpected(connected.error()); co_return std::unexpected(connected.error());
} }
auto filled = co_await backfill_metadata(connection, "practice_exercises");
if (!filled) {
static_cast<void>(co_await connection.async_close());
co_return std::unexpected(filled.error());
}
auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, " auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, "
"key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, " "key_signature, source_path, measure_count, total_pages, authored_key_signature FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
"lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE"); "lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE");
if (!selected) { if (!selected) {
auto close = co_await connection.async_close(); auto close = co_await connection.async_close();
@@ -561,13 +648,8 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
break; break;
} }
auto const &row = **next; auto const &row = **next;
if (row.size() < 10U) { if (row.size() != 12U || count_value(row[9]) == 0U || count_value(row[10]) == 0U) {
continue; status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
}
auto source_json = text_value(row[9]);
auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[8]));
if (!parsed) {
status = std::unexpected(parsed.error());
break; break;
} }
auto type_id = text_value(row[1]); auto type_id = text_value(row[1]);
@@ -578,9 +660,8 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
.id = text_value(row[0]), .id = text_value(row[0]),
.key_signature = key_signature, .key_signature = key_signature,
.source_path = source_path, .source_path = source_path,
.source_json = source_json, .measure_count = count_value(row[9]),
.measure_count = parsed->measure_count, .total_pages = count_value(row[10]),
.total_pages = vamp_zero::score_page_count(*parsed),
}; };
exercises.push_back({ exercises.push_back({
.id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])), .id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])),
@@ -590,11 +671,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
.lesson_group_title = text_value(row[4]), .lesson_group_title = text_value(row[4]),
.title = text_value(row[5]), .title = text_value(row[5]),
.composer = text_value(row[6]), .composer = text_value(row[6]),
.key_signature = variant.key_signature, .authored_key_signature = text_value(row[11]),
.source_path = variant.source_path,
.source_json = variant.source_json,
.measure_count = variant.measure_count,
.total_pages = variant.total_pages,
.variants = {std::move(variant)}, .variants = {std::move(variant)},
}); });
} }
@@ -604,3 +681,44 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
} }
co_return vamp_zero::group_practice_exercises(std::move(exercises)); co_return vamp_zero::group_practice_exercises(std::move(exercises));
} }
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
// Unwind inline database completions between sequential loads of many scores.
co_await boost::asio::post(executor, boost::asio::use_awaitable);
auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto table = reference.category == vamp_zero::score_category::PERFORMANCE ? "songs" : "practice_exercises";
auto selected = co_await connection.async_prepare("SELECT source_json, source_path FROM " + std::string{table} + " WHERE id = ?1");
if (!selected) {
static_cast<void>(co_await connection.async_close());
co_return std::unexpected(selected.error());
}
auto result = std::expected<vamp_zero::score_document, modulus::error>{std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score not found: " + reference.id))};
auto params = std::vector<noto::sqlite_value>{noto::sqlite_value{reference.id}};
auto bound = co_await selected->async_reset(std::move(params));
if (!bound) {
result = std::unexpected(bound.error());
} else {
auto row = co_await selected->async_step();
if (!row) {
result = std::unexpected(row.error());
} else if (*row) {
result = vamp_zero::parse_score_document(text_value((**row)[0]), text_value((**row)[1]));
if (result) {
result->id = reference.id;
}
}
}
auto status = std::expected<void, modulus::error>{};
if (!result) {
status = std::unexpected(result.error());
}
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
if (!closed) {
co_return std::unexpected(closed.error());
}
co_return result;
}
+46
View File
@@ -0,0 +1,46 @@
#include <future>
#include <string_view>
#include <utility>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/health.hpp>
auto vamp_zero::check_health(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint);
if (!endpoint) {
return std::unexpected(endpoint.error());
}
if (endpoint->port == 0) {
endpoint->port = 8080;
}
auto error = boost::system::error_code{};
auto address = boost::asio::ip::make_address(endpoint->host, error);
if (!error && address.is_unspecified()) {
endpoint->host = address.is_v6() ? "::1" : "127.0.0.1";
}
auto io = boost::asio::io_context{};
auto client = ligo::http::client{*endpoint, ligo::http::client_options_from_config(config.control.endpoint)};
auto request = ligo::http::request{.method = ligo::http::method::GET, .target = "/api/health"};
auto future = boost::asio::co_spawn(io, client.call(request), boost::asio::use_future);
io.run();
auto response = future.get();
if (!response) {
return std::unexpected(response.error());
}
auto matches = [&](std::string_view field, std::string_view expected) {
auto object = response->if_object();
auto value = object == nullptr ? nullptr : object->if_contains(field);
return value != nullptr && value->is_string() && value->as_string() == expected;
};
if (!matches("product", "product-vamp") || !matches("device", "zero") || !matches("status", "ready")) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "health response is not a ready product-vamp zero"));
}
return {};
}
+120 -220
View File
@@ -6,6 +6,7 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <system_error> #include <system_error>
#include <tuple>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -22,9 +23,6 @@ auto invalid(std::string message) -> modulus::error {
} }
auto human_label(std::string value) -> std::string { auto human_label(std::string value) -> std::string {
if (value.empty()) {
return "Score";
}
auto capitalize_next = std::bool_t{true}; auto capitalize_next = std::bool_t{true};
for (auto &ch : value) { for (auto &ch : value) {
if (ch == '-' || ch == '_') { if (ch == '-' || ch == '_') {
@@ -37,31 +35,57 @@ auto human_label(std::string value) -> std::string {
} }
capitalize_next = false; capitalize_next = false;
} }
return value; return value.empty() ? "Score" : value;
} }
auto practice_type_label(std::string const &value) -> std::string { auto key_rank(std::string const &key) -> std::size_t {
return human_label(value) + " Practice"; auto const &keys = vamp_zero::practice_key_names();
auto tonic = key.substr(0, key.find(' '));
return static_cast<std::size_t>(std::distance(keys.begin(), std::find(keys.begin(), keys.end(), tonic)));
} }
auto load_score_documents(std::filesystem::path const &directory, std::bool_t required) -> std::expected<std::vector<vamp_zero::score_document>, modulus::error> { }
auto vamp_zero::parse_score_document(std::string_view json, std::string source_path) -> std::expected<vamp_zero::score_document, modulus::error> {
return vamp_zero::parse_notation_document(json, std::move(source_path));
}
auto vamp_zero::read_score_document(std::filesystem::path const &path) -> std::expected<vamp_zero::score_document, modulus::error> {
auto input = std::ifstream{path, std::ios::binary};
if (!input) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open score JSON: " + path.string()));
}
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
if (input.bad()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read score JSON: " + path.string()));
}
return vamp_zero::parse_score_document(text, path.string());
}
auto vamp_zero::score_paths(std::string const &assets_path, vamp_zero::score_category category) -> std::expected<std::vector<std::filesystem::path>, modulus::error> {
auto practice = category == vamp_zero::score_category::PRACTICE;
auto root = std::filesystem::path{assets_path} / (practice ? "practice" : "songs");
auto error = std::error_code{}; auto error = std::error_code{};
auto exists = std::filesystem::exists(directory, error); auto exists = std::filesystem::exists(root, error);
if (error) { if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect score assets: " + error.message())); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, root.string() + ": " + error.message()));
} }
if (!exists) { if (!exists) {
if (required) { if (practice) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + directory.string())); return std::vector<std::filesystem::path>{};
} }
return std::vector<vamp_zero::score_document>{}; return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + root.string()));
} }
auto paths = std::vector<std::filesystem::path>{}; auto paths = std::vector<std::filesystem::path>{};
auto entries = std::filesystem::directory_iterator{directory, error}; auto entries = std::filesystem::recursive_directory_iterator{root, error};
auto end = std::filesystem::directory_iterator{}; auto end = std::filesystem::recursive_directory_iterator{};
while (!error && entries != end) { while (!error && entries != end) {
auto const &entry = *entries; auto const &entry = *entries;
if (entry.is_regular_file(error) && entry.path().extension() == ".json") { if (entry.is_regular_file(error) && entry.path().extension() == ".json") {
auto relative = entry.path().lexically_relative(root);
if (std::distance(relative.begin(), relative.end()) != (practice ? 4 : 1)) {
return std::unexpected(invalid("unexpected score asset path: " + entry.path().string()));
}
paths.push_back(entry.path()); paths.push_back(entry.path());
} }
if (!error) { if (!error) {
@@ -72,255 +96,131 @@ auto load_score_documents(std::filesystem::path const &directory, std::bool_t re
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message())); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message()));
} }
std::sort(paths.begin(), paths.end()); std::sort(paths.begin(), paths.end());
auto documents = std::vector<vamp_zero::score_document>{}; return paths;
for (auto const &path : paths) {
auto input = std::ifstream{path, std::ios::binary};
if (!input) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open score JSON: " + path.string()));
}
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
auto document = vamp_zero::parse_score_document(text, path.string());
if (!document) {
return std::unexpected(document.error());
}
documents.push_back(std::move(*document));
}
return documents;
} }
auto path_parts(std::filesystem::path const &path) -> std::vector<std::string> { auto vamp_zero::performance_metadata(vamp_zero::score_document const &document) -> vamp_zero::library_part {
return {
.id = document.id,
.title = document.title,
.composer = document.composer,
.key_signature = document.key_signature,
.part = document.part,
.source_path = document.source_path,
.measure_count = document.measure_count,
.total_pages = vamp_zero::score_page_count(document),
};
}
auto vamp_zero::practice_metadata(vamp_zero::score_document const &document, std::string const &assets_path) -> std::expected<vamp_zero::practice_exercise, modulus::error> {
auto relative = std::filesystem::path{document.source_path}.lexically_relative(std::filesystem::path{assets_path} / "practice");
auto parts = std::vector<std::string>{}; auto parts = std::vector<std::string>{};
for (auto const &part : path) { for (auto const &part : relative) {
parts.push_back(part.string()); parts.push_back(part.string());
} }
return parts; if (parts.size() != 4U) {
} return std::unexpected(invalid("practice JSON must be stored as practice/<type>/<lesson_group>/<exercise>/<key>.json: " + document.source_path));
auto key_rank(std::string const &key) -> std::size_t {
static auto const keys = std::vector<std::string>{
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
};
auto tonic = key.substr(0, key.find(' '));
auto found = std::find(keys.begin(), keys.end(), tonic);
return found == keys.end() ? keys.size() : static_cast<std::size_t>(std::distance(keys.begin(), found));
}
auto sync_primary_variant(vamp_zero::practice_exercise &exercise) -> void {
std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const &left, auto const &right) {
auto left_rank = key_rank(left.key_signature);
auto right_rank = key_rank(right.key_signature);
if (left_rank != right_rank) {
return left_rank < right_rank;
}
return left.id < right.id;
});
if (exercise.variants.empty()) {
return;
} }
auto const &primary = exercise.variants.front(); auto id = parts[0] + "-" + parts[1] + "-" + parts[2];
exercise.key_signature = primary.key_signature; return vamp_zero::practice_exercise{
exercise.source_path = primary.source_path; .id = id,
exercise.source_json = primary.source_json; .practice_type_id = parts[0],
exercise.measure_count = primary.measure_count; .practice_type_title = human_label(parts[0]) + " Practice",
exercise.total_pages = primary.total_pages; .lesson_group_id = parts[1],
} .lesson_group_title = human_label(parts[1]),
.title = document.title,
auto same_practice_exercise(vamp_zero::practice_exercise const &left, vamp_zero::practice_exercise const &right) -> std::bool_t { .composer = document.composer,
return left.id == right.id && left.practice_type_id == right.practice_type_id && .authored_key_signature = document.authored_key_signature,
left.lesson_group_id == right.lesson_group_id; .variants = {{
} .id = id + "-" + std::filesystem::path{parts[3]}.stem().string(),
.key_signature = document.key_signature,
} .source_path = document.source_path,
.measure_count = document.measure_count,
auto vamp_zero::parse_score_document(std::string_view json, std::string source_path) -> std::expected<vamp_zero::score_document, modulus::error> { .total_pages = vamp_zero::score_page_count(document),
return vamp_zero::parse_notation_document(json, std::move(source_path)); }},
};
} }
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state { auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
std::sort(parts.begin(), parts.end(), [](auto const &left, auto const &right) { std::sort(parts.begin(), parts.end(), [](auto const &left, auto const &right) {
if (left.title != right.title) { return std::tie(left.title, left.composer, left.part, left.id) < std::tie(right.title, right.composer, right.part, right.id);
return left.title < right.title;
}
return left.part < right.part;
}); });
auto groups = std::vector<vamp_zero::song_group>{}; auto groups = std::vector<vamp_zero::song_group>{};
for (auto const &part : parts) { for (auto index = std::size_t{}; index < parts.size(); ++index) {
auto const &part = parts[index];
if (groups.empty() || groups.back().title != part.title || groups.back().composer != part.composer) { if (groups.empty() || groups.back().title != part.title || groups.back().composer != part.composer) {
groups.push_back({ groups.push_back({.title = part.title, .composer = part.composer});
.title = part.title,
.composer = part.composer,
});
} }
groups.back().parts.push_back(part); groups.back().parts.push_back(index);
} }
return vamp_zero::library_state{ return {.parts = std::move(parts), .groups = std::move(groups)};
.parts = std::move(parts),
.groups = std::move(groups),
};
} }
auto vamp_zero::group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> vamp_zero::practice_library { auto vamp_zero::group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> std::expected<vamp_zero::practice_library, modulus::error> {
std::sort(exercises.begin(), exercises.end(), [](auto const &left, auto const &right) { std::sort(exercises.begin(), exercises.end(), [](auto const &left, auto const &right) {
if (left.practice_type_id != right.practice_type_id) { return std::tie(left.practice_type_id, left.lesson_group_id, left.id) < std::tie(right.practice_type_id, right.lesson_group_id, right.id);
return left.practice_type_id < right.practice_type_id;
}
if (left.lesson_group_id != right.lesson_group_id) {
return left.lesson_group_id < right.lesson_group_id;
}
if (left.id != right.id) {
return left.id < right.id;
}
if (key_rank(left.key_signature) != key_rank(right.key_signature)) {
return key_rank(left.key_signature) < key_rank(right.key_signature);
}
return left.source_path < right.source_path;
}); });
auto merged = std::vector<vamp_zero::practice_exercise>{}; auto merged = std::vector<vamp_zero::practice_exercise>{};
for (auto &exercise : exercises) { for (auto &exercise : exercises) {
if (!merged.empty() && same_practice_exercise(merged.back(), exercise)) { if (!merged.empty() && std::tie(merged.back().practice_type_id, merged.back().lesson_group_id, merged.back().id) == std::tie(exercise.practice_type_id, exercise.lesson_group_id, exercise.id)) {
if (merged.back().authored_key_signature != exercise.authored_key_signature) {
return std::unexpected(invalid("practice variants disagree on authored key: " + exercise.id));
}
merged.back().variants.insert(merged.back().variants.end(), std::make_move_iterator(exercise.variants.begin()), std::make_move_iterator(exercise.variants.end())); merged.back().variants.insert(merged.back().variants.end(), std::make_move_iterator(exercise.variants.begin()), std::make_move_iterator(exercise.variants.end()));
sync_primary_variant(merged.back()); } else {
continue; merged.push_back(std::move(exercise));
} }
sync_primary_variant(exercise);
merged.push_back(std::move(exercise));
} }
auto types = std::vector<vamp_zero::practice_type_group>{}; auto types = std::vector<vamp_zero::practice_type_group>{};
for (auto const &exercise : merged) { for (auto index = std::size_t{}; index < merged.size(); ++index) {
auto type = auto &exercise = merged[index];
std::find_if(types.begin(), types.end(), [&](auto const &item) { return item.id == exercise.practice_type_id; }); std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const &left, auto const &right) {
if (type == types.end()) { return std::tuple{key_rank(left.key_signature), left.id} < std::tuple{key_rank(right.key_signature), right.id};
types.push_back({
.id = exercise.practice_type_id,
.title = exercise.practice_type_title,
});
type = std::prev(types.end());
}
auto lesson = std::find_if(type->lesson_groups.begin(), type->lesson_groups.end(), [&](auto const &item) {
return item.id == exercise.lesson_group_id;
}); });
if (lesson == type->lesson_groups.end()) { if (types.empty() || types.back().id != exercise.practice_type_id) {
type->lesson_groups.push_back({ types.push_back({.id = exercise.practice_type_id, .title = exercise.practice_type_title});
.id = exercise.lesson_group_id,
.title = exercise.lesson_group_title,
});
lesson = std::prev(type->lesson_groups.end());
} }
lesson->exercises.push_back(exercise); auto &lessons = types.back().lesson_groups;
if (lessons.empty() || lessons.back().id != exercise.lesson_group_id) {
lessons.push_back({.id = exercise.lesson_group_id, .title = exercise.lesson_group_title});
}
lessons.back().exercises.push_back(index);
} }
return vamp_zero::practice_library{ return vamp_zero::practice_library{.exercises = std::move(merged), .types = std::move(types)};
.exercises = std::move(merged),
.types = std::move(types),
};
} }
auto vamp_zero::load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error> { auto vamp_zero::load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error> {
auto documents = load_score_documents(std::filesystem::path{assets_path} / "songs", true); auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
if (!documents) { if (!paths) {
return std::unexpected(documents.error()); return std::unexpected(paths.error());
} }
auto parts = std::vector<vamp_zero::library_part>{}; auto parts = std::vector<vamp_zero::library_part>{};
for (auto &document : *documents) { for (auto const &path : *paths) {
parts.push_back({ auto document = vamp_zero::read_score_document(path);
.id = document.id, if (!document) {
.title = document.title, return std::unexpected(document.error());
.composer = document.composer, }
.key_signature = document.key_signature, parts.push_back(vamp_zero::performance_metadata(*document));
.part = document.part,
.source_path = document.source_path,
.source_json = std::move(document.source_json),
.measure_count = document.measure_count,
.total_pages = vamp_zero::score_page_count(document),
});
} }
return vamp_zero::group_library_parts(std::move(parts)); return vamp_zero::group_library_parts(std::move(parts));
} }
auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error> { auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error> {
auto root = std::filesystem::path{assets_path} / "practice"; auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
auto error = std::error_code{}; if (!paths) {
auto exists = std::filesystem::exists(root, error); return std::unexpected(paths.error());
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect practice assets: " + error.message()));
} }
if (!exists) {
return vamp_zero::practice_library{};
}
auto paths = std::vector<std::filesystem::path>{};
auto entries = std::filesystem::recursive_directory_iterator{root, error};
auto end = std::filesystem::recursive_directory_iterator{};
while (!error && entries != end) {
auto const &entry = *entries;
if (entry.is_regular_file(error) && entry.path().extension() == ".json") {
paths.push_back(entry.path());
}
if (!error) {
entries.increment(error);
}
}
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan practice assets: " + error.message()));
}
std::sort(paths.begin(), paths.end());
auto exercises = std::vector<vamp_zero::practice_exercise>{}; auto exercises = std::vector<vamp_zero::practice_exercise>{};
for (auto const &path : paths) { for (auto const &path : *paths) {
auto relative = std::filesystem::relative(path, root, error); auto document = vamp_zero::read_score_document(path);
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect practice asset path: " + error.message()));
}
auto parts = path_parts(relative);
if (parts.size() != 4U) {
return std::unexpected(invalid("practice JSON must be stored as practice/<type>/<lesson_group>/<exercise>/<key>.json: " + path.string()));
}
auto input = std::ifstream{path, std::ios::binary};
if (!input) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open practice JSON: " + path.string()));
}
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
auto document = vamp_zero::parse_score_document(text, path.string());
if (!document) { if (!document) {
return std::unexpected(document.error()); return std::unexpected(document.error());
} }
auto type_id = std::move(parts[0]); auto exercise = vamp_zero::practice_metadata(*document, assets_path);
auto lesson_id = std::move(parts[1]); if (!exercise) {
auto exercise_slug = std::move(parts[2]); return std::unexpected(exercise.error());
auto key = document->key_signature.empty() ? std::string{"C"} : document->key_signature; }
auto exercise_id = type_id + "-" + lesson_id + "-" + exercise_slug; exercises.push_back(std::move(*exercise));
auto variant_id = exercise_id + "-" + std::filesystem::path{parts[3]}.stem().string();
auto variant = vamp_zero::practice_exercise::score_variant{
.id = std::move(variant_id),
.key_signature = key,
.source_path = document->source_path,
.source_json = document->source_json,
.measure_count = document->measure_count,
.total_pages = vamp_zero::score_page_count(*document),
};
exercises.push_back({
.id = std::move(exercise_id),
.practice_type_id = type_id,
.practice_type_title = practice_type_label(type_id),
.lesson_group_id = lesson_id,
.lesson_group_title = human_label(lesson_id),
.title = document->title,
.composer = document->composer,
.key_signature = variant.key_signature,
.source_path = variant.source_path,
.source_json = variant.source_json,
.measure_count = variant.measure_count,
.total_pages = variant.total_pages,
.variants = {std::move(variant)},
});
} }
return vamp_zero::group_practice_exercises(std::move(exercises)); return vamp_zero::group_practice_exercises(std::move(exercises));
} }
+16 -14
View File
@@ -12,14 +12,14 @@ auto menu_row_count(std::size_t item_count) -> std::size_t {
} }
auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * { auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * {
if (state.selected_song_index == 0U || state.performance.groups.empty()) { if (state.selected_song_index == 0U || state.catalog->performance.groups.empty()) {
return nullptr; return nullptr;
} }
auto index = state.selected_song_index - 1U; auto index = state.selected_song_index - 1U;
if (index >= state.performance.groups.size()) { if (index >= state.catalog->performance.groups.size()) {
return nullptr; return nullptr;
} }
return &state.performance.groups[index]; return &state.catalog->performance.groups[index];
} }
auto vamp_zero::selected_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const * { auto vamp_zero::selected_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const * {
@@ -31,28 +31,29 @@ auto vamp_zero::selected_part(vamp_zero::app_state const &state) -> vamp_zero::l
if (index >= group->parts.size()) { if (index >= group->parts.size()) {
return nullptr; return nullptr;
} }
return &group->parts[index]; auto part_index = group->parts[index];
return part_index < state.catalog->performance.parts.size() ? &state.catalog->performance.parts[part_index] : nullptr;
} }
auto vamp_zero::active_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const * { auto vamp_zero::active_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const * {
if (state.active_part_id.empty()) { if (state.active_part_id.empty()) {
return nullptr; return nullptr;
} }
auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const &part) { auto found = std::find_if(state.catalog->performance.parts.begin(), state.catalog->performance.parts.end(), [&](auto const &part) {
return part.id == state.active_part_id; return part.id == state.active_part_id;
}); });
return found == state.performance.parts.end() ? nullptr : &*found; return found == state.catalog->performance.parts.end() ? nullptr : &*found;
} }
auto vamp_zero::selected_practice_type(vamp_zero::app_state const &state) -> vamp_zero::practice_type_group const * { auto vamp_zero::selected_practice_type(vamp_zero::app_state const &state) -> vamp_zero::practice_type_group const * {
if (state.selected_practice_type_index == 0U || state.practice.types.empty()) { if (state.selected_practice_type_index == 0U || state.catalog->practice.types.empty()) {
return nullptr; return nullptr;
} }
auto index = state.selected_practice_type_index - 1U; auto index = state.selected_practice_type_index - 1U;
if (index >= state.practice.types.size()) { if (index >= state.catalog->practice.types.size()) {
return nullptr; return nullptr;
} }
return &state.practice.types[index]; return &state.catalog->practice.types[index];
} }
auto vamp_zero::selected_practice_lesson(vamp_zero::app_state const &state) -> vamp_zero::practice_lesson_group const * { auto vamp_zero::selected_practice_lesson(vamp_zero::app_state const &state) -> vamp_zero::practice_lesson_group const * {
@@ -76,17 +77,18 @@ auto vamp_zero::selected_practice_exercise(vamp_zero::app_state const &state) ->
if (index >= lesson->exercises.size()) { if (index >= lesson->exercises.size()) {
return nullptr; return nullptr;
} }
return &lesson->exercises[index]; auto exercise_index = lesson->exercises[index];
return exercise_index < state.catalog->practice.exercises.size() ? &state.catalog->practice.exercises[exercise_index] : nullptr;
} }
auto vamp_zero::active_practice(vamp_zero::app_state const &state) -> vamp_zero::practice_exercise const * { auto vamp_zero::active_practice(vamp_zero::app_state const &state) -> vamp_zero::practice_exercise const * {
if (state.active_practice_id.empty()) { if (state.active_practice_id.empty()) {
return nullptr; return nullptr;
} }
auto found = std::find_if(state.practice.exercises.begin(), auto found = std::find_if(state.catalog->practice.exercises.begin(),
state.practice.exercises.end(), state.catalog->practice.exercises.end(),
[&](auto const &exercise) { return exercise.id == state.active_practice_id; }); [&](auto const &exercise) { return exercise.id == state.active_practice_id; });
return found == state.practice.exercises.end() ? nullptr : &*found; return found == state.catalog->practice.exercises.end() ? nullptr : &*found;
} }
auto vamp_zero::supports_key_selection(vamp_zero::practice_exercise const &exercise) -> std::bool_t { auto vamp_zero::supports_key_selection(vamp_zero::practice_exercise const &exercise) -> std::bool_t {
@@ -131,7 +133,7 @@ auto vamp_zero::effective_practice_key(vamp_zero::app_state const &state, vamp_z
variant != nullptr && !variant->key_signature.empty()) { variant != nullptr && !variant->key_signature.empty()) {
return variant->key_signature; return variant->key_signature;
} }
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature; return exercise.variants.empty() ? std::string{} : exercise.variants.front().key_signature;
} }
auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t { auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t {
+7
View File
@@ -86,6 +86,13 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
return invalid(std::string{part.error().message()}); return invalid(std::string{part.error().message()});
} }
document.part = std::move(*part); document.part = std::move(*part);
if (metadata->if_contains("authored_key_signature")) {
auto key = string_field(*metadata, "authored_key_signature");
if (!key || key->empty()) {
return invalid("authored_key_signature must be a nonempty string");
}
document.authored_key_signature = std::move(*key);
}
if (staff.if_contains("arrangement_part_type")) { if (staff.if_contains("arrangement_part_type")) {
auto type = string_field(staff, "arrangement_part_type"); auto type = string_field(staff, "arrangement_part_type");
if (!type) { if (!type) {
+38 -28
View File
@@ -66,16 +66,19 @@ auto decrease_bpm(vamp_zero::audio_state &audio) -> void {
} }
auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
if (!state.catalog) {
state.catalog = vamp_zero::empty_catalogue();
}
state.audio = clamp_audio_state(state.audio); state.audio = clamp_audio_state(state.audio);
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U); state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U); state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.catalog->performance.groups.size()) - 1U);
if (auto const *group = selected_group(state); group != nullptr) { if (auto const *group = selected_group(state); group != nullptr) {
state.selected_part_index = std::min(state.selected_part_index, menu_row_count(group->parts.size()) - 1U); state.selected_part_index = std::min(state.selected_part_index, menu_row_count(group->parts.size()) - 1U);
} else { } else {
state.selected_part_index = 0U; state.selected_part_index = 0U;
} }
state.selected_practice_type_index = state.selected_practice_type_index =
std::min(state.selected_practice_type_index, menu_row_count(state.practice.types.size()) - 1U); std::min(state.selected_practice_type_index, menu_row_count(state.catalog->practice.types.size()) - 1U);
if (auto const *type = selected_practice_type(state); type != nullptr) { if (auto const *type = selected_practice_type(state); type != nullptr) {
state.selected_practice_lesson_index = state.selected_practice_lesson_index =
std::min(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()) - 1U); std::min(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()) - 1U);
@@ -192,19 +195,23 @@ auto exercise_json(vamp_zero::practice_exercise const &exercise) -> boost::json:
result["supports_key_selection"] = supports_key_selection(exercise); result["supports_key_selection"] = supports_key_selection(exercise);
result["title"] = exercise.title; result["title"] = exercise.title;
result["composer"] = exercise.composer; result["composer"] = exercise.composer;
result["key_signature"] = exercise.key_signature; result["authored_key_signature"] = exercise.authored_key_signature;
result["source_path"] = exercise.source_path; if (!exercise.variants.empty()) {
result["measures"] = static_cast<std::uint64_t>(exercise.measure_count); auto const &primary = exercise.variants.front();
result["pages"] = static_cast<std::uint64_t>(exercise.total_pages); result["key_signature"] = primary.key_signature;
result["source_path"] = primary.source_path;
result["measures"] = static_cast<std::uint64_t>(primary.measure_count);
result["pages"] = static_cast<std::uint64_t>(primary.total_pages);
}
result["variant_count"] = static_cast<std::uint64_t>(exercise.variants.size()); result["variant_count"] = static_cast<std::uint64_t>(exercise.variants.size());
result["variants"] = std::move(variants); result["variants"] = std::move(variants);
return result; return result;
} }
auto lesson_json(vamp_zero::practice_lesson_group const &lesson) -> boost::json::object { auto lesson_json(vamp_zero::practice_lesson_group const &lesson, vamp_zero::practice_library const &library) -> boost::json::object {
auto exercises = boost::json::array{}; auto exercises = boost::json::array{};
for (auto const &exercise : lesson.exercises) { for (auto const &exercise : lesson.exercises) {
exercises.push_back(exercise_json(exercise)); exercises.push_back(exercise_json(library.exercises[exercise]));
} }
auto result = boost::json::object{}; auto result = boost::json::object{};
result["id"] = lesson.id; result["id"] = lesson.id;
@@ -222,10 +229,10 @@ auto practice_type_exercise_count(vamp_zero::practice_type_group const &type) ->
return count; return count;
} }
auto type_json(vamp_zero::practice_type_group const &type) -> boost::json::object { auto type_json(vamp_zero::practice_type_group const &type, vamp_zero::practice_library const &library) -> boost::json::object {
auto lessons = boost::json::array{}; auto lessons = boost::json::array{};
for (auto const &lesson : type.lesson_groups) { for (auto const &lesson : type.lesson_groups) {
lessons.push_back(lesson_json(lesson)); lessons.push_back(lesson_json(lesson, library));
} }
auto result = boost::json::object{}; auto result = boost::json::object{};
result["id"] = type.id; result["id"] = type.id;
@@ -238,10 +245,10 @@ auto type_json(vamp_zero::practice_type_group const &type) -> boost::json::objec
auto performance_json(vamp_zero::app_state const &state) -> boost::json::object { auto performance_json(vamp_zero::app_state const &state) -> boost::json::object {
auto groups = boost::json::array{}; auto groups = boost::json::array{};
for (auto const &group : state.performance.groups) { for (auto const &group : state.catalog->performance.groups) {
auto parts = boost::json::array{}; auto parts = boost::json::array{};
for (auto const &part : group.parts) { for (auto const &part : group.parts) {
parts.push_back(part_json(part)); parts.push_back(part_json(state.catalog->performance.parts[part]));
} }
auto item = boost::json::object{}; auto item = boost::json::object{};
item["title"] = group.title; item["title"] = group.title;
@@ -250,19 +257,19 @@ auto performance_json(vamp_zero::app_state const &state) -> boost::json::object
groups.push_back(std::move(item)); groups.push_back(std::move(item));
} }
auto library = boost::json::object{}; auto library = boost::json::object{};
library["song_count"] = static_cast<std::uint64_t>(state.performance.groups.size()); library["song_count"] = static_cast<std::uint64_t>(state.catalog->performance.groups.size());
library["part_count"] = static_cast<std::uint64_t>(state.performance.parts.size()); library["part_count"] = static_cast<std::uint64_t>(state.catalog->performance.parts.size());
library["groups"] = std::move(groups); library["groups"] = std::move(groups);
return library; return library;
} }
auto practice_json(vamp_zero::app_state const &state) -> boost::json::object { auto practice_json(vamp_zero::app_state const &state) -> boost::json::object {
auto types = boost::json::array{}; auto types = boost::json::array{};
for (auto const &type : state.practice.types) { for (auto const &type : state.catalog->practice.types) {
types.push_back(type_json(type)); types.push_back(type_json(type, state.catalog->practice));
} }
auto exercises = boost::json::array{}; auto exercises = boost::json::array{};
for (auto const &exercise : state.practice.exercises) { for (auto const &exercise : state.catalog->practice.exercises) {
exercises.push_back(exercise_json(exercise)); exercises.push_back(exercise_json(exercise));
} }
auto keys = boost::json::array{}; auto keys = boost::json::array{};
@@ -275,10 +282,10 @@ auto practice_json(vamp_zero::app_state const &state) -> boost::json::object {
selection["exercise_row_index"] = static_cast<std::uint64_t>(state.selected_practice_exercise_index); selection["exercise_row_index"] = static_cast<std::uint64_t>(state.selected_practice_exercise_index);
selection["key_row_index"] = static_cast<std::uint64_t>(state.selected_practice_key_index); selection["key_row_index"] = static_cast<std::uint64_t>(state.selected_practice_key_index);
if (auto const *type = selected_practice_type(state); type != nullptr) { if (auto const *type = selected_practice_type(state); type != nullptr) {
selection["type"] = type_json(*type); selection["type"] = type_json(*type, state.catalog->practice);
} }
if (auto const *lesson = selected_practice_lesson(state); lesson != nullptr) { if (auto const *lesson = selected_practice_lesson(state); lesson != nullptr) {
selection["lesson_group"] = lesson_json(*lesson); selection["lesson_group"] = lesson_json(*lesson, state.catalog->practice);
} }
if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) {
selection["exercise"] = exercise_json(*exercise); selection["exercise"] = exercise_json(*exercise);
@@ -299,8 +306,8 @@ auto practice_json(vamp_zero::app_state const &state) -> boost::json::object {
} }
} }
auto practice = boost::json::object{}; auto practice = boost::json::object{};
practice["type_count"] = static_cast<std::uint64_t>(state.practice.types.size()); practice["type_count"] = static_cast<std::uint64_t>(state.catalog->practice.types.size());
practice["exercise_count"] = static_cast<std::uint64_t>(state.practice.exercises.size()); practice["exercise_count"] = static_cast<std::uint64_t>(state.catalog->practice.exercises.size());
practice["types"] = std::move(types); practice["types"] = std::move(types);
practice["exercises"] = std::move(exercises); practice["exercises"] = std::move(exercises);
practice["key_count"] = static_cast<std::uint64_t>(vamp_zero::practice_key_names().size()); practice["key_count"] = static_cast<std::uint64_t>(vamp_zero::practice_key_names().size());
@@ -368,11 +375,11 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
return clamp_state(std::move(state)); return clamp_state(std::move(state));
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU: case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
if (command == vamp_protocol::page_command::NEXT) { if (command == vamp_protocol::page_command::NEXT) {
state.selected_song_index = wrap_next(state.selected_song_index, menu_row_count(state.performance.groups.size())); state.selected_song_index = wrap_next(state.selected_song_index, menu_row_count(state.catalog->performance.groups.size()));
state.selected_part_index = 0; state.selected_part_index = 0;
} else if (command == vamp_protocol::page_command::PREVIOUS) { } else if (command == vamp_protocol::page_command::PREVIOUS) {
state.selected_song_index = state.selected_song_index =
wrap_previous(state.selected_song_index, menu_row_count(state.performance.groups.size())); wrap_previous(state.selected_song_index, menu_row_count(state.catalog->performance.groups.size()));
state.selected_part_index = 0; state.selected_part_index = 0;
} else if (state.selected_song_index == 0U) { } else if (state.selected_song_index == 0U) {
state.mode = vamp_zero::app_mode::MAIN_MENU; state.mode = vamp_zero::app_mode::MAIN_MENU;
@@ -415,13 +422,13 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
case vamp_zero::app_mode::PRACTICE_TYPE_MENU: case vamp_zero::app_mode::PRACTICE_TYPE_MENU:
if (command == vamp_protocol::page_command::NEXT) { if (command == vamp_protocol::page_command::NEXT) {
state.selected_practice_type_index = state.selected_practice_type_index =
wrap_next(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); wrap_next(state.selected_practice_type_index, menu_row_count(state.catalog->practice.types.size()));
state.selected_practice_lesson_index = 0; state.selected_practice_lesson_index = 0;
state.selected_practice_exercise_index = 0; state.selected_practice_exercise_index = 0;
state.selected_practice_key_index = 0; state.selected_practice_key_index = 0;
} else if (command == vamp_protocol::page_command::PREVIOUS) { } else if (command == vamp_protocol::page_command::PREVIOUS) {
state.selected_practice_type_index = state.selected_practice_type_index =
wrap_previous(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); wrap_previous(state.selected_practice_type_index, menu_row_count(state.catalog->practice.types.size()));
state.selected_practice_lesson_index = 0; state.selected_practice_lesson_index = 0;
state.selected_practice_exercise_index = 0; state.selected_practice_exercise_index = 0;
state.selected_practice_key_index = 0; state.selected_practice_key_index = 0;
@@ -619,6 +626,9 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
} }
auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::expected<void, modulus::error> { auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::expected<void, modulus::error> {
if (!state.catalog) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "catalogue is missing"));
}
if (state.selected_main_menu_index >= main_menu_count) { 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")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range"));
} }
@@ -638,14 +648,14 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::MAIN_MENU) { 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")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires the main menu"));
} }
if (state.selected_song_index >= menu_row_count(state.performance.groups.size())) { if (state.selected_song_index >= menu_row_count(state.catalog->performance.groups.size())) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range"));
} }
if (auto const *group = selected_group(state); if (auto const *group = selected_group(state);
group != nullptr && state.selected_part_index >= menu_row_count(group->parts.size())) { group != nullptr && state.selected_part_index >= menu_row_count(group->parts.size())) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance part row is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance part row is out of range"));
} }
if (state.selected_practice_type_index >= menu_row_count(state.practice.types.size())) { if (state.selected_practice_type_index >= menu_row_count(state.catalog->practice.types.size())) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice type row is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice type row is out of range"));
} }
if (auto const *type = selected_practice_type(state); if (auto const *type = selected_practice_type(state);
@@ -668,7 +678,7 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
auto const *exercise = selected_practice_exercise(state); auto const *exercise = selected_practice_exercise(state);
if (exercise == nullptr || !supports_key_selection(*exercise)) { if (exercise == nullptr || !supports_key_selection(*exercise)) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice key mode requires a melody exercise")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice key mode requires an exercise with key variants"));
} }
} }
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
+6
View File
@@ -140,6 +140,9 @@ auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::a
auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router { auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router {
auto router = ligo::service_router{}; auto router = ligo::service_router{};
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>> { 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()); co_return ligo::text_response("text/html; charset=utf-8", portal_html());
}); });
@@ -155,14 +158,17 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
co_return remote_response(updated); co_return remote_response(updated);
}); });
router.add_response_route("POST", vamp_protocol::remote_next_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> { router.add_response_route("POST", vamp_protocol::remote_next_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
static_cast<void>(state.heartbeat());
auto updated = state.apply(vamp_protocol::page_command::NEXT); auto updated = state.apply(vamp_protocol::page_command::NEXT);
co_return remote_response(updated); co_return remote_response(updated);
}); });
router.add_response_route("POST", vamp_protocol::remote_previous_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> { router.add_response_route("POST", vamp_protocol::remote_previous_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
static_cast<void>(state.heartbeat());
auto updated = state.apply(vamp_protocol::page_command::PREVIOUS); auto updated = state.apply(vamp_protocol::page_command::PREVIOUS);
co_return remote_response(updated); co_return remote_response(updated);
}); });
router.add_response_route("POST", vamp_protocol::remote_select_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> { router.add_response_route("POST", vamp_protocol::remote_select_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
static_cast<void>(state.heartbeat());
auto updated = state.apply(vamp_protocol::page_command::SELECT); auto updated = state.apply(vamp_protocol::page_command::SELECT);
co_return remote_response(updated); co_return remote_response(updated);
}); });
+144
View File
@@ -0,0 +1,144 @@
#include <vamp_zero/score_loader.hpp>
#include <cstdint>
#include <exception>
#include <mutex>
#include <utility>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/post.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp>
struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
state(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed)
: executor{std::move(executor)}, load{std::move(load)}, completed{std::move(completed)} {}
static auto run(std::weak_ptr<state> weak, std::uint64_t generation, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
{
auto self = weak.lock();
if (!self) {
co_return vamp_zero::score_document{};
}
auto lock = std::scoped_lock{self->mutex};
if (self->stopped || self->generation != generation) {
co_return vamp_zero::score_document{};
}
}
co_return co_await load(std::move(reference));
}
static auto finish(std::weak_ptr<state> weak, std::uint64_t generation, std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) -> void {
auto self = weak.lock();
if (!self) {
return;
}
auto completed = std::function<void()>{};
{
auto lock = std::scoped_lock{self->mutex};
self->in_flight = false;
if (self->stopped) {
return;
}
if (self->generation == generation) {
self->value.loading = false;
if (exception) {
self->value.error = vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "score loading failed");
} else if (!loaded) {
self->value.error = std::move(loaded.error());
} else {
self->value.document = std::make_shared<vamp_zero::score_document const>(std::move(*loaded));
}
completed = self->completed;
} else if (self->reference) {
loaded = vamp_zero::score_document{};
self->start();
}
}
// Redraw dispatch may wait for the UI, which reads snapshot().
if (completed) {
completed();
}
}
auto start() -> void {
in_flight = true;
// post is required: co_spawn can enter inline on an already-running executor.
boost::asio::post(executor, [weak = weak_from_this(), generation = generation, load = load,
reference = *reference, executor = executor]() mutable {
boost::asio::co_spawn(executor, run(weak, generation, std::move(load), std::move(reference)),
[weak, generation](std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) {
finish(weak, generation, exception, std::move(loaded));
});
});
}
boost::asio::any_io_executor executor;
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load;
std::function<void()> completed;
std::mutex mutex{};
std::shared_ptr<vamp_zero::catalogue const> catalog{};
std::optional<vamp_zero::score_reference> reference{};
vamp_zero::score_load_state value{};
std::uint64_t generation{};
std::bool_t in_flight{};
std::bool_t stopped{};
};
vamp_zero::score_loader::score_loader(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed)
: state_{std::make_shared<state>(std::move(executor), std::move(load), std::move(completed))} {}
vamp_zero::score_loader::~score_loader() {
stop();
}
auto vamp_zero::score_loader::select(std::shared_ptr<vamp_zero::catalogue const> catalog, std::optional<vamp_zero::score_reference> reference) -> void {
auto lock = std::scoped_lock{state_->mutex};
if (!catalog) {
reference.reset();
}
if (!reference) {
catalog.reset();
}
if (state_->stopped || (state_->catalog == catalog && state_->reference == reference)) {
return;
}
++state_->generation;
state_->catalog = std::move(catalog);
state_->reference = std::move(reference);
state_->value = {.loading = state_->reference.has_value()};
if (state_->reference && !state_->in_flight) {
state_->start();
}
}
auto vamp_zero::score_loader::snapshot() const -> vamp_zero::score_load_state {
auto lock = std::scoped_lock{state_->mutex};
return state_->value;
}
auto vamp_zero::score_loader::stop() -> void {
auto lock = std::scoped_lock{state_->mutex};
state_->stopped = true;
++state_->generation;
state_->reference.reset();
state_->catalog.reset();
state_->value = {};
state_->completed = {};
}
auto vamp_zero::selected_score_reference(vamp_zero::app_state const &state) -> std::optional<vamp_zero::score_reference> {
if (!state.catalog) {
return std::nullopt;
}
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
if (auto part = vamp_zero::active_part(state)) {
return vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part->id};
}
} else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
if (auto exercise = vamp_zero::active_practice(state)) {
if (auto variant = vamp_zero::selected_practice_variant(state, *exercise)) {
return vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = variant->id};
}
}
}
return std::nullopt;
}
+5
View File
@@ -4,6 +4,11 @@
#include <vector> #include <vector>
auto vamp_zero::empty_catalogue() -> std::shared_ptr<vamp_zero::catalogue const> const & {
static auto const empty = std::make_shared<vamp_zero::catalogue const>();
return empty;
}
auto vamp_zero::to_string(vamp_zero::hardware_mode value) -> std::string { auto vamp_zero::to_string(vamp_zero::hardware_mode value) -> std::string {
switch (value) { switch (value) {
case vamp_zero::hardware_mode::FAKE: case vamp_zero::hardware_mode::FAKE:
+166 -111
View File
@@ -1,6 +1,7 @@
#include <vamp_zero/ui.hpp> #include <vamp_zero/ui.hpp>
#include <algorithm> #include <algorithm>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <expected> #include <expected>
#include <functional> #include <functional>
@@ -13,46 +14,71 @@
#include <boost/asio/any_io_executor.hpp> #include <boost/asio/any_io_executor.hpp>
#include <boost/asio/post.hpp> #include <boost/asio/post.hpp>
#include <boost/asio/steady_timer.hpp>
#include <luma.hpp> #include <luma.hpp>
#include <vamp_style.hpp> #include <vamp_style.hpp>
#include <vamp_style/font.hpp> #include <vamp_style/font.hpp>
#include <vamp_zero/color.hpp> #include <vamp_zero/color.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/parse.hpp> #include <vamp_zero/parse.hpp>
#include <vamp_zero/score.hpp> #include <vamp_zero/score.hpp>
#include <vamp_zero/score_loader.hpp>
struct vamp_zero::score_draw_cache { struct vamp_zero::ui_runtime : std::enable_shared_from_this<vamp_zero::ui_runtime> {
ui_runtime(boost::asio::any_io_executor executor, std::function<void(vamp_protocol::page_command)> handler)
: executor_{std::move(executor)}, tap_timer_{executor_}, command_handler_{std::move(handler)} {}
auto handle_touch(luma::touch_event const &event) -> std::bool_t;
auto send_touch_command(vamp_protocol::page_command command) -> void;
auto arm_pending_tap_locked() -> void;
auto cancel_pending_tap_locked() -> void;
boost::asio::any_io_executor executor_;
boost::asio::steady_timer tap_timer_;
std::function<void(vamp_protocol::page_command)> command_handler_{};
std::mutex mutex_{};
vamp_zero::ui_state state_{};
std::mutex touch_mutex_{};
std::optional<std::int32_t> active_touch_id_{};
std::chrono::steady_clock::time_point touch_down_time_{};
std::chrono::steady_clock::time_point last_tap_time_{};
std::bool_t hold_sent_{};
std::bool_t pending_tap_{};
std::bool_t stopped_{};
};
namespace {
struct score_draw_cache {
explicit score_draw_cache(luma::application &application) explicit score_draw_cache(luma::application &application)
: textures{[this, &application](std::string const &name) -> std::expected<std::shared_ptr<luma::texture>, modulus::error> { : textures{[this, &application](std::string const &name) -> std::expected<std::shared_ptr<luma::texture>, modulus::error> {
auto found = std::find_if(document->pages.begin(), document->pages.end(), [&](auto const &page) { return page.path == name; }); auto score = document.lock();
auto path = vamp_zero::validated_page_path(*document, static_cast<std::size_t>(std::distance(document->pages.begin(), found))); if (!score) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected"));
}
auto found = std::find_if(score->pages.begin(), score->pages.end(), [&](auto const &page) { return page.path == name; });
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(score->pages.begin(), found)));
if (!path) { if (!path) {
return std::unexpected(path.error()); return std::unexpected(path.error());
} }
return application.load_texture_uncached(*path); return application.load_texture_uncached(*path);
}} {} }} {}
auto get(std::string_view source_json, std::string source_path) -> std::expected<std::shared_ptr<vamp_zero::score_document const>, modulus::error> { auto select(std::shared_ptr<vamp_zero::score_document const> const &score) -> void {
if (document && document->source_path == source_path && document->source_json == source_json) { if (score && document.lock() == score) {
return document; return;
}
auto parsed = vamp_zero::parse_score_document(source_json, std::move(source_path));
if (!parsed) {
return std::unexpected(parsed.error());
} }
textures.clear(); textures.clear();
document = std::make_shared<vamp_zero::score_document const>(std::move(*parsed)); document = score;
return document;
} }
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> { auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> {
if (!document || index >= document->pages.size()) { auto score = document.lock();
if (!score || index >= score->pages.size()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
} }
auto texture = textures.get(document->pages[index].path); auto texture = textures.get(score->pages[index].path);
if (!texture) { if (!texture) {
return std::unexpected(texture.error()); return std::unexpected(texture.error());
} }
@@ -66,11 +92,9 @@ struct vamp_zero::score_draw_cache {
return {}; return {};
} }
vamp_zero::score_page_cache textures; vamp_zero::score_page_cache textures;
std::shared_ptr<vamp_zero::score_document const> document{}; std::weak_ptr<vamp_zero::score_document const> document{};
}; };
namespace {
constexpr auto touch_hold_duration = std::chrono::milliseconds{700}; constexpr auto touch_hold_duration = std::chrono::milliseconds{700};
constexpr auto touch_double_tap_duration = std::chrono::milliseconds{220}; constexpr auto touch_double_tap_duration = std::chrono::milliseconds{220};
@@ -156,12 +180,12 @@ auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::a
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height}, {.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height},
"Performance Mode", "Performance Mode",
std::to_string(state.performance.groups.size()) + " SONGS", std::to_string(state.catalog->performance.groups.size()) + " SONGS",
state.selected_main_menu_index == 0U); state.selected_main_menu_index == 0U);
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height}, {.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height},
"Practice Mode", "Practice Mode",
std::to_string(state.practice.exercises.size()) + " EXERCISES", std::to_string(state.catalog->practice.exercises.size()) + " EXERCISES",
state.selected_main_menu_index == 1U); state.selected_main_menu_index == 1U);
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height}, {.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
@@ -186,11 +210,11 @@ auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::a
auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { 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 + 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.performance.groups.size()) + " SONGS " + std::to_string(state.performance.parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->performance.groups.size()) + " SONGS " + std::to_string(state.catalog->performance.parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F);
auto list_y = bounds.y + 112.0F; auto list_y = bounds.y + 112.0F;
auto row_height = std::float32_t{54.0F}; auto row_height = std::float32_t{54.0F};
auto row_width = bounds.width - 72.0F; auto row_width = bounds.width - 72.0F;
auto total_rows = state.performance.groups.size() + 1U; auto total_rows = state.catalog->performance.groups.size() + 1U;
auto max_rows = std::min<std::size_t>(total_rows, 7U); auto max_rows = std::min<std::size_t>(total_rows, 7U);
auto first = first_visible_row(state.selected_song_index, total_rows, max_rows); auto first = first_visible_row(state.selected_song_index, total_rows, max_rows);
for (auto row = std::size_t{}; row < max_rows; ++row) { for (auto row = std::size_t{}; row < max_rows; ++row) {
@@ -206,7 +230,7 @@ auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds,
state.selected_song_index == 0U); state.selected_song_index == 0U);
continue; continue;
} }
auto const &group = state.performance.groups[index - 1U]; auto const &group = state.catalog->performance.groups[index - 1U];
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, {.x = bounds.x + 36.0F,
.y = list_y + static_cast<std::float32_t>(row) * row_height, .y = list_y + static_cast<std::float32_t>(row) * row_height,
@@ -216,7 +240,7 @@ auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds,
std::to_string(group.parts.size()) + " PARTS", std::to_string(group.parts.size()) + " PARTS",
index == state.selected_song_index); index == state.selected_song_index);
} }
if (state.performance.groups.empty()) { if (state.catalog->performance.groups.empty()) {
draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F); draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F);
} }
} }
@@ -254,7 +278,11 @@ auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds,
state.selected_part_index == 0U); state.selected_part_index == 0U);
continue; continue;
} }
auto const &part = group->parts[index - 1U]; auto part_index = group->parts[index - 1U];
if (part_index >= state.catalog->performance.parts.size()) {
continue;
}
auto const &part = state.catalog->performance.parts[part_index];
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, {.x = bounds.x + 36.0F,
.y = list_y + static_cast<std::float32_t>(row) * row_height, .y = list_y + static_cast<std::float32_t>(row) * row_height,
@@ -268,11 +296,11 @@ auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds,
auto draw_practice_type_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto draw_practice_type_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, "PRACTICE", 4, vamp_style::ink, 300.0F); draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PRACTICE", 4, vamp_style::ink, 300.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.practice.types.size()) + " TYPES " + std::to_string(state.practice.exercises.size()) + " EXERCISES", 1, vamp_style::muted, 420.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.catalog->practice.types.size()) + " TYPES " + std::to_string(state.catalog->practice.exercises.size()) + " EXERCISES", 1, vamp_style::muted, 420.0F);
auto list_y = bounds.y + 112.0F; auto list_y = bounds.y + 112.0F;
auto row_height = std::float32_t{54.0F}; auto row_height = std::float32_t{54.0F};
auto row_width = bounds.width - 72.0F; auto row_width = bounds.width - 72.0F;
auto total_rows = state.practice.types.size() + 1U; auto total_rows = state.catalog->practice.types.size() + 1U;
auto max_rows = std::min<std::size_t>(total_rows, 7U); auto max_rows = std::min<std::size_t>(total_rows, 7U);
auto first = first_visible_row(state.selected_practice_type_index, total_rows, max_rows); auto first = first_visible_row(state.selected_practice_type_index, total_rows, max_rows);
for (auto row = std::size_t{}; row < max_rows; ++row) { for (auto row = std::size_t{}; row < max_rows; ++row) {
@@ -288,7 +316,7 @@ auto draw_practice_type_menu(luma::draw_context &context, luma::rect bounds, vam
state.selected_practice_type_index == 0U); state.selected_practice_type_index == 0U);
continue; continue;
} }
auto const &type = state.practice.types[index - 1U]; auto const &type = state.catalog->practice.types[index - 1U];
auto exercise_count = std::size_t{}; auto exercise_count = std::size_t{};
for (auto const &lesson : type.lesson_groups) { for (auto const &lesson : type.lesson_groups) {
exercise_count += lesson.exercises.size(); exercise_count += lesson.exercises.size();
@@ -302,7 +330,7 @@ auto draw_practice_type_menu(luma::draw_context &context, luma::rect bounds, vam
std::to_string(exercise_count) + " EXERCISES", std::to_string(exercise_count) + " EXERCISES",
index == state.selected_practice_type_index); index == state.selected_practice_type_index);
} }
if (state.practice.types.empty()) { if (state.catalog->practice.types.empty()) {
draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO PRACTICE EXERCISES FOUND", 3, vamp_style::retrying, 560.0F); draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO PRACTICE EXERCISES FOUND", 3, vamp_style::retrying, 560.0F);
} }
} }
@@ -385,7 +413,11 @@ auto draw_practice_exercise_menu(luma::draw_context &context, luma::rect bounds,
state.selected_practice_exercise_index == 0U); state.selected_practice_exercise_index == 0U);
continue; continue;
} }
auto const &exercise = lesson->exercises[index - 1U]; auto exercise_index = lesson->exercises[index - 1U];
if (exercise_index >= state.catalog->practice.exercises.size()) {
continue;
}
auto const &exercise = state.catalog->practice.exercises[exercise_index];
auto secondary = auto secondary =
supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise); supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise);
draw_row(context, draw_row(context,
@@ -429,8 +461,8 @@ auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp
continue; continue;
} }
auto key = exercise == nullptr ? std::string{"C"} : exercise->variants[index - 1U].key_signature; auto key = exercise == nullptr ? std::string{"C"} : exercise->variants[index - 1U].key_signature;
auto source = exercise == nullptr || exercise->key_signature.empty() ? std::string{"SOURCE C"} auto source = exercise == nullptr || exercise->authored_key_signature.empty() ? std::string{}
: "SOURCE " + exercise->key_signature; : "SOURCE " + exercise->authored_key_signature;
draw_row(context, draw_row(context,
{.x = bounds.x + 36.0F, {.x = bounds.x + 36.0F,
.y = list_y + static_cast<std::float32_t>(row) * row_height, .y = list_y + static_cast<std::float32_t>(row) * row_height,
@@ -442,17 +474,13 @@ auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp
} }
} }
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_draw_cache &score_cache) -> void { 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 {
auto source_json = std::string_view{};
auto source_path = std::string{};
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
auto const *part = active_part(state); auto const *part = active_part(state);
if (part == nullptr) { if (part == nullptr) {
draw_performance_song_menu(context, bounds, state); draw_performance_song_menu(context, bounds, state);
return; return;
} }
source_json = part->source_json;
source_path = part->source_path;
} else { } else {
auto const *exercise = active_practice(state); auto const *exercise = active_practice(state);
if (exercise == nullptr) { if (exercise == nullptr) {
@@ -464,13 +492,14 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
draw_practice_key_menu(context, bounds, state); draw_practice_key_menu(context, bounds, state);
return; return;
} }
source_json = variant->source_json;
source_path = variant->source_path;
} }
auto parsed = score_cache.get(source_json, source_path); if (loaded.loading) {
if (!parsed) { draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "LOADING SCORE", 4, vamp_style::ink, bounds.width - 72.0F);
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 + 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, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.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);
return; return;
} }
auto paper_bounds = luma::rect{ auto paper_bounds = luma::rect{
@@ -487,89 +516,116 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
class vamp_root_control final : public luma::control { class vamp_root_control final : public luma::control {
public: public:
explicit vamp_root_control(vamp_zero::ui_state state, std::shared_ptr<vamp_zero::score_draw_cache> score_cache, std::function<std::bool_t(luma::touch_event const &)> on_touch) vamp_root_control(std::shared_ptr<vamp_zero::ui_runtime> runtime, std::shared_ptr<vamp_zero::score_loader> loader, luma::application &application)
: state_{std::move(state)}, score_cache_{std::move(score_cache)}, on_touch_{std::move(on_touch)} {} : runtime_{std::move(runtime)}, loader_{std::move(loader)}, score_cache_{application} {}
auto measure(luma::draw_context &, luma::theme const &, luma::constraints limits) -> luma::size override { auto measure(luma::draw_context &, luma::theme const &, luma::constraints limits) -> luma::size override {
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits); return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
} }
auto draw(luma::draw_context &context, luma::theme const &) const -> void override { auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
auto state = vamp_zero::ui_state{};
auto loaded = vamp_zero::score_load_state{};
{
auto lock = std::scoped_lock{runtime_->mutex_};
state = runtime_->state_;
loaded = loader_->snapshot();
}
score_cache_.select(loaded.document);
auto area = bounds(); auto area = bounds();
context.fill_rect(area, vamp_zero::to_luma(vamp_style::background)); context.fill_rect(area, vamp_zero::to_luma(vamp_style::background));
draw_header(context, area, state_); draw_header(context, area, state);
auto content = luma::rect{ auto content = luma::rect{
.x = area.x, .x = area.x,
.y = area.y + 80.0F, .y = area.y + 80.0F,
.width = area.width, .width = area.width,
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F), .height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
}; };
if (state_.app.mode == vamp_zero::app_mode::MAIN_MENU) { if (state.app.mode == vamp_zero::app_mode::MAIN_MENU) {
draw_main_menu(context, content, state_.app); draw_main_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
draw_performance_song_menu(context, content, state_.app); draw_performance_song_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
draw_performance_part_menu(context, content, state_.app); draw_performance_part_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_TYPE_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_TYPE_MENU) {
draw_practice_type_menu(context, content, state_.app); draw_practice_type_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU) {
draw_practice_lesson_menu(context, content, state_.app); draw_practice_lesson_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) {
draw_practice_exercise_menu(context, content, state_.app); draw_practice_exercise_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { } else if (state.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
draw_practice_key_menu(context, content, state_.app); draw_practice_key_menu(context, content, state.app);
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || } else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE ||
state_.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) { state.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
if (score_cache_) { draw_score(context, content, state.app, loaded, score_cache_);
draw_score(context, content, state_.app, *score_cache_);
}
} else { } else {
draw_main_menu(context, content, state_.app); draw_main_menu(context, content, state.app);
} }
} }
auto handle_touch(luma::touch_event const &event) -> std::bool_t override { auto handle_touch(luma::touch_event const &event) -> std::bool_t override {
return on_touch_ ? on_touch_(event) : false; return runtime_->handle_touch(event);
} }
private: private:
vamp_zero::ui_state state_{}; std::shared_ptr<vamp_zero::ui_runtime> runtime_;
std::shared_ptr<vamp_zero::score_draw_cache> score_cache_{}; std::shared_ptr<vamp_zero::score_loader> loader_;
std::function<std::bool_t(luma::touch_event const &)> on_touch_{}; mutable score_draw_cache score_cache_;
}; };
} }
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, 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::function<void(vamp_protocol::page_command)> command_handler)
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)}, : application_{std::make_shared<luma::application>(std::move(config))},
command_handler_{std::move(command_handler)}, score_cache_{std::make_shared<vamp_zero::score_draw_cache>(application_)} {} runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); }, [weak = std::weak_ptr{application_}] {
if (auto application = weak.lock()) {
static_cast<void>(application->request_layout());
} })} {}
auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void { vamp_zero::zero_ui::~zero_ui() {
stop();
}
auto vamp_zero::ui_runtime::send_touch_command(vamp_protocol::page_command command) -> void {
if (!command_handler_) { if (!command_handler_) {
return; return;
} }
boost::asio::post(executor_, [handler = command_handler_, command] { handler(command); }); boost::asio::post(executor_, [weak = weak_from_this(), command] {
auto self = weak.lock();
if (!self) {
return;
}
{
auto lock = std::scoped_lock{self->touch_mutex_};
if (self->stopped_) {
return;
}
}
self->command_handler_(command);
});
} }
auto vamp_zero::zero_ui::cancel_pending_tap_locked() -> void { auto vamp_zero::ui_runtime::cancel_pending_tap_locked() -> void {
pending_tap_ = false; pending_tap_ = false;
} }
auto vamp_zero::zero_ui::arm_pending_tap() -> void { auto vamp_zero::ui_runtime::arm_pending_tap_locked() -> void {
tap_timer_.expires_after(touch_double_tap_duration); tap_timer_.expires_after(touch_double_tap_duration);
tap_timer_.async_wait([this](boost::system::error_code const &error) { tap_timer_.async_wait([weak = weak_from_this()](boost::system::error_code const &error) {
if (error) { auto self = weak.lock();
if (error || !self) {
return; return;
} }
auto send_next = std::bool_t{false}; auto send_next = std::bool_t{false};
{ {
auto lock = std::scoped_lock{touch_mutex_}; auto lock = std::scoped_lock{self->touch_mutex_};
send_next = pending_tap_; send_next = self->pending_tap_ && !self->stopped_;
pending_tap_ = false; self->pending_tap_ = false;
} }
if (send_next) { if (send_next) {
send_touch_command(vamp_protocol::page_command::NEXT); self->send_touch_command(vamp_protocol::page_command::NEXT);
} }
}); });
} }
auto vamp_zero::zero_ui::handle_touch(luma::touch_event const &event) -> std::bool_t { auto vamp_zero::ui_runtime::handle_touch(luma::touch_event const &event) -> std::bool_t {
if (!command_handler_ || event.points.empty()) { if (!command_handler_ || event.points.empty()) {
return false; return false;
} }
@@ -578,6 +634,9 @@ auto vamp_zero::zero_ui::handle_touch(luma::touch_event const &event) -> std::bo
auto arm_tap = std::bool_t{false}; auto arm_tap = std::bool_t{false};
{ {
auto lock = std::scoped_lock{touch_mutex_}; auto lock = std::scoped_lock{touch_mutex_};
if (stopped_) {
return false;
}
for (auto const &point : event.points) { for (auto const &point : event.points) {
if (point.phase == luma::touch_phase::DOWN && !active_touch_id_) { if (point.phase == luma::touch_phase::DOWN && !active_touch_id_) {
active_touch_id_ = point.id; active_touch_id_ = point.id;
@@ -625,55 +684,51 @@ auto vamp_zero::zero_ui::handle_touch(luma::touch_event const &event) -> std::bo
hold_sent_ = false; hold_sent_ = false;
} }
} }
if (arm_tap) {
arm_pending_tap_locked();
}
} }
for (auto command : commands) { for (auto command : commands) {
send_touch_command(command); send_touch_command(command);
} }
if (arm_tap) {
arm_pending_tap();
}
return consumed; return consumed;
} }
auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> { auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
auto state = snapshot(); if (auto status = application_->set_root(std::make_unique<vamp_root_control>(runtime_, score_loader_, *application_)); !status) {
auto root = make_root(state);
if (!root) {
return std::unexpected(root.error());
}
if (auto status = application_.set_root(std::move(*root)); !status) {
return status; return status;
} }
return application_.run(); auto status = application_->run();
stop();
return status;
} }
auto vamp_zero::zero_ui::stop() -> void { auto vamp_zero::zero_ui::stop() -> void {
tap_timer_.cancel(); {
application_.request_stop(); auto lock = std::scoped_lock{runtime_->touch_mutex_};
runtime_->stopped_ = true;
runtime_->pending_tap_ = false;
runtime_->tap_timer_.cancel();
}
score_loader_->stop();
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::ui_state state) -> std::expected<void, modulus::error> {
{ {
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{runtime_->mutex_};
state_ = std::move(state); if (!state.app.catalog) {
} state.app.catalog = vamp_zero::empty_catalogue();
return application_.dispatch([this] -> std::expected<void, modulus::error> {
auto root = make_root(snapshot());
if (!root) {
return std::unexpected(root.error());
} }
return application_.set_root(std::move(*root)); score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app));
}); runtime_->state_ = std::move(state);
}
return application_->request_layout();
} }
auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state { auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{runtime_->mutex_};
return state_; return runtime_->state_;
}
auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const &state) -> std::expected<std::unique_ptr<luma::control>, modulus::error> {
return std::make_unique<vamp_root_control>(
state, score_cache_, [this](luma::touch_event const &event) { return handle_touch(event); });
} }
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state { auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
+9
View File
@@ -1,15 +1,24 @@
add_executable(product-vamp-test 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/remote_display.cpp
test.cpp test.cpp
test_catalogue_runtime.cpp
test_health.cpp
test_pico.cpp
test_score_loader.cpp
) )
target_include_directories(product-vamp-test PRIVATE target_include_directories(product-vamp-test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/fixtures/pico_sdk
) )
target_link_libraries(product-vamp-test PRIVATE target_link_libraries(product-vamp-test PRIVATE
vamp-zero-core vamp-zero-core
) )
target_compile_features(product-vamp-test PRIVATE cxx_std_23) target_compile_features(product-vamp-test PRIVATE cxx_std_23)
target_compile_definitions(product-vamp-test PRIVATE target_compile_definitions(product-vamp-test PRIVATE
VAMP_ZERO_HOST="192.168.42.1"
VAMP_ZERO_PORT=8080
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets" VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
) )
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
#include <modulus/types.hpp>
struct ip_addr_t {
std::uint32_t addr{};
};
constexpr auto IPADDR_TYPE_V4 = std::uint8_t{0};
auto ipaddr_aton(std::char_t const *text, ip_addr_t *address) -> std::int32_t;
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <cstdint>
#include <modulus/types.hpp>
struct pbuf {
pbuf *next{};
void const *payload{};
std::uint16_t tot_len{};
std::uint16_t len{};
std::bool_t freed{};
};
auto pbuf_copy_partial(pbuf const *buffer, void *destination, std::uint16_t length, std::uint16_t offset) -> std::uint16_t;
auto pbuf_free(pbuf *buffer) -> std::uint8_t;
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <cstdint>
#include <lwip/ip_addr.h>
#include <lwip/pbuf.h>
constexpr auto ERR_OK = std::int8_t{0};
constexpr auto ERR_MEM = std::int8_t{-1};
constexpr auto ERR_ABRT = std::int8_t{-13};
constexpr auto ERR_RST = std::int8_t{-14};
constexpr auto TCP_WRITE_FLAG_COPY = std::uint8_t{1};
struct tcp_pcb {
void *argument{};
std::int8_t (*receive)(void *, tcp_pcb *, pbuf *, std::int8_t){};
std::int8_t (*sent)(void *, tcp_pcb *, std::uint16_t){};
std::int8_t (*poll)(void *, tcp_pcb *){};
void (*error)(void *, std::int8_t){};
std::int8_t (*connected)(void *, tcp_pcb *, std::int8_t){};
std::bool_t alive{};
};
auto tcp_new_ip_type(std::uint8_t type) -> tcp_pcb *;
auto tcp_arg(tcp_pcb *pcb, void *argument) -> void;
auto tcp_recv(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, pbuf *, std::int8_t)) -> void;
auto tcp_sent(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, std::uint16_t)) -> void;
auto tcp_poll(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *), std::uint8_t interval) -> void;
auto tcp_err(tcp_pcb *pcb, void (*callback)(void *, std::int8_t)) -> void;
auto tcp_connect(tcp_pcb *pcb, ip_addr_t const *address, std::uint16_t port, std::int8_t (*callback)(void *, tcp_pcb *, std::int8_t)) -> std::int8_t;
auto tcp_write(tcp_pcb *pcb, void const *data, std::uint16_t size, std::uint8_t flags) -> std::int8_t;
auto tcp_output(tcp_pcb *pcb) -> std::int8_t;
auto tcp_recved(tcp_pcb *pcb, std::uint16_t size) -> void;
auto tcp_close(tcp_pcb *pcb) -> std::int8_t;
auto tcp_abort(tcp_pcb *pcb) -> void;
+4
View File
@@ -0,0 +1,4 @@
#pragma once
auto cyw43_arch_lwip_begin() -> void;
auto cyw43_arch_lwip_end() -> void;
+15 -37
View File
@@ -130,7 +130,6 @@ auto navigation_state() -> vamp_zero::app_state {
.key_signature = "C", .key_signature = "C",
.part = "Lead", .part = "Lead",
.source_path = "navigation.json", .source_path = "navigation.json",
.source_json = score_asset_json("Navigation Tune"),
.measure_count = 1, .measure_count = 1,
.total_pages = 2, .total_pages = 2,
}; };
@@ -138,7 +137,6 @@ auto navigation_state() -> vamp_zero::app_state {
.id = "exercise-c", .id = "exercise-c",
.key_signature = "C", .key_signature = "C",
.source_path = "exercise.json", .source_path = "exercise.json",
.source_json = score_asset_json("Exercise"),
.measure_count = 1, .measure_count = 1,
.total_pages = 1, .total_pages = 1,
}; };
@@ -150,36 +148,15 @@ auto navigation_state() -> vamp_zero::app_state {
.lesson_group_title = "Major", .lesson_group_title = "Major",
.title = "C Major", .title = "C Major",
.composer = "Composer", .composer = "Composer",
.key_signature = "C", .authored_key_signature = "C",
.source_path = "exercise.json",
.source_json = score_asset_json("Exercise"),
.measure_count = 1,
.total_pages = 1,
.variants = {variant}, .variants = {variant},
}; };
auto practice = vamp_zero::group_practice_exercises({exercise});
return vamp_zero::app_state{ return vamp_zero::app_state{
.performance = .catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{
{ .performance = vamp_zero::group_library_parts({part}),
.parts = {part}, .practice = std::move(*practice),
.groups = {{ }),
.title = part.title,
.composer = part.composer,
.parts = {part},
}},
},
.practice =
{
.exercises = {exercise},
.types = {{
.id = "scale",
.title = "Scales",
.lesson_groups = {{
.id = "lesson-1",
.title = "Major",
.exercises = {exercise},
}},
}},
},
}; };
} }
@@ -325,17 +302,17 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_display) { BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_display) {
for (auto type : {"chord", "melody"}) { for (auto type : {"chord", "melody"}) {
auto state = navigation_state(); auto state = navigation_state();
auto &exercise = state.practice.exercises.front(); auto catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog);
auto &exercise = catalog->practice.exercises.front();
exercise.practice_type_id = type; exercise.practice_type_id = type;
auto variant = exercise.variants.front(); auto variant = exercise.variants.front();
variant.id = "exercise-d"; variant.id = "exercise-d";
variant.key_signature = "D"; variant.key_signature = "D";
variant.source_path = "exercise-d.json"; variant.source_path = "exercise-d.json";
variant.source_json = score_asset_json("Exercise", "D");
variant.total_pages = 3U; variant.total_pages = 3U;
exercise.variants.push_back(variant); exercise.variants.push_back(variant);
state.practice.types.front().id = type; catalog->practice.types.front().id = type;
state.practice.types.front().lesson_groups.front().exercises = {exercise}; state.catalog = catalog;
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
state.selected_practice_type_index = 1U; state.selected_practice_type_index = 1U;
state.selected_practice_lesson_index = 1U; state.selected_practice_lesson_index = 1U;
@@ -350,7 +327,7 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE);
BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(state.audio.metronome_enabled);
auto selected = vamp_zero::selected_practice_variant(state, state.practice.exercises.front()); auto selected = vamp_zero::selected_practice_variant(state, state.catalog->practice.exercises.front());
BOOST_REQUIRE(selected != nullptr); BOOST_REQUIRE(selected != nullptr);
BOOST_TEST(selected->id == "exercise-d"); BOOST_TEST(selected->id == "exercise-d");
BOOST_TEST(selected->source_path == "exercise-d.json"); BOOST_TEST(selected->source_path == "exercise-d.json");
@@ -400,7 +377,8 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
for (auto index = std::size_t{}; index < keys.size(); ++index) { for (auto index = std::size_t{}; index < keys.size(); ++index) {
auto const &key = exercise.variants[index].key_signature; auto const &key = exercise.variants[index].key_signature;
BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]); BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]);
BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path)); BOOST_TEST(exercise.variants[index].total_pages > 0U);
BOOST_TEST(exercise.variants[index].measure_count > 0U);
} }
} }
} }
@@ -424,8 +402,8 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
auto const &lesson = library->types.front().lesson_groups.front(); auto const &lesson = library->types.front().lesson_groups.front();
BOOST_TEST(lesson.id == "001_lesson"); BOOST_TEST(lesson.id == "001_lesson");
BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U); BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U);
BOOST_TEST(lesson.exercises[0].id == "chord-001_lesson-002-cycles"); BOOST_TEST(library->exercises[lesson.exercises[0]].id == "chord-001_lesson-002-cycles");
BOOST_TEST(lesson.exercises[1].id == "chord-001_lesson-010-cycles"); BOOST_TEST(library->exercises[lesson.exercises[1]].id == "chord-001_lesson-010-cycles");
for (auto const &exercise : library->exercises) { for (auto const &exercise : library->exercises) {
BOOST_TEST(exercise.title == "Shared Title"); BOOST_TEST(exercise.title == "Shared Title");
} }
+564
View File
@@ -0,0 +1,564 @@
#include <boost/test/unit_test.hpp>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <expected>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <variant>
#include <vector>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <noto.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/portal.hpp>
namespace {
template <typename T>
concept retains_score_json = requires(T value) { value.source_json; };
static_assert(!retains_score_json<vamp_zero::library_part>);
static_assert(!retains_score_json<vamp_zero::practice_exercise>);
static_assert(!retains_score_json<vamp_zero::practice_exercise::score_variant>);
struct catalogue_directory {
std::filesystem::path path;
catalogue_directory() {
auto pattern = (std::filesystem::temp_directory_path() / "vamp-catalogue-XXXXXX").string();
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
path = pattern;
}
~catalogue_directory() {
auto ignored = std::error_code{};
std::filesystem::remove_all(path, ignored);
}
};
template <typename Function, typename... Arguments>
auto run_catalogue_operation(Function function, Arguments... arguments) {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, function(io.get_executor(), std::move(arguments)...), boost::asio::use_future);
io.run();
return future.get();
}
auto catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string sql, std::vector<noto::sqlite_value> params)
-> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
auto connection = noto::connection{executor, noto::file_connection_config(config.path, "vamp-catalogue-test")};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto result = co_await connection.async_execute(std::move(sql), std::move(params));
auto closed = co_await connection.async_close();
if (!result) {
co_return std::unexpected(result.error());
}
if (!closed) {
co_return std::unexpected(closed.error());
}
co_return result;
}
auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::optional<std::string_view> authored = std::nullopt) -> std::string {
auto value = boost::json::parse(R"json({
"schema_version":3,
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"name":"Lead","measures":[{"voices":[]},{"voices":[]},{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[
{"path":"fixture.pages/1.png","sha256":"0000000000000000000000000000000000000000000000000000000000000000"},
{"path":"fixture.pages/2.png","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}]}
})json");
auto &metadata = value.at("music").at("metadata").as_object();
metadata["title"] = title;
metadata["key_signature"] = key;
if (authored) {
metadata["authored_key_signature"] = *authored;
}
return boost::json::serialize(value);
}
struct catalogue_database {
catalogue_directory directory;
std::filesystem::path assets{directory.path / "assets"};
vamp_zero::database_config config{.path = (directory.path / "catalogue.db").string()};
catalogue_database() {
std::filesystem::create_directories(assets / "songs");
}
auto query(std::string sql, std::vector<noto::sqlite_value> params = {}) const -> noto::query_result {
auto result = run_catalogue_operation(catalogue_query, config, std::move(sql), std::move(params));
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
return std::move(*result);
}
auto rows(std::string const &table, std::string const &where = "") const -> std::vector<std::string> {
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, catalog_managed, "
"measure_count, total_pages, authored_key_signature, created_at_ms, updated_at_ms) FROM " +
table + where + " ORDER BY id");
auto rows = std::vector<std::string>{};
for (auto const &row : result.rows) {
rows.push_back(std::get<std::string>(row[0].value));
}
return rows;
}
auto write(std::filesystem::path const &relative, std::string const &text) const -> void {
auto path = assets / relative;
std::filesystem::create_directories(path.parent_path());
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
output << text;
output.close();
BOOST_REQUIRE(output.good());
}
auto ensure() const -> std::expected<void, modulus::error> {
return run_catalogue_operation(vamp_zero::async_ensure_database, config);
}
auto import_assets() const -> std::expected<void, modulus::error> {
return run_catalogue_operation(vamp_zero::async_import_catalogs, config, assets.string());
}
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_performance_library, config);
}
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_practice_library, config);
}
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)});
}
};
auto catalogue_exercise(std::string key, std::string authored = "Eb", std::string id = "exercise", std::string lesson = "lesson") -> vamp_zero::practice_exercise {
auto variant_id = id + "-" + key;
return {
.id = std::move(id),
.practice_type_id = "melody",
.practice_type_title = "Melody Practice",
.lesson_group_id = std::move(lesson),
.lesson_group_title = "Lesson",
.title = "Exercise",
.composer = "Composer",
.authored_key_signature = std::move(authored),
.variants = {{.id = std::move(variant_id), .key_signature = std::move(key), .measure_count = 3, .total_pages = 2}},
};
}
}
BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutable_metadata) {
auto performance = vamp_zero::group_library_parts({
{.id = "other", .title = "A Tune", .composer = "Composer", .part = "Lead", .total_pages = 1},
{.id = "chosen", .title = "B Tune", .composer = "Composer", .part = "Lead", .total_pages = 2},
});
auto practice = vamp_zero::group_practice_exercises({catalogue_exercise("D", "Eb", "chosen", "b"), catalogue_exercise("C", "Eb", "other", "a")});
BOOST_REQUIRE(practice);
auto catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{std::move(performance), std::move(*practice)});
auto original = vamp_zero::app_state{
.catalog = catalog,
.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU,
.selected_song_index = 2,
.selected_part_index = 1,
.selected_practice_type_index = 1,
.selected_practice_lesson_index = 2,
.selected_practice_exercise_index = 1,
};
auto control = vamp_zero::control_state{{}, original};
auto observed = std::vector<vamp_zero::app_state>{};
control.set_observer([&](vamp_zero::app_state state) { observed.push_back(std::move(state)); });
auto before = control.snapshot();
BOOST_TEST(before.catalog.get() == catalog.get());
BOOST_TEST(vamp_zero::selected_part(before) == &catalog->performance.parts[1]);
BOOST_TEST(vamp_zero::selected_practice_exercise(before) == &catalog->practice.exercises[1]);
auto heartbeat = control.heartbeat();
BOOST_TEST(heartbeat.catalog.get() == catalog.get());
BOOST_TEST(heartbeat.heartbeat_count == 1U);
BOOST_TEST(heartbeat.pico_connected);
auto playing = control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(vamp_zero::to_string(playing.mode) == "performance_score");
BOOST_TEST(vamp_zero::active_part(playing) == &catalog->performance.parts[1]);
BOOST_TEST(control.apply(vamp_protocol::page_command::NEXT).page_index == 1U);
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.active_part_id.empty());
BOOST_TEST(original.catalog.get() == catalog.get());
BOOST_REQUIRE(!observed.empty());
for (auto const &snapshot : observed) {
BOOST_TEST(snapshot.catalog.get() == catalog.get());
}
original.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
auto practice_control = vamp_zero::control_state{{}, original};
auto keys = practice_control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(vamp_zero::to_string(keys.mode) == "practice_key_menu");
auto selected = practice_control.apply(vamp_protocol::page_command::NEXT);
BOOST_TEST(selected.catalog.get() == catalog.get());
BOOST_TEST(vamp_zero::selected_practice_variant(selected, catalog->practice.exercises[1]) == &catalog->practice.exercises[1].variants[0]);
auto exercise = practice_control.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(vamp_zero::to_string(exercise.mode) == "practice_score");
BOOST_TEST(exercise.catalog.get() == catalog.get());
BOOST_TEST(vamp_zero::active_practice(exercise) == &catalog->practice.exercises[1]);
BOOST_TEST(practice_control.apply(vamp_protocol::page_command::NEXT).page_index == 1U);
BOOST_TEST(practice_control.heartbeat().catalog.get() == catalog.get());
BOOST_TEST(keys.active_practice_id.empty());
BOOST_TEST(keys.selected_practice_key_index == 0U);
}
BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_part_indices) {
auto library = vamp_zero::group_library_parts({
{.id = "other-composer", .title = "Shared Title", .composer = "Bea", .part = "Lead"},
{.id = "vocal", .title = "Shared Title", .composer = "Ada", .part = "Vocal"},
{.id = "other-title", .title = "Other Title", .composer = "Ada", .part = "Lead"},
{.id = "guitar", .title = "Shared Title", .composer = "Ada", .part = "Guitar"},
});
BOOST_REQUIRE_EQUAL(library.parts.size(), 4U);
BOOST_REQUIRE_EQUAL(library.groups.size(), 3U);
BOOST_TEST(library.groups[0].title == "Other Title");
BOOST_TEST(library.groups[1].title == "Shared Title");
BOOST_TEST(library.groups[1].composer == "Ada");
BOOST_TEST(library.groups[2].composer == "Bea");
BOOST_REQUIRE_EQUAL(library.groups[1].parts.size(), 2U);
auto seen = std::vector<std::uint32_t>(library.parts.size());
for (auto const &group : library.groups) {
for (auto index : group.parts) {
BOOST_REQUIRE_LT(index, library.parts.size());
auto const &part = library.parts[index];
BOOST_TEST(part.title == group.title);
BOOST_TEST(part.composer == group.composer);
++seen[index];
}
}
for (auto count : seen) {
BOOST_TEST(count == 1U);
}
BOOST_TEST(library.parts[library.groups[1].parts[0]].id == "guitar");
BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal");
}
BOOST_AUTO_TEST_CASE(catalogue_practice_groups_require_authored_key_agreement_and_index_exercises) {
for (auto authored : {"Eb", "C dorian", "No key", ""}) {
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("D", authored), catalogue_exercise("C", authored), catalogue_exercise("F", "F", "second", "second")});
BOOST_REQUIRE(grouped);
BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U);
auto const &exercise = grouped->exercises[0];
BOOST_TEST(exercise.authored_key_signature == authored);
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 2U);
BOOST_TEST(exercise.variants[0].key_signature == "C");
BOOST_TEST(exercise.variants[1].key_signature == "D");
BOOST_REQUIRE_EQUAL(grouped->types.size(), 1U);
auto const &lessons = grouped->types[0].lesson_groups;
BOOST_REQUIRE_EQUAL(lessons.size(), 2U);
for (auto index = std::size_t{}; index < lessons.size(); ++index) {
BOOST_REQUIRE_EQUAL(lessons[index].exercises.size(), 1U);
BOOST_TEST(lessons[index].exercises[0] == index);
BOOST_TEST(grouped->exercises[lessons[index].exercises[0]].lesson_group_id == lessons[index].id);
}
}
for (auto conflict : {"F", ""}) {
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("C"), catalogue_exercise("D", conflict)});
BOOST_REQUIRE(!grouped);
BOOST_TEST(grouped.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(grouped.error().message().find("authored key") != std::string_view::npos);
}
}
BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_nonempty_string) {
auto legacy = vamp_zero::parse_score_document(catalogue_bundle("Legacy", "D"), "legacy.json");
BOOST_REQUIRE(legacy);
BOOST_TEST(legacy->authored_key_signature.empty());
for (auto authored : {"Eb", "C minor", "C dorian", "No key"}) {
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/melody/lesson/exercise/d.json");
BOOST_REQUIRE(document);
BOOST_TEST(document->authored_key_signature == authored);
auto metadata = vamp_zero::practice_metadata(*document, "/assets");
BOOST_REQUIRE(metadata);
BOOST_TEST(metadata->authored_key_signature == authored);
BOOST_TEST(metadata->variants[0].key_signature == "D");
}
for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) {
auto value = boost::json::parse(catalogue_bundle("Invalid"));
value.at("music").at("metadata").as_object()["authored_key_signature"] = invalid;
auto document = vamp_zero::parse_score_document(boost::json::serialize(value), "invalid.json");
BOOST_REQUIRE(!document);
BOOST_TEST(document.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(document.error().message().find("authored_key_signature") != std::string_view::npos);
}
}
BOOST_AUTO_TEST_CASE(catalogue_streamed_import_rolls_back_and_preserves_unmanaged_rows) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/a-managed.json", catalogue_bundle("Original"));
db.write("songs/custom.json", catalogue_bundle("Custom Song"));
db.write("songs/stale.json", catalogue_bundle("Stale"));
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Exercise", "D", "Eb"));
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Custom Exercise", "C", "Eb"));
BOOST_REQUIRE(db.import_assets());
db.query("UPDATE songs SET catalog_managed = 0 WHERE id = 'custom'");
db.query("UPDATE practice_exercises SET catalog_managed = 0 WHERE id = 'melody-lesson-custom-c'");
auto songs = db.rows("songs");
auto practice = db.rows("practice_exercises");
auto custom_songs = db.rows("songs", " WHERE catalog_managed = 0");
auto custom_practice = db.rows("practice_exercises", " WHERE catalog_managed = 0");
BOOST_REQUIRE_EQUAL(custom_songs.size(), 1U);
BOOST_REQUIRE_EQUAL(custom_practice.size(), 1U);
db.write("songs/a-managed.json", catalogue_bundle("Updated"));
db.write("songs/b-new.json", catalogue_bundle("New"));
db.write("songs/custom.json", catalogue_bundle("Collision"));
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Collision", "C", "Eb"));
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Updated Exercise", "C", "Eb"));
std::filesystem::remove(db.assets / "songs/stale.json");
for (auto invalid : {std::string{"{}"}, catalogue_bundle("Exercise", "D", "F")}) {
db.write("practice/melody/lesson/exercise/d.json", invalid);
auto imported = db.import_assets();
BOOST_REQUIRE(!imported);
BOOST_TEST(imported.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
}
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Updated Exercise", "D", "Eb"));
BOOST_REQUIRE(db.import_assets());
auto updated = db.query("SELECT title FROM songs WHERE id = 'a-managed'");
BOOST_REQUIRE_EQUAL(updated.rows.size(), 1U);
BOOST_TEST(std::get<std::string>(updated.rows[0][0].value) == "Updated");
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'b-new'").rows.size() == 1U);
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'stale'").rows.empty());
BOOST_TEST(db.rows("songs", " WHERE catalog_managed = 0") == custom_songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises", " WHERE catalog_managed = 0") == custom_practice, boost::test_tools::per_element());
std::filesystem::remove_all(db.assets);
std::filesystem::create_directories(db.assets / "songs");
BOOST_REQUIRE(db.import_assets());
BOOST_TEST(db.rows("songs") == custom_songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == custom_practice, boost::test_tools::per_element());
}
BOOST_AUTO_TEST_CASE(catalogue_legacy_metadata_backfill_retains_custom_rows_and_score_json) {
auto db = catalogue_database{};
db.query(R"sql(CREATE TABLE songs (
id TEXT PRIMARY KEY, title TEXT NOT NULL, composer TEXT NOT NULL, source_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, part TEXT NOT NULL,
source_path TEXT NOT NULL, key_signature TEXT NOT NULL, catalog_managed INTEGER NOT NULL
) STRICT)sql");
db.query(R"sql(CREATE TABLE practice_exercises (
id TEXT PRIMARY KEY, practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL, title TEXT NOT NULL, composer TEXT NOT NULL,
key_signature TEXT NOT NULL, source_json TEXT NOT NULL, source_path TEXT NOT NULL,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, catalog_managed INTEGER NOT NULL
) STRICT)sql");
auto song = catalogue_bundle("Embedded Song", "D");
auto exercise = catalogue_bundle("Embedded Exercise", "D", "Eb");
db.query("INSERT INTO songs VALUES('legacy-song', 'Custom Song', 'Composer', ?1, 11, 17, 'Lead', 'legacy.json', 'D', 0)", {noto::sqlite_value{song}});
db.query("INSERT INTO practice_exercises VALUES('legacy-practice', 'melody', 'Melody Practice', 'lesson', 'Lesson', "
"'Custom Exercise', 'Composer', 'D', ?1, 'practice/melody/lesson/exercise/d.json', 11, 17, 0)",
{noto::sqlite_value{exercise}});
BOOST_REQUIRE(db.ensure());
for (auto table : {"songs", "practice_exercises"}) {
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
auto performance = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].id == "legacy-song");
BOOST_TEST(performance->parts[0].title == "Custom Song");
BOOST_TEST(performance->parts[0].measure_count == 3U);
BOOST_TEST(performance->parts[0].total_pages == 2U);
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
BOOST_TEST(practice->exercises[0].title == "Custom Exercise");
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
BOOST_TEST(practice->exercises[0].variants[0].id == "legacy-practice");
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 3U);
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 2U);
for (auto table : {"songs", "practice_exercises"}) {
auto row = db.query("SELECT source_json, catalog_managed, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature FROM " + std::string{table});
BOOST_REQUIRE_EQUAL(row.rows.size(), 1U);
auto const &values = row.rows[0];
auto is_song = std::string_view{table} == "songs";
BOOST_TEST(std::get<std::string>(values[0].value) == (is_song ? song : exercise));
BOOST_TEST(std::get<std::int64_t>(values[1].value) == 0);
BOOST_TEST(std::get<std::int64_t>(values[2].value) == 11);
BOOST_TEST(std::get<std::int64_t>(values[3].value) == 17);
BOOST_TEST(std::get<std::int64_t>(values[4].value) == 3);
BOOST_TEST(std::get<std::int64_t>(values[5].value) == 2);
BOOST_TEST(std::get<std::string>(values[6].value) == (is_song ? "" : "Eb"));
}
auto songs = db.rows("songs");
auto exercises = db.rows("practice_exercises");
BOOST_REQUIRE(db.ensure());
BOOST_REQUIRE(db.performance());
BOOST_REQUIRE(db.practice());
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
}
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
auto song = catalogue_bundle("Song", "F");
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
db.write("songs/shared.json", song);
db.write("practice/melody/lesson/exercise/d.json", exercise);
BOOST_REQUIRE(db.import_assets());
db.query("UPDATE practice_exercises SET id = 'shared'");
std::filesystem::remove_all(db.assets);
for (auto category : {vamp_zero::score_category::PERFORMANCE, vamp_zero::score_category::PRACTICE}) {
auto is_song = category == vamp_zero::score_category::PERFORMANCE;
auto document = db.score(category, "shared");
BOOST_REQUIRE(document);
BOOST_TEST(document->id == "shared");
BOOST_TEST(document->title == (is_song ? "Song" : "Exercise"));
BOOST_TEST(document->key_signature == (is_song ? "F" : "D"));
BOOST_TEST(document->authored_key_signature == (is_song ? "" : "Eb"));
BOOST_TEST(document->source_json == (is_song ? song : exercise));
BOOST_TEST(document->source_path == (db.assets / (is_song ? "songs/shared.json" : "practice/melody/lesson/exercise/d.json")).string());
BOOST_TEST(document->measure_count == 3U);
BOOST_REQUIRE_EQUAL(document->pages.size(), 2U);
BOOST_TEST(document->pages[1].path == "fixture.pages/2.png");
for (auto id : {"missing", "shared' OR 1=1 --"}) {
auto missing = db.score(category, id);
BOOST_REQUIRE(!missing);
BOOST_TEST(missing.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::NOT_FOUND));
}
auto table = std::string{is_song ? "songs" : "practice_exercises"};
db.query("UPDATE " + table + " SET source_json = '{}' WHERE id = 'shared'");
auto invalid = db.score(category, "shared");
BOOST_REQUIRE(!invalid);
BOOST_TEST(invalid.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_REQUIRE(db.score(is_song ? vamp_zero::score_category::PRACTICE : vamp_zero::score_category::PERFORMANCE, "shared"));
db.query("UPDATE " + table + " SET source_json = ?1 WHERE id = 'shared'", {noto::sqlite_value{is_song ? song : exercise}});
}
}
BOOST_AUTO_TEST_CASE(catalogue_sequential_score_loads_keep_executor_stack_bounded) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/repeated.json", catalogue_bundle("Repeated"));
BOOST_REQUIRE(db.import_assets());
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config)
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = "repeated"};
for (auto count = std::uint32_t{}; count < 4096U; ++count) {
auto score = co_await vamp_zero::async_load_score(executor, config, reference);
if (!score) {
co_return std::unexpected(score.error());
}
}
co_return 4096U;
};
auto result = run_catalogue_operation(repeated_loads, db.config);
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
BOOST_TEST(*result == 4096U);
}
BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score_json) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
// Complete cached metadata must not require parsing or retaining the score body.
db.query("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
"key_signature, measure_count, total_pages, authored_key_signature) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 1, 1, 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
for (auto key : {"C", "D"}) {
db.query("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
"title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature) "
"VALUES(?1, 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 1, 1, 3, 2, 'Eb')",
{noto::sqlite_value{std::string{"variant-"} + key}, noto::sqlite_value{std::string{key}},
noto::sqlite_value{std::string{"practice/melody/lesson/exercise/"} + key + ".json"}});
}
auto songs = db.rows("songs");
auto exercises = db.rows("practice_exercises");
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
auto performance = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].title == "Metadata Song");
BOOST_TEST(performance->parts[0].key_signature == "F");
BOOST_TEST(performance->parts[0].measure_count == 3U);
BOOST_TEST(performance->parts[0].total_pages == 2U);
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
BOOST_TEST(practice->exercises[0].title == "Metadata Exercise");
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 2U);
BOOST_TEST(practice->exercises[0].variants[0].key_signature == "C");
BOOST_TEST(practice->exercises[0].variants[1].key_signature == "D");
for (auto const &variant : practice->exercises[0].variants) {
BOOST_TEST(variant.measure_count == 3U);
BOOST_TEST(variant.total_pages == 2U);
}
}
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
auto song = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
auto exercise = db.score(vamp_zero::score_category::PRACTICE, "variant-D");
BOOST_REQUIRE(!song);
BOOST_REQUIRE(!exercise);
BOOST_TEST(song.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(exercise.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
db.query("UPDATE practice_exercises SET authored_key_signature = 'F' WHERE id = 'variant-D'");
auto disagreement = db.practice();
BOOST_REQUIRE(!disagreement);
BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos);
}
BOOST_AUTO_TEST_CASE(catalogue_changed_score_json_invalidates_metadata_and_requires_successful_backfill) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
auto original = catalogue_bundle("Original", "D", "Eb");
db.write("songs/tune.json", original);
db.write("practice/melody/lesson/exercise/d.json", original);
BOOST_REQUIRE(db.import_assets());
auto changed = boost::json::parse(catalogue_bundle("Changed", "D", "F"));
changed.at("music").at("staves").as_array()[0].at("measures").as_array().pop_back();
changed.at("presentation").at("pages").as_array().pop_back();
for (auto table : {"songs", "practice_exercises"}) {
auto name = std::string{table};
auto before = db.rows(name);
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{original}});
BOOST_TEST(db.rows(name) == before, boost::test_tools::per_element());
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{boost::json::serialize(changed)}});
BOOST_TEST(db.query("SELECT id FROM " + name + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
auto performance = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].measure_count == 2U);
BOOST_TEST(performance->parts[0].total_pages == 1U);
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
BOOST_TEST(practice->exercises[0].authored_key_signature == "F");
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 2U);
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 1U);
for (auto table : {"songs", "practice_exercises"}) {
db.query("UPDATE " + std::string{table} + " SET source_json = '{}'");
}
performance = db.performance();
practice = db.practice();
BOOST_REQUIRE(!performance);
BOOST_REQUIRE(!practice);
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
for (auto table : {"songs", "practice_exercises"}) {
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
}
+411
View File
@@ -0,0 +1,411 @@
#include <boost/test/unit_test.hpp>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <future>
#include <iterator>
#include <string>
#include <thread>
#include <utility>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <openssl/sha.h>
#include <vamp_zero/app.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/health.hpp>
#include <vamp_zero/portal.hpp>
namespace {
struct temporary_directory {
std::filesystem::path path;
temporary_directory() {
auto pattern = (std::filesystem::temp_directory_path() / "vamp-health-XXXXXX").string();
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
path = pattern;
}
~temporary_directory() {
auto ignored = std::error_code{};
std::filesystem::remove_all(path, ignored);
}
};
auto write_file(std::filesystem::path const &path, std::string const &bytes) -> void {
std::filesystem::create_directories(path.parent_path());
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
output << bytes;
output.close();
BOOST_REQUIRE(output.good());
}
auto read_file(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 healthy_payload() -> boost::json::value {
return boost::json::object{{"device", "zero"}, {"status", "ready"}, {"product", "product-vamp"}};
}
auto health_router(boost::json::value payload) -> ligo::service_router {
auto router = ligo::service_router{};
router.add_route("GET", "/api/health", [payload = std::move(payload)](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> { co_return payload; });
return router;
}
struct http_service {
boost::asio::io_context io;
ligo::http::server server;
std::thread thread;
explicit http_service(ligo::service_router router, std::string host = "127.0.0.1")
: server{io, {.host = std::move(host)}, std::move(router), {.timeout = std::chrono::milliseconds{250}}} {
auto started = server.start();
BOOST_REQUIRE_MESSAGE(started, (started ? "" : started.error().describe()));
thread = std::thread{[this] { io.run(); }};
}
~http_service() {
server.stop();
io.stop();
thread.join();
}
auto config(std::string host = "127.0.0.1") const -> vamp_zero::config {
auto result = vamp_zero::config{};
result.control.endpoint.bind = host + ":" + std::to_string(server.selected_port());
result.control.endpoint.timeout = std::chrono::milliseconds{250};
result.control.zero_ip = "192.0.2.1";
result.control.endpoint.connect = "192.0.2.2:1";
return result;
}
};
auto unused_port() -> std::uint16_t {
auto io = boost::asio::io_context{};
auto acceptor = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
return acceptor.local_endpoint().port();
}
auto fixture_config(std::filesystem::path const &root) -> vamp_zero::config {
auto config = vamp_zero::config{};
config.runtime.assets_path = (root / "assets").string();
config.runtime.heartbeat_path = (root / "run" / "heartbeat.json").string();
config.database.path = (root / "db" / "vamp.db").string();
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
config.control.endpoint.timeout = std::chrono::milliseconds{250};
return config;
}
auto config_file(std::filesystem::path const &root, vamp_zero::config const &config) -> std::string {
auto json = boost::json::object{
{"modulus", modulus::to_json(config.modulus)},
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"database", {{"path", config.database.path}}},
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
{"audio", {{"enabled", true}, {"track_enabled", true}, {"metronome_enabled", true}}},
};
auto path = root / "config.json";
write_file(path, boost::json::serialize(json));
return path.string();
}
auto fixture_page() -> std::string {
auto bytes = std::array<std::uchar_t, 24>{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13,
73, 72, 68, 82, 0, 0, 3, 208, 0, 0, 1, 228};
return {reinterpret_cast<std::char_t const *>(bytes.data()), bytes.size()};
}
auto checksum(std::string const &bytes) -> std::string {
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<std::uchar_t const *>(bytes.data()), bytes.size(), digest.data());
auto result = std::string{};
constexpr auto digits = std::string_view{"0123456789abcdef"};
for (std::uchar_t byte : digest) {
result.push_back(digits[byte >> 4U]);
result.push_back(digits[byte & 15U]);
}
return result;
}
auto write_score(std::filesystem::path const &path, std::string key = "C") -> void {
auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer",
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[]}})");
score.at("music").at("metadata").as_object()["key_signature"] = key;
auto &pages = score.at("presentation").at("pages").as_array();
for (auto index = std::uint32_t{1}; index <= 2U; ++index) {
auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png";
auto bytes = fixture_page();
write_file(path.parent_path() / relative, bytes);
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
}
write_file(path, boost::json::serialize(score));
}
auto make_assets(vamp_zero::config const &config) -> void {
auto root = std::filesystem::path{config.runtime.assets_path};
write_score(root / "songs" / "first.json");
write_score(root / "songs" / "second.json");
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "c.json");
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "d.json", "D");
}
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
return vamp_zero::run({.config_path = config_file(root, config), .exit_after_startup = true});
}
auto require_no_probe(vamp_zero::config const &config, std::bool_t heartbeat_exists = false) -> void {
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
if (!std::filesystem::exists(directory)) {
return;
}
auto count = std::uint32_t{};
for (auto const &entry : std::filesystem::directory_iterator{directory}) {
BOOST_TEST(entry.path().filename().string() == "heartbeat.json");
++count;
}
BOOST_TEST(count == (heartbeat_exists ? 1U : 0U));
}
}
BOOST_AUTO_TEST_CASE(health_uses_configured_bind_and_normalizes_wildcards) {
auto service = http_service{health_router(healthy_payload())};
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
BOOST_TEST(vamp_zero::check_health(service.config("0.0.0.0")).has_value());
auto other = http_service{health_router(healthy_payload()), "127.0.0.2"};
BOOST_TEST(vamp_zero::check_health(other.config("127.0.0.2")).has_value());
auto ipv6 = http_service{health_router(healthy_payload()), "::1"};
BOOST_TEST(vamp_zero::check_health(ipv6.config("[::]")).has_value());
BOOST_TEST(vamp_zero::check_health(ipv6.config("[0:0:0:0:0:0:0:0]")).has_value());
}
BOOST_AUTO_TEST_CASE(health_rejects_wrong_device_product_status_and_payload_types) {
for (std::char_t const *field : {"product", "device", "status"}) {
for (auto value : {boost::json::value{"wrong"}, boost::json::value{42}, boost::json::value{}}) {
auto payload = healthy_payload();
payload.as_object()[field] = value;
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
auto payload = healthy_payload();
payload.as_object().erase(field);
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
for (auto payload : {boost::json::value{}, boost::json::value{true}, boost::json::value{boost::json::array{}}}) {
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
}
BOOST_AUTO_TEST_CASE(health_rejects_malformed_or_failed_envelopes_and_missing_routes) {
for (std::char_t const *body : {"not JSON", "{}", "[]", "{\"ok\":true}", "{\"ok\":false}",
"{\"device\":\"zero\",\"product\":\"product-vamp\",\"status\":\"ready\"}"}) {
auto router = ligo::service_router{};
router.add_response_route("GET", "/api/health", [body](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
co_return ligo::text_response("application/json", body);
});
auto service = http_service{std::move(router)};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
auto service = http_service{ligo::service_router{}};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
BOOST_AUTO_TEST_CASE(health_fails_when_stopped_and_obeys_timeout) {
auto config = vamp_zero::config{};
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
config.control.endpoint.timeout = std::chrono::milliseconds{50};
BOOST_TEST(!vamp_zero::check_health(config));
auto io = boost::asio::io_context{};
auto stalled = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(stalled.local_endpoint().port());
auto started = std::chrono::steady_clock::now();
BOOST_TEST(!vamp_zero::check_health(config));
auto elapsed = std::chrono::steady_clock::now() - started;
BOOST_TEST((elapsed < std::chrono::seconds{2}));
}
BOOST_AUTO_TEST_CASE(health_command_never_mutates_database_assets_or_runtime) {
auto root = temporary_directory{};
auto service = http_service{health_router(healthy_payload())};
auto config = fixture_config(root.path);
config.control = service.config().control;
config.runtime.exit_after_startup = true;
auto log_level = modulus::default_logger().level();
auto error_config = modulus::default_error_config();
config.modulus.logging.level = log_level == modulus::log_level::DEBUG ? modulus::log_level::ERROR : modulus::log_level::DEBUG;
config.modulus.errors.include_location = !error_config.include_location;
auto command = vamp_zero::command_line{.config_path = config_file(root.path, config), .health_check = true};
BOOST_TEST(vamp_zero::run(command).has_value());
BOOST_TEST((modulus::default_logger().level() == log_level));
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
BOOST_TEST(!std::filesystem::exists(root.path / "db"));
BOOST_TEST(!std::filesystem::exists(root.path / "assets"));
BOOST_TEST(!std::filesystem::exists(root.path / "run"));
write_file(config.database.path, "not a database");
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json", "not JSON");
write_file(config.runtime.heartbeat_path, "stale heartbeat");
auto database_time = std::filesystem::last_write_time(config.database.path);
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
BOOST_TEST(vamp_zero::run(command).has_value());
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
command.config_path = config_file(root.path, config);
BOOST_TEST(!vamp_zero::run(command));
BOOST_TEST((modulus::default_logger().level() == log_level));
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
BOOST_TEST(read_file(config.database.path) == "not a database");
BOOST_TEST(read_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json") == "not JSON");
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "stale heartbeat");
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
require_no_probe(config, true);
}
BOOST_AUTO_TEST_CASE(portal_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.pico_connected);
BOOST_TEST(snapshot.last_command == "startup");
}
BOOST_AUTO_TEST_CASE(portal_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;
auto router = vamp_zero::make_router(state);
auto io = boost::asio::io_context{};
auto response = boost::asio::co_spawn(io, router.call_response({.method = "POST", .route = route}, {}), boost::asio::use_future);
io.run();
BOOST_REQUIRE(response.get());
auto snapshot = state.snapshot();
BOOST_TEST(snapshot.pico_connected);
BOOST_TEST(snapshot.heartbeat_count == 1U);
BOOST_TEST(snapshot.catalog.get() == catalog.get());
}
}
BOOST_AUTO_TEST_CASE(readiness_imports_catalogue_without_hardware_or_heartbeat) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
BOOST_TEST(std::filesystem::exists(config.database.path));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
require_no_probe(config);
auto io = boost::asio::io_context{};
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
io.run();
auto songs = performance.get();
auto exercises = practice.get();
BOOST_REQUIRE(songs);
BOOST_REQUIRE(exercises);
BOOST_TEST(songs->parts.size() == 2U);
BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U);
BOOST_TEST(exercises->exercises.front().variants.size() == 2U);
write_file(config.runtime.heartbeat_path, "existing heartbeat");
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
config.runtime.exit_after_startup = true;
ready = vamp_zero::run({.config_path = config_file(root.path, config)});
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
require_no_probe(config, true);
}
BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants) {
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/melody/lesson/exercise/d.pages/2.png"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
std::filesystem::remove(std::filesystem::path{config.runtime.assets_path} / relative);
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("page is missing") != std::string::npos);
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
for (std::bool_t escape : {false, true}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto page = std::filesystem::path{config.runtime.assets_path} / "songs" / "second.pages" / "2.png";
if (escape) {
write_file(root.path / "outside.png", fixture_page());
std::filesystem::remove(page);
std::filesystem::create_symlink(root.path / "outside.png", page);
} else {
auto bytes = fixture_page();
bytes.back() = 0;
write_file(page, bytes);
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
auto expected = std::string_view{escape ? "escapes its asset directory" : "checksum mismatch"};
BOOST_TEST(std::string{ready.error().message()}.find(expected) != std::string::npos);
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
BOOST_TEST(!run_once(root.path, config));
make_assets(config);
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json", "not JSON");
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
BOOST_AUTO_TEST_CASE(readiness_rejects_unusable_database_and_runtime_paths) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
write_file(root.path / "blocked", "regular file");
config.database.path = (root.path / "blocked" / "vamp.db").string();
BOOST_TEST(!run_once(root.path, config));
config.database.path = (root.path / "db" / "vamp.db").string();
config.runtime.heartbeat_path = (root.path / "blocked" / "heartbeat.json").string();
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(read_file(root.path / "blocked") == "regular file");
}
BOOST_AUTO_TEST_CASE(readiness_rejects_occupied_bind_and_releases_successful_probe) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto service = http_service{health_router(healthy_payload())};
config.control = service.config().control;
write_file(config.runtime.heartbeat_path, "existing heartbeat");
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
require_no_probe(config, true);
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto io = boost::asio::io_context{};
auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint);
BOOST_REQUIRE(endpoint);
auto server = ligo::http::server{io, *endpoint, {}};
BOOST_TEST(server.start().has_value());
server.stop();
io.run();
require_no_probe(config, true);
}
+172
View File
@@ -0,0 +1,172 @@
import copy
from fractions import Fraction
import json
from pathlib import Path
import tempfile
import unittest
from xml.etree import ElementTree
from generator.engraving import Fonts, Style, engrave_tree, layout_score
from generator.engraving.attributes import staff_attributes
from generator.engraving.notation import ScoreIndex
from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document
from generator.practice.generate import generate_practice_scores, key_file_name, practice_document
from generator.score import load_json, write_json
from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document
from tests.test_musicxml import parse, xml_note, xml_score
FONT_ROOT = Path('package/assets/fonts/petaluma')
def key_change(old, new, local=False, attributes=''):
initial = f'<key><fifths>{old}</fifths></key>' + attributes
change = f'<attributes><key><cancel>{old}</cancel><fifths>{new}</fifths></key></attributes>'
if local:
return parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'), initial))
root = ElementTree.fromstring(xml_score(xml_note(), initial))
root.find('part').append(ElementTree.fromstring('<measure number="2">' + change + xml_note() + '</measure>'))
return parse(ElementTree.tostring(root, encoding='unicode'))
class KeyCancellationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fonts = Fonts(FONT_ROOT)
def test_sharp_flat_and_cross_sign_changes_keep_their_placement(self):
cases = [(1, 0, 'A', 3, 2, -54), (-2, -1, 'Ab', -4, -3, -36),
(-1, -2, 'G', 1, 0, -48), (1, 2, 'F', -1, 0, -24)]
for old, new, target, expected_old, expected_new, natural_y in cases:
for local in (False, True):
with self.subTest(old=old, new=new, target=target, local=local):
source = key_change(old, new, local)
before = copy.deepcopy(source)
result = transpose_document(source, target)
validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(measures[0]['key'], key_from_fifths(expected_old))
change = measures[0]['attributes'][0] if local else measures[1]
self.assertEqual(change['key'], key_from_fifths(expected_new) | {'cancel': expected_old})
self.assertEqual(len(measures), 1 if local else 2)
if local:
self.assertEqual(len(measures[0]['attributes']), 1)
self.assertEqual(change['onset'], rational(2))
else:
self.assertTrue(all(not m['attributes'] for m in measures))
self.assertEqual(change['number'], '2')
time = Fraction(2 if local else 4)
index = ScoreIndex(result)
glyphs = staff_attributes(index, index.state(time), time, self.fonts, 12, 'change', ('key',))
naturals = [p for p in glyphs if p.value == 'accidentalNatural']
self.assertEqual(len(naturals), 1)
self.assertEqual(naturals[0].points[0][1], natural_y)
self.assertEqual([p.value for p in glyphs[1:]], ['accidentalSharp' if expected_new > 0 else 'accidentalFlat'] * abs(expected_new))
layout = layout_score(result, Style(), self.fonts)
reference = 'measure-1:attributes:0' if local else 'measure-2:initial'
drawn = [p for page in layout.pages for p in page.primitives if p.role == 'accidental' and p.sources == (reference,)]
self.assertEqual([p.value for p in drawn], [p.value for p in glyphs])
self.assertEqual([p.points[0][1] - drawn[0].points[0][1] for p in drawn], [p.points[0][1] - glyphs[0].points[0][1] for p in glyphs])
self.assertEqual(source, before)
def test_metadata_and_initial_signature_transpose_cancellation(self):
source = parse(xml_score(xml_note(), '<key><cancel>1</cancel><fifths>0</fifths></key>'))
result = transpose_document(source, 'D', octave_shift=1)
expected = key_from_fifths(2) | {'cancel': 3}
self.assertEqual(result['metadata']['key'], expected)
self.assertEqual(result['staves'][0]['measures'][0]['key'], expected)
def test_zero_and_modal_cancellations_reference_the_old_signature(self):
for mode in ('major', 'minor', 'dorian', 'none'):
with self.subTest(mode=mode):
key = key_from_fifths(-1, mode) | {'cancel': 0}
result = shift_key(key, 1, 2)
self.assertEqual(result['cancel'], 2)
self.assertEqual(result['mode'], mode)
self.assertEqual(result['alterations'], key_from_fifths(1)['alterations'])
def test_unchanged_accidentals_do_not_receive_naturals(self):
for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]:
with self.subTest(old=old, new=new, target=target):
result = transpose_document(key_change(old, new), target)
index = ScoreIndex(result)
glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',))
self.assertNotIn('accidentalNatural', [p.value for p in glyphs])
def test_unrepresentable_cancellation_fails_without_mutating_source(self):
for fifths, steps, semitones in [(14, 1, 2), (-14, -1, -2), (0, 0, Fraction(1, 2))]:
key = key_from_fifths(0) | {'cancel': fifths}
before = copy.deepcopy(key)
with self.subTest(fifths=fifths, semitones=semitones), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
shift_key(key, steps, semitones)
self.assertEqual(key, before)
for local in (False, True):
source = key_change(0, 0, local)
measures = source['staves'][0]['measures']
change = measures[0]['attributes'][0] if local else measures[1]
change['key']['cancel'] = 14
before = copy.deepcopy(source)
with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
transpose_document(source, 'D')
self.assertEqual(source, before)
def test_concert_transposition_uses_the_same_interval_for_cancellation(self):
attributes = '<transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'
for local in (False, True):
with self.subTest(local=local):
source = key_change(1, 0, local, attributes)
result = concert_document(source)
self.assertEqual(result, concert_document(result))
measures = result['staves'][0]['measures']
change = measures[0]['attributes'][0] if local else measures[1]
self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3})
class AuthoredKeyMetadataTests(unittest.TestCase):
def test_eb_authored_key_survives_every_generated_practice_variant(self):
with tempfile.TemporaryDirectory() as temp:
source, output = Path(temp) / 'source', Path(temp) / 'output'
text = xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')
for practice_type in ('chord', 'melody'):
path = source / practice_type / 'lesson' / 'exercise.xml'
path.parent.mkdir(parents=True)
path.write_text(text, encoding='utf-8')
self.assertEqual(generate_practice_scores(source, output), 24)
for practice_type in ('chord', 'melody'):
for key in TARGET_KEYS:
with self.subTest(practice_type=practice_type, key=key):
result = load_json(output / practice_type / 'lesson' / 'exercise' / key_file_name(key))
self.assertEqual(result['schema_version'], 3)
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
self.assertEqual(result['metadata']['key_signature'], key)
self.assertEqual(result['metadata']['variant_tonic'], key)
self.assertEqual((source / practice_type / 'lesson' / 'exercise.xml').read_text(encoding='utf-8'), text)
def test_modes_and_keyless_authored_labels_survive_variants(self):
for mode, fifths, label in [('minor', -3, 'C minor'), ('dorian', -2, 'C dorian'), ('mixolydian', -1, 'C mixolydian'), ('none', 0, 'No key')]:
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
if mode == 'none':
source['metadata']['transpose_origin'] = parse_pitch_name('C')
before = copy.deepcopy(source)
base = practice_document(source)
self.assertEqual(base['metadata']['authored_key_signature'], label)
for key in TARGET_KEYS:
with self.subTest(mode=mode, key=key):
result = transpose_document(base, key)
validate_document(result)
self.assertEqual(result['metadata']['authored_key_signature'], label)
self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}')
self.assertEqual(result['metadata']['variant_tonic'], key)
self.assertEqual(source, before)
def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self):
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
before = copy.deepcopy(source)
variant = transpose_document(practice_document(source), 'D')
result = transpose_document(practice_document(variant), 'F')
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
self.assertEqual(result['metadata']['key_signature'], 'F')
self.assertEqual(source, before)
def test_schema3_bundles_accept_optional_authored_key(self):
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertNotIn('authored_key_signature', source['metadata'])
for authored in (False, True):
with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp:
result = transpose_document(practice_document(source) if authored else source, 'D')
root = Path(temp)
path = root / 'variant.json'
write_json(path, result)
engrave_tree(root, FONT_ROOT)
bundle = json.loads(path.read_text(encoding='utf-8'))
self.assertEqual(bundle['schema_version'], 3)
self.assertEqual(bundle['music']['metadata']['key_signature'], 'D')
self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None)
self.assertEqual(load_json(path), result)
+697
View File
@@ -0,0 +1,697 @@
#include <boost/test/unit_test.hpp>
#include <algorithm>
#include <array>
#include <cstring>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <type_traits>
#include <lwip/tcp.h>
#include <pico/cyw43_arch.h>
#include <vamp_pico/http_client.hpp>
#include <vamp_pico/input.hpp>
namespace {
auto check_command(std::optional<vamp_protocol::page_command> command, vamp_protocol::page_command expected) -> void {
BOOST_REQUIRE(command.has_value());
BOOST_CHECK(*command == expected);
}
auto take_command(vamp_pico::request_scheduler &scheduler, std::uint64_t now_ms, vamp_protocol::page_command expected) -> void {
auto request = scheduler.next(now_ms);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::COMMAND);
BOOST_CHECK(request->command == expected);
}
}
BOOST_AUTO_TEST_CASE(pico_gesture_single_tap_waits_for_double_tap_deadline) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(false, 0U));
BOOST_CHECK(!gesture.update(true, 10U));
BOOST_CHECK(!gesture.update(false, 50U));
BOOST_CHECK(!gesture.update(false, 270U));
check_command(gesture.update(false, 271U), vamp_protocol::page_command::NEXT);
BOOST_CHECK(!gesture.update(false, 900U));
}
BOOST_AUTO_TEST_CASE(pico_gesture_double_tap_includes_deadline_and_has_no_trailing_next) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
check_command(gesture.update(false, 260U), vamp_protocol::page_command::PREVIOUS);
BOOST_CHECK(!gesture.update(false, 1000U));
}
BOOST_AUTO_TEST_CASE(pico_gesture_late_second_release_preserves_both_single_taps) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
check_command(gesture.update(false, 261U), vamp_protocol::page_command::NEXT);
BOOST_CHECK(!gesture.update(false, 481U));
check_command(gesture.update(false, 482U), vamp_protocol::page_command::NEXT);
}
BOOST_AUTO_TEST_CASE(pico_gesture_expired_tap_is_emitted_when_next_press_starts) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
check_command(gesture.update(true, 261U), vamp_protocol::page_command::NEXT);
BOOST_CHECK(!gesture.update(false, 300U));
check_command(gesture.update(false, 521U), vamp_protocol::page_command::NEXT);
}
BOOST_AUTO_TEST_CASE(pico_gesture_hold_emits_once_and_suppresses_release) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, 50U));
BOOST_CHECK(!gesture.update(true, 749U));
check_command(gesture.update(true, 750U), vamp_protocol::page_command::SELECT);
BOOST_CHECK(!gesture.update(true, 2000U));
BOOST_CHECK(!gesture.update(false, 2010U));
BOOST_CHECK(!gesture.update(false, 2300U));
BOOST_CHECK(!gesture.update(true, 2400U));
BOOST_CHECK(!gesture.update(false, 2450U));
check_command(gesture.update(false, 2671U), vamp_protocol::page_command::NEXT);
}
BOOST_AUTO_TEST_CASE(pico_gesture_hold_consumes_pending_tap_even_if_release_is_next_sample) {
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, 0U));
BOOST_CHECK(!gesture.update(false, 40U));
BOOST_CHECK(!gesture.update(true, 180U));
BOOST_CHECK(!gesture.update(true, 879U));
check_command(gesture.update(false, 880U), vamp_protocol::page_command::SELECT);
BOOST_CHECK(!gesture.update(false, 1200U));
}
BOOST_AUTO_TEST_CASE(pico_scheduler_initial_poll_and_one_second_from_completion) {
auto scheduler = vamp_pico::request_scheduler{};
auto request = scheduler.next(500U);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
BOOST_CHECK(!scheduler.next(100000U));
scheduler.complete(100000U);
BOOST_CHECK(!scheduler.next(100999U));
request = scheduler.next(101000U);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
BOOST_CHECK(!scheduler.next(200000U));
}
BOOST_AUTO_TEST_CASE(pico_scheduler_commands_take_priority_and_never_retry) {
auto scheduler = vamp_pico::request_scheduler{};
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::SELECT) == vamp_pico::enqueue_result::QUEUED);
take_command(scheduler, 0U, vamp_protocol::page_command::SELECT);
BOOST_CHECK(!scheduler.next(700U));
scheduler.complete(700U);
BOOST_TEST(scheduler.queued() == 0U);
BOOST_CHECK(!scheduler.next(1699U));
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::QUEUED);
take_command(scheduler, 1700U, vamp_protocol::page_command::NEXT);
scheduler.complete(1900U);
BOOST_CHECK(!scheduler.next(2899U));
auto request = scheduler.next(2900U);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
}
BOOST_AUTO_TEST_CASE(pico_scheduler_fifo_overflow_is_explicit_and_does_not_replace_commands) {
auto scheduler = vamp_pico::request_scheduler{};
BOOST_REQUIRE(scheduler.next(0U));
constexpr auto commands = std::array{
vamp_protocol::page_command::NEXT,
vamp_protocol::page_command::PREVIOUS,
vamp_protocol::page_command::SELECT,
};
for (auto index = std::size_t{}; index < 16U; ++index) {
BOOST_CHECK(scheduler.enqueue(commands[index % commands.size()]) == vamp_pico::enqueue_result::QUEUED);
}
BOOST_TEST(scheduler.queued() == 16U);
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::SELECT) == vamp_pico::enqueue_result::BUSY);
BOOST_TEST(scheduler.queued() == 16U);
BOOST_CHECK(!scheduler.next(1000U));
scheduler.complete(1000U);
for (auto index = std::size_t{}; index < 8U; ++index) {
take_command(scheduler, 1000U + index, commands[index % commands.size()]);
scheduler.complete(1000U + index);
}
for (auto index = std::size_t{16}; index < 24U; ++index) {
BOOST_CHECK(scheduler.enqueue(commands[index % commands.size()]) == vamp_pico::enqueue_result::QUEUED);
}
BOOST_TEST(scheduler.queued() == 16U);
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::BUSY);
for (auto index = std::size_t{8}; index < 24U; ++index) {
take_command(scheduler, 1000U + index, commands[index % commands.size()]);
scheduler.complete(1000U + index);
}
BOOST_TEST(scheduler.queued() == 0U);
BOOST_CHECK(!scheduler.next(2022U));
auto request = scheduler.next(2023U);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
}
BOOST_AUTO_TEST_CASE(pico_scheduler_duplicate_commands_are_not_coalesced) {
auto scheduler = vamp_pico::request_scheduler{};
for (auto index = 0U; index < 16U; ++index) {
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::QUEUED);
}
for (auto index = 0U; index < 16U; ++index) {
take_command(scheduler, index, vamp_protocol::page_command::NEXT);
BOOST_CHECK(!scheduler.next(index));
scheduler.complete(index);
}
BOOST_TEST(scheduler.queued() == 0U);
BOOST_CHECK(!scheduler.next(16U));
}
BOOST_AUTO_TEST_CASE(pico_scheduler_extra_completion_does_not_delay_next_poll) {
auto scheduler = vamp_pico::request_scheduler{};
scheduler.complete(0U);
BOOST_REQUIRE(scheduler.next(0U));
scheduler.complete(500U);
scheduler.complete(1200U);
BOOST_CHECK(!scheduler.next(1499U));
BOOST_REQUIRE(scheduler.next(1500U));
}
BOOST_AUTO_TEST_CASE(pico_gestures_are_sampled_and_queued_during_slow_request) {
auto gesture = vamp_pico::gesture_state{};
auto scheduler = vamp_pico::request_scheduler{};
BOOST_REQUIRE(scheduler.next(0U));
for (auto now_ms = std::uint64_t{}; now_ms < 1504U; now_ms += 8U) {
auto touching = (now_ms >= 16U && now_ms < 48U) || (now_ms >= 120U && now_ms < 152U) ||
(now_ms >= 240U && now_ms < 1008U) || (now_ms >= 1104U && now_ms < 1144U);
if (auto command = gesture.update(touching, now_ms); command) {
BOOST_CHECK(scheduler.enqueue(*command) == vamp_pico::enqueue_result::QUEUED);
}
BOOST_CHECK(!scheduler.next(now_ms));
}
BOOST_TEST(scheduler.queued() == 3U);
scheduler.complete(1504U);
take_command(scheduler, 1504U, vamp_protocol::page_command::PREVIOUS);
scheduler.complete(1505U);
take_command(scheduler, 1505U, vamp_protocol::page_command::SELECT);
scheduler.complete(1506U);
take_command(scheduler, 1506U, vamp_protocol::page_command::NEXT);
scheduler.complete(1507U);
BOOST_CHECK(!scheduler.next(2506U));
auto request = scheduler.next(2507U);
BOOST_REQUIRE(request.has_value());
BOOST_CHECK(request->kind == vamp_pico::request_kind::DISPLAY);
}
BOOST_AUTO_TEST_CASE(pico_timing_survives_32_bit_millisecond_boundary) {
constexpr auto start = std::uint64_t{0xffffffffU} - 100U;
auto gesture = vamp_pico::gesture_state{};
BOOST_CHECK(!gesture.update(true, start));
BOOST_CHECK(!gesture.update(false, start + 40U));
check_command(gesture.update(false, start + 261U), vamp_protocol::page_command::NEXT);
auto scheduler = vamp_pico::request_scheduler{};
BOOST_REQUIRE(scheduler.next(start));
scheduler.complete(start + 500U);
BOOST_CHECK(!scheduler.next(start + 1499U));
BOOST_REQUIRE(scheduler.next(start + 1500U));
}
BOOST_AUTO_TEST_CASE(pico_http_client_has_stable_ownership) {
BOOST_TEST(!std::is_copy_constructible_v<vamp_pico::http_client>);
BOOST_TEST(!std::is_copy_assignable_v<vamp_pico::http_client>);
BOOST_TEST(!std::is_move_constructible_v<vamp_pico::http_client>);
BOOST_TEST(!std::is_move_assignable_v<vamp_pico::http_client>);
}
namespace {
struct fake_network {
std::array<tcp_pcb, 64> pcbs{};
std::size_t allocated{};
std::size_t writes{};
std::size_t closes{};
std::size_t aborts{};
std::size_t acknowledged{};
std::size_t freed{};
std::size_t lock_depth{};
std::bool_t address_valid{true};
std::bool_t allocate_failure{};
std::int8_t connect_error{ERR_OK};
std::int8_t write_error{ERR_OK};
std::int8_t output_error{ERR_OK};
std::int8_t close_error{ERR_OK};
std::string written{};
std::char_t const *written_data{};
std::uint16_t written_size{};
};
auto network = fake_network{};
auto check_locked(tcp_pcb const *pcb = nullptr) -> void {
BOOST_CHECK(network.lock_depth > 0U);
if (pcb != nullptr) {
BOOST_CHECK(pcb->alive);
}
}
auto check_detached(tcp_pcb const *pcb) -> void {
BOOST_CHECK(pcb->argument == nullptr);
BOOST_CHECK(pcb->receive == nullptr);
BOOST_CHECK(pcb->sent == nullptr);
BOOST_CHECK(pcb->poll == nullptr);
BOOST_CHECK(pcb->error == nullptr);
}
struct fake_lwip_fixture {
fake_lwip_fixture() {
network = fake_network{};
}
~fake_lwip_fixture() {
BOOST_CHECK(network.lock_depth == 0U);
for (auto index = std::size_t{}; index < network.allocated; ++index) {
BOOST_CHECK(!network.pcbs[index].alive);
}
}
};
struct callback_lock {
callback_lock() {
cyw43_arch_lwip_begin();
}
~callback_lock() {
cyw43_arch_lwip_end();
}
};
auto deliver_connected(tcp_pcb &pcb, std::int8_t error = ERR_OK) -> std::int8_t {
auto lock = callback_lock{};
if (!pcb.alive || pcb.connected == nullptr) {
return ERR_OK;
}
return pcb.connected(pcb.argument, &pcb, error);
}
auto deliver_data(tcp_pcb &pcb, std::string_view data, std::int8_t error = ERR_OK) -> std::int8_t {
auto lock = callback_lock{};
if (!pcb.alive || pcb.receive == nullptr) {
return ERR_OK;
}
auto half = static_cast<std::uint16_t>(data.size() / 2U);
auto tail_size = static_cast<std::uint16_t>(data.size() - half);
auto tail = pbuf{nullptr, data.data() + half, tail_size, tail_size};
auto head = pbuf{&tail, data.data(), static_cast<std::uint16_t>(data.size()), half};
auto result = pcb.receive(pcb.argument, &pcb, &head, error);
BOOST_CHECK(head.freed);
BOOST_CHECK(tail.freed);
return result;
}
auto deliver_eof(tcp_pcb &pcb) -> std::int8_t {
auto lock = callback_lock{};
if (!pcb.alive || pcb.receive == nullptr) {
return ERR_OK;
}
return pcb.receive(pcb.argument, &pcb, nullptr, ERR_OK);
}
auto deliver_error(tcp_pcb &pcb) -> void {
auto lock = callback_lock{};
if (!pcb.alive || pcb.error == nullptr) {
return;
}
auto callback = pcb.error;
auto argument = pcb.argument;
pcb.alive = false;
callback(argument, ERR_RST);
}
constexpr auto display_response = std::string_view{
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"
"active=1\ntitle=Owned response\npart=Piano\nmode=score\npage=2\npages=3\n"};
auto require_failure(vamp_pico::http_client &client, std::uint64_t now_ms) -> void {
BOOST_CHECK(client.poll(now_ms) == vamp_pico::http_status::READY);
BOOST_CHECK(client.start({}, now_ms) == vamp_pico::http_start_result::BUSY);
auto result = client.take_result();
BOOST_REQUIRE(result.has_value());
BOOST_CHECK(!result->success);
BOOST_CHECK(!client.take_result());
BOOST_CHECK(client.poll(now_ms) == vamp_pico::http_status::IDLE);
}
}
auto cyw43_arch_lwip_begin() -> void {
++network.lock_depth;
}
auto cyw43_arch_lwip_end() -> void {
BOOST_CHECK(network.lock_depth > 0U);
--network.lock_depth;
}
auto ipaddr_aton(std::char_t const *, ip_addr_t *address) -> std::int32_t {
check_locked();
address->addr = 1U;
return network.address_valid ? 1 : 0;
}
auto tcp_new_ip_type(std::uint8_t type) -> tcp_pcb * {
check_locked();
BOOST_CHECK(type == IPADDR_TYPE_V4);
if (network.allocate_failure) {
return nullptr;
}
BOOST_REQUIRE(network.allocated < network.pcbs.size());
auto &pcb = network.pcbs[network.allocated++];
pcb.alive = true;
return &pcb;
}
auto tcp_arg(tcp_pcb *pcb, void *argument) -> void {
check_locked(pcb);
pcb->argument = argument;
}
auto tcp_recv(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, pbuf *, std::int8_t)) -> void {
check_locked(pcb);
pcb->receive = callback;
}
auto tcp_sent(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *, std::uint16_t)) -> void {
check_locked(pcb);
pcb->sent = callback;
}
auto tcp_poll(tcp_pcb *pcb, std::int8_t (*callback)(void *, tcp_pcb *), std::uint8_t) -> void {
check_locked(pcb);
pcb->poll = callback;
}
auto tcp_err(tcp_pcb *pcb, void (*callback)(void *, std::int8_t)) -> void {
check_locked(pcb);
pcb->error = callback;
}
auto tcp_connect(tcp_pcb *pcb, ip_addr_t const *, std::uint16_t, std::int8_t (*callback)(void *, tcp_pcb *, std::int8_t)) -> std::int8_t {
check_locked(pcb);
pcb->connected = callback;
return network.connect_error;
}
auto tcp_write(tcp_pcb *pcb, void const *data, std::uint16_t size, std::uint8_t flags) -> std::int8_t {
check_locked(pcb);
BOOST_CHECK(flags == TCP_WRITE_FLAG_COPY);
network.written_data = static_cast<std::char_t const *>(data);
network.written_size = size;
network.written.assign(network.written_data, size);
++network.writes;
return network.write_error;
}
auto tcp_output(tcp_pcb *pcb) -> std::int8_t {
check_locked(pcb);
return network.output_error;
}
auto tcp_recved(tcp_pcb *pcb, std::uint16_t size) -> void {
check_locked(pcb);
network.acknowledged += size;
}
auto tcp_close(tcp_pcb *pcb) -> std::int8_t {
check_locked(pcb);
check_detached(pcb);
++network.closes;
if (network.close_error == ERR_OK) {
pcb->alive = false;
}
return network.close_error;
}
auto tcp_abort(tcp_pcb *pcb) -> void {
check_locked(pcb);
check_detached(pcb);
pcb->alive = false;
++network.aborts;
}
auto pbuf_copy_partial(pbuf const *buffer, void *destination, std::uint16_t length, std::uint16_t offset) -> std::uint16_t {
check_locked();
auto copied = std::uint16_t{};
auto *output = static_cast<std::char_t *>(destination);
while (buffer != nullptr && copied < length) {
if (offset < buffer->len) {
auto size = std::min<std::uint16_t>(length - copied, buffer->len - offset);
std::memcpy(output + copied, static_cast<std::char_t const *>(buffer->payload) + offset, size);
copied += size;
offset = 0U;
} else {
offset -= buffer->len;
}
buffer = buffer->next;
}
return copied;
}
auto pbuf_free(pbuf *buffer) -> std::uint8_t {
check_locked();
auto count = std::uint8_t{};
while (buffer != nullptr) {
BOOST_CHECK(!buffer->freed);
buffer->freed = true;
++count;
++network.freed;
buffer = buffer->next;
}
return count;
}
BOOST_FIXTURE_TEST_CASE(pico_http_owns_fragmented_buffers_and_unconsumed_result, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.poll(0U) == vamp_pico::http_status::IDLE);
BOOST_CHECK(!client.take_result());
auto request = vamp_pico::remote_request{vamp_pico::request_kind::COMMAND, vamp_protocol::page_command::SELECT};
BOOST_CHECK(client.start(request, 0U) == vamp_pico::http_start_result::STARTED);
request.command = vamp_protocol::page_command::PREVIOUS;
auto &pcb = network.pcbs[0];
BOOST_CHECK(client.poll(10U) == vamp_pico::http_status::IN_FLIGHT);
BOOST_TEST(network.writes == 0U);
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
BOOST_TEST(network.writes == 1U);
BOOST_CHECK(network.written.find("POST /api/remote/select HTTP/1.1\r\n") == 0U);
BOOST_CHECK(network.written.find("Content-Length: 2\r\n") != std::string::npos);
BOOST_CHECK(network.written.find("\r\n\r\n{}") != std::string::npos);
BOOST_CHECK(client.start(request, 20U) == vamp_pico::http_start_result::BUSY);
BOOST_CHECK(std::string_view(network.written_data, network.written_size) == network.written);
auto response = std::string{display_response};
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(0U, 9U)) == ERR_OK);
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(9U, 48U)) == ERR_OK);
BOOST_CHECK(!client.take_result());
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(57U)) == ERR_OK);
std::fill(response.begin(), response.end(), 'x');
BOOST_CHECK(deliver_eof(pcb) == ERR_OK);
check_detached(&pcb);
BOOST_CHECK(client.poll(30U) == vamp_pico::http_status::READY);
BOOST_CHECK(client.start(request, 30U) == vamp_pico::http_start_result::BUSY);
auto result = client.take_result();
BOOST_REQUIRE(result.has_value());
BOOST_CHECK(result->success);
BOOST_CHECK(result->request.command == vamp_protocol::page_command::SELECT);
BOOST_TEST(std::string{result->display.title} == "Owned response");
BOOST_TEST(result->display.page == 2U);
BOOST_TEST(result->display.pages == 3U);
BOOST_TEST(network.acknowledged == display_response.size());
BOOST_CHECK(!client.take_result());
BOOST_CHECK(client.start({}, 40U) == vamp_pico::http_start_result::STARTED);
BOOST_TEST(std::string{result->display.title} == "Owned response");
}
BOOST_FIXTURE_TEST_CASE(pico_http_cancel_detaches_callbacks_before_reuse, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
for (auto connected : {false, true}) {
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &old = network.pcbs[network.allocated - 1U];
if (connected) {
BOOST_CHECK(deliver_connected(old) == ERR_OK);
BOOST_CHECK(deliver_data(old, "HTTP/1.1 200 OK\r\n") == ERR_OK);
}
client.cancel();
check_detached(&old);
BOOST_CHECK(!old.alive);
BOOST_CHECK(!client.take_result());
BOOST_CHECK(client.poll(1000U) == vamp_pico::http_status::IDLE);
BOOST_CHECK(client.start({}, 1000U) == vamp_pico::http_start_result::STARTED);
BOOST_CHECK(deliver_connected(old) == ERR_OK);
BOOST_CHECK(deliver_data(old, display_response) == ERR_OK);
BOOST_CHECK(deliver_eof(old) == ERR_OK);
deliver_error(old);
BOOST_CHECK(client.poll(1001U) == vamp_pico::http_status::IN_FLIGHT);
BOOST_CHECK(!client.take_result());
client.cancel();
client.cancel();
}
}
BOOST_FIXTURE_TEST_CASE(pico_http_timeout_detaches_callbacks_and_never_retries, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start({vamp_pico::request_kind::COMMAND, vamp_protocol::page_command::NEXT}, 100U) == vamp_pico::http_start_result::STARTED);
auto &old = network.pcbs[0];
BOOST_CHECK(deliver_connected(old) == ERR_OK);
BOOST_CHECK(deliver_data(old, "HTTP/1.1 200 OK\r\n") == ERR_OK);
BOOST_CHECK(client.poll(799U) == vamp_pico::http_status::IN_FLIGHT);
require_failure(client, 800U);
check_detached(&old);
BOOST_TEST(network.writes == 1U);
BOOST_TEST(network.aborts == 1U);
BOOST_CHECK(client.start({}, 900U) == vamp_pico::http_start_result::STARTED);
BOOST_CHECK(deliver_eof(old) == ERR_OK);
deliver_error(old);
BOOST_CHECK(client.poll(1899U) == vamp_pico::http_status::IN_FLIGHT);
require_failure(client, 1900U);
BOOST_TEST(network.writes == 1U);
BOOST_TEST(network.allocated == 2U);
}
BOOST_FIXTURE_TEST_CASE(pico_http_destructor_aborts_and_detaches, fake_lwip_fixture) {
{
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
BOOST_CHECK(deliver_connected(network.pcbs[0]) == ERR_OK);
}
check_detached(&network.pcbs[0]);
BOOST_TEST(network.aborts == 1U);
BOOST_CHECK(deliver_data(network.pcbs[0], display_response) == ERR_OK);
BOOST_CHECK(deliver_eof(network.pcbs[0]) == ERR_OK);
deliver_error(network.pcbs[0]);
}
BOOST_AUTO_TEST_CASE(pico_http_immediate_failures_are_consumed_once) {
for (auto failure = 0U; failure < 3U; ++failure) {
auto fixture = fake_lwip_fixture{};
auto client = vamp_pico::http_client{};
network.address_valid = failure != 0U;
network.allocate_failure = failure == 1U;
network.connect_error = failure == 2U ? ERR_MEM : ERR_OK;
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
require_failure(client, 0U);
BOOST_TEST(network.writes == 0U);
}
}
BOOST_AUTO_TEST_CASE(pico_http_connected_write_output_and_receive_failures_abort) {
for (auto failure = 0U; failure < 4U; ++failure) {
auto fixture = fake_lwip_fixture{};
auto client = vamp_pico::http_client{};
network.write_error = failure == 1U ? ERR_MEM : ERR_OK;
network.output_error = failure == 2U ? ERR_MEM : ERR_OK;
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[0];
auto connected = deliver_connected(pcb, failure == 0U ? ERR_RST : ERR_OK);
if (failure == 3U) {
BOOST_CHECK(connected == ERR_OK);
BOOST_CHECK(deliver_data(pcb, display_response, ERR_RST) == ERR_ABRT);
} else {
BOOST_CHECK(connected == ERR_ABRT);
}
require_failure(client, 10U);
check_detached(&pcb);
BOOST_TEST(network.aborts == 1U);
}
}
BOOST_FIXTURE_TEST_CASE(pico_http_error_callback_does_not_touch_freed_pcb, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
deliver_error(network.pcbs[0]);
require_failure(client, 1U);
BOOST_TEST(network.aborts == 0U);
BOOST_TEST(network.closes == 0U);
BOOST_CHECK(client.start({}, 2U) == vamp_pico::http_start_result::STARTED);
BOOST_CHECK(deliver_connected(network.pcbs[1]) == ERR_OK);
deliver_error(network.pcbs[1]);
require_failure(client, 3U);
BOOST_TEST(network.aborts == 0U);
}
BOOST_FIXTURE_TEST_CASE(pico_http_close_failure_aborts_with_correct_callback_result, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
network.close_error = ERR_MEM;
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[0];
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
BOOST_CHECK(deliver_data(pcb, display_response) == ERR_OK);
BOOST_CHECK(deliver_eof(pcb) == ERR_ABRT);
auto result = client.take_result();
BOOST_REQUIRE(result.has_value());
BOOST_CHECK(result->success);
BOOST_TEST(network.closes == 1U);
BOOST_TEST(network.aborts == 1U);
}
BOOST_FIXTURE_TEST_CASE(pico_http_malformed_headers_status_and_body_fail, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
for (auto response : {
"",
"HTTP/1.1 2",
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n",
"HTTP/1.1 500 Error\r\n\r\nactive=1\n",
"HTTP/1.1 2x0 OK\r\n\r\nactive=1\n",
"HTTP/1.1 20x OK\r\n\r\nactive=1\n",
"HTTP/1.1 2000 OK\r\n\r\nactive=1\n",
"HTTP/2.0 200 OK\r\n\r\nactive=1\n",
"HTTP/1.1 200 OK\r\n\r\nactive=1\npage=invalid\n",
}) {
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[network.allocated - 1U];
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
BOOST_CHECK(deliver_data(pcb, response) == ERR_OK);
deliver_eof(pcb);
require_failure(client, 1U);
}
}
BOOST_FIXTURE_TEST_CASE(pico_http_http10_and_heartbeat_are_supported, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &display = network.pcbs[0];
BOOST_CHECK(deliver_connected(display) == ERR_OK);
BOOST_CHECK(network.written.find("GET /api/remote/display HTTP/1.1\r\n") == 0U);
BOOST_CHECK(deliver_data(display, "HTTP/1.0 200 OK\r\n\r\nactive=1\ntitle=HTTP10\n") == ERR_OK);
BOOST_CHECK(deliver_eof(display) == ERR_OK);
auto result = client.take_result();
BOOST_REQUIRE(result.has_value());
BOOST_CHECK(result->success);
BOOST_TEST(std::string{result->display.title} == "HTTP10");
BOOST_CHECK(client.start({vamp_pico::request_kind::HEARTBEAT}, 10U) == vamp_pico::http_start_result::STARTED);
auto &heartbeat = network.pcbs[1];
BOOST_CHECK(deliver_connected(heartbeat) == ERR_OK);
BOOST_CHECK(network.written.find("POST /api/remote/heartbeat HTTP/1.1\r\n") == 0U);
BOOST_CHECK(deliver_data(heartbeat, "HTTP/1.1 204 No Content\r\n\r\n") == ERR_OK);
BOOST_CHECK(deliver_eof(heartbeat) == ERR_OK);
result = client.take_result();
BOOST_REQUIRE(result.has_value());
BOOST_CHECK(result->success);
}
BOOST_FIXTURE_TEST_CASE(pico_http_oversized_response_is_rejected_without_truncation, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start({}, 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[0];
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
auto response = std::string{display_response};
response.resize(1024U, 'x');
BOOST_CHECK(deliver_data(pcb, response) == ERR_OK);
BOOST_CHECK(deliver_data(pcb, "x") == ERR_ABRT);
require_failure(client, 1U);
check_detached(&pcb);
BOOST_TEST(network.acknowledged == 1025U);
BOOST_TEST(network.aborts == 1U);
}
+346
View File
@@ -0,0 +1,346 @@
#include <boost/test/unit_test.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <future>
#include <memory>
#include <stdexcept>
#include <thread>
#include <vector>
#include <boost/asio/io_context.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/redirect_error.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/score_loader.hpp>
namespace {
auto performance(std::string id) -> vamp_zero::score_reference {
return {.category = vamp_zero::score_category::PERFORMANCE, .id = std::move(id)};
}
auto drain(boost::asio::io_context &io) -> void {
io.restart();
io.poll();
}
struct controlled_scores {
explicit controlled_scores(boost::asio::io_context &io)
: io{io} {}
auto load(vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
requests.push_back(reference);
auto timer = std::make_shared<boost::asio::steady_timer>(io, std::chrono::steady_clock::time_point::max());
waits.push_back(timer);
maximum_active = std::max(maximum_active, ++active);
auto error = boost::system::error_code{};
co_await timer->async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, error));
--active;
if (throws) {
throw std::runtime_error{"fixture failure"};
}
if (fails) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "missing fixture score"));
}
co_return vamp_zero::score_document{.id = reference.id, .source_json = "fixture payload"};
}
auto complete(std::size_t index) -> void {
waits.at(index)->cancel();
}
boost::asio::io_context &io;
std::vector<vamp_zero::score_reference> requests{};
std::vector<std::shared_ptr<boost::asio::steady_timer>> waits{};
std::size_t active{};
std::size_t maximum_active{};
std::bool_t fails{};
std::bool_t throws{};
};
struct loader_fixture {
boost::asio::io_context io{};
std::shared_ptr<controlled_scores> scores{std::make_shared<controlled_scores>(io)};
std::shared_ptr<vamp_zero::catalogue const> catalog{std::make_shared<vamp_zero::catalogue const>()};
std::shared_ptr<std::size_t> completions{std::make_shared<std::size_t>()};
vamp_zero::score_loader loader{io.get_executor(),
[scores = scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); },
[completions = completions] { ++*completions; }};
};
}
BOOST_AUTO_TEST_CASE(score_loader_starts_asynchronously_from_executor_handler) {
auto fixture = loader_fixture{};
boost::asio::post(fixture.io, [&] {
fixture.loader.select(fixture.catalog, performance("a"));
BOOST_CHECK(fixture.scores->requests.empty());
BOOST_CHECK(fixture.loader.snapshot().loading);
});
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U);
BOOST_CHECK(!fixture.loader.snapshot().document);
fixture.scores->complete(0);
drain(fixture.io);
auto loaded = fixture.loader.snapshot();
BOOST_REQUIRE(loaded.document);
BOOST_CHECK_EQUAL(loaded.document->id, "a");
BOOST_CHECK(!loaded.loading);
BOOST_CHECK(!loaded.error);
BOOST_CHECK_EQUAL(*fixture.completions, 1U);
}
BOOST_AUTO_TEST_CASE(score_loader_coalesces_pending_and_discards_stale_results) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
fixture.loader.select(fixture.catalog, performance("b"));
fixture.loader.select(fixture.catalog, performance("c"));
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U);
fixture.scores->complete(0);
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U);
BOOST_CHECK_EQUAL(fixture.scores->requests.back().id, "c");
BOOST_CHECK(!fixture.loader.snapshot().document);
BOOST_CHECK(fixture.loader.snapshot().loading);
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
fixture.scores->complete(1);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "c");
BOOST_CHECK_EQUAL(fixture.scores->maximum_active, 1U);
}
BOOST_AUTO_TEST_CASE(score_loader_coalesces_selections_before_executor_starts) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
fixture.loader.select(fixture.catalog, performance("b"));
fixture.loader.select(fixture.catalog, performance("c"));
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 1U);
BOOST_CHECK_EQUAL(fixture.scores->requests.front().id, "c");
fixture.scores->complete(0);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "c");
}
BOOST_AUTO_TEST_CASE(score_loader_stale_error_does_not_replace_pending_selection) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("missing"));
drain(fixture.io);
fixture.loader.select(fixture.catalog, performance("available"));
fixture.scores->fails = true;
fixture.scores->complete(0);
drain(fixture.io);
BOOST_CHECK(!fixture.loader.snapshot().error);
BOOST_CHECK(fixture.loader.snapshot().loading);
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
fixture.scores->fails = false;
fixture.scores->complete(1);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "available");
}
BOOST_AUTO_TEST_CASE(score_loader_catalogue_identity_and_category_invalidate_selection) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("same-id"));
drain(fixture.io);
fixture.catalog = std::make_shared<vamp_zero::catalogue const>();
fixture.loader.select(fixture.catalog, performance("same-id"));
fixture.scores->complete(0);
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U);
BOOST_CHECK(!fixture.loader.snapshot().document);
fixture.scores->complete(1);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
fixture.loader.select(fixture.catalog, vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = "same-id"});
BOOST_CHECK(!fixture.loader.snapshot().document);
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 3U);
BOOST_CHECK(fixture.scores->requests.back().category == vamp_zero::score_category::PRACTICE);
fixture.scores->complete(2);
drain(fixture.io);
}
BOOST_AUTO_TEST_CASE(score_loader_reuses_active_score_and_releases_on_menu_exit) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
fixture.scores->complete(0);
drain(fixture.io);
auto document = std::weak_ptr{fixture.loader.snapshot().document};
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U);
BOOST_CHECK(document.lock() == fixture.loader.snapshot().document);
fixture.loader.select(fixture.catalog, std::nullopt);
BOOST_CHECK(document.expired());
BOOST_CHECK(!fixture.loader.snapshot().loading);
BOOST_CHECK(!fixture.loader.snapshot().error);
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U);
fixture.scores->complete(1);
drain(fixture.io);
}
BOOST_AUTO_TEST_CASE(score_loader_exit_and_reentry_rejects_previous_generation) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
fixture.loader.select(fixture.catalog, std::nullopt);
fixture.loader.select(fixture.catalog, performance("a"));
fixture.scores->complete(0);
drain(fixture.io);
BOOST_REQUIRE_EQUAL(fixture.scores->requests.size(), 2U);
BOOST_CHECK(!fixture.loader.snapshot().document);
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
fixture.loader.select(fixture.catalog, std::nullopt);
fixture.scores->complete(1);
drain(fixture.io);
BOOST_CHECK(!fixture.loader.snapshot().document);
BOOST_CHECK(!fixture.loader.snapshot().loading);
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
}
BOOST_AUTO_TEST_CASE(score_loader_errors_persist_without_repeated_loads_and_allow_reselection) {
auto fixture = loader_fixture{};
fixture.scores->fails = true;
fixture.loader.select(fixture.catalog, performance("missing"));
drain(fixture.io);
fixture.scores->complete(0);
drain(fixture.io);
auto loaded = fixture.loader.snapshot();
BOOST_REQUIRE(loaded.error);
BOOST_CHECK(loaded.error->describe().find("missing fixture score") != std::string::npos);
BOOST_CHECK(!loaded.loading);
BOOST_CHECK(!loaded.document);
fixture.loader.select(fixture.catalog, performance("missing"));
drain(fixture.io);
BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U);
fixture.loader.select(fixture.catalog, std::nullopt);
fixture.scores->fails = false;
fixture.loader.select(fixture.catalog, performance("missing"));
drain(fixture.io);
fixture.scores->complete(1);
drain(fixture.io);
BOOST_REQUIRE(fixture.loader.snapshot().document);
BOOST_CHECK(!fixture.loader.snapshot().error);
}
BOOST_AUTO_TEST_CASE(score_loader_converts_coroutine_exceptions_to_error_state) {
auto fixture = loader_fixture{};
fixture.scores->throws = true;
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
fixture.scores->complete(0);
drain(fixture.io);
BOOST_CHECK(fixture.loader.snapshot().error.has_value());
BOOST_CHECK(!fixture.loader.snapshot().loading);
BOOST_CHECK_EQUAL(*fixture.completions, 1U);
}
BOOST_AUTO_TEST_CASE(score_loader_stop_discards_inflight_and_pending_loads) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
drain(fixture.io);
fixture.loader.select(fixture.catalog, performance("b"));
fixture.loader.stop();
fixture.scores->complete(0);
fixture.loader.select(fixture.catalog, performance("c"));
drain(fixture.io);
BOOST_CHECK_EQUAL(fixture.scores->requests.size(), 1U);
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
BOOST_CHECK(!fixture.loader.snapshot().document);
BOOST_CHECK(!fixture.loader.snapshot().loading);
}
BOOST_AUTO_TEST_CASE(score_loader_stop_before_executor_runs_does_not_start_load) {
auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a"));
fixture.loader.stop();
drain(fixture.io);
BOOST_CHECK(fixture.scores->requests.empty());
BOOST_CHECK_EQUAL(*fixture.completions, 0U);
}
BOOST_AUTO_TEST_CASE(score_loader_destruction_during_load_does_not_call_consumer) {
auto io = boost::asio::io_context{};
auto scores = std::make_shared<controlled_scores>(io);
auto completions = std::size_t{};
{
auto loader = vamp_zero::score_loader{io.get_executor(),
[scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); },
[&] { ++completions; }};
loader.select(std::make_shared<vamp_zero::catalogue const>(), performance("a"));
drain(io);
BOOST_REQUIRE_EQUAL(scores->requests.size(), 1U);
}
scores->complete(0);
drain(io);
BOOST_CHECK_EQUAL(completions, 0U);
}
BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destruction) {
auto io = boost::asio::io_context{};
auto entered = std::promise<void>{};
auto entered_future = entered.get_future();
auto resume = std::promise<void>{};
auto resume_future = resume.get_future().share();
auto consumer = std::make_shared<std::atomic<std::size_t>>(0U);
auto loader = std::make_unique<vamp_zero::score_loader>(io.get_executor(), [](vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> { co_return vamp_zero::score_document{.id = reference.id}; }, [weak = std::weak_ptr{consumer}, &entered, resume_future] {
entered.set_value();
resume_future.wait();
if (auto target = weak.lock()) {
++*target;
} });
loader->select(std::make_shared<vamp_zero::catalogue const>(), performance("a"));
auto worker = std::thread{[&] { io.run(); }};
auto entered_status = entered_future.wait_for(std::chrono::seconds{5});
if (entered_status == std::future_status::ready) {
auto document = std::weak_ptr{loader->snapshot().document};
loader->stop();
BOOST_CHECK(document.expired());
loader.reset();
consumer.reset();
}
resume.set_value();
worker.join();
BOOST_CHECK(entered_status == std::future_status::ready);
}
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_variant) {
auto catalog = std::make_shared<vamp_zero::catalogue>();
catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}};
catalog->practice.exercises = {{.id = "scale", .practice_type_id = "melody", .variants = {{.id = "scale-c"}, {.id = "scale-d"}}}};
auto state = vamp_zero::app_state{.catalog = catalog};
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
state.active_part_id = "part-b";
auto reference = vamp_zero::selected_score_reference(state);
BOOST_REQUIRE(reference);
BOOST_CHECK_EQUAL(reference->id, "part-b");
BOOST_CHECK(reference->category == vamp_zero::score_category::PERFORMANCE);
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
state.active_practice_id = "scale";
state.selected_practice_key_index = 2;
reference = vamp_zero::selected_score_reference(state);
BOOST_REQUIRE(reference);
BOOST_CHECK_EQUAL(reference->id, "scale-d");
BOOST_CHECK(reference->category == vamp_zero::score_category::PRACTICE);
state.selected_practice_key_index = 0;
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
catalog->practice.exercises.front().practice_type_id = "guitar";
reference = vamp_zero::selected_score_reference(state);
BOOST_REQUIRE(reference);
BOOST_CHECK_EQUAL(reference->id, "scale-c");
state.catalog.reset();
BOOST_CHECK(!vamp_zero::selected_score_reference(state));
}