Shared Catalog
This commit is contained in:
@@ -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 *part_text = part == nullptr ? "" : part;
|
||||
auto status_changed = !remote_valid_ || !same_color(last_status_, status);
|
||||
auto content_changed = !remote_valid_ || last_active_ != active || !same_text(last_title_, title_text) ||
|
||||
auto status_changed = !remote_valid_ || !same_color(last_status_, status) || last_busy_ != busy;
|
||||
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;
|
||||
if (!remote_valid_) {
|
||||
fill(vamp_style::rgb565(vamp_style::background));
|
||||
}
|
||||
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) {
|
||||
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') {
|
||||
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) {
|
||||
auto page_text = std::array<std::char_t, 32>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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_active_ = active;
|
||||
last_busy_ = busy;
|
||||
copy_text(last_title_, title_text);
|
||||
copy_text(last_part_, part_text);
|
||||
last_page_ = page;
|
||||
|
||||
+127
-151
@@ -1,33 +1,33 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include <lwip/ip_addr.h>
|
||||
#include <lwip/pbuf.h>
|
||||
#include <lwip/tcp.h>
|
||||
#include <pico/cyw43_arch.h>
|
||||
#include <pico/stdlib.h>
|
||||
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
struct request_state {
|
||||
tcp_pcb *pcb{};
|
||||
std::bool_t done{};
|
||||
std::bool_t status_known{};
|
||||
std::bool_t success{};
|
||||
std::char_t request[384]{};
|
||||
std::size_t request_size{};
|
||||
std::char_t response[1024]{};
|
||||
std::size_t response_size{};
|
||||
class lwip_lock {
|
||||
public:
|
||||
lwip_lock() {
|
||||
cyw43_arch_lwip_begin();
|
||||
}
|
||||
~lwip_lock() {
|
||||
cyw43_arch_lwip_end();
|
||||
}
|
||||
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 {
|
||||
return size >= 10U && (std::memcmp(data, "HTTP/1.1 2", 10U) == 0 || std::memcmp(data, "HTTP/1.0 2", 10U) == 0);
|
||||
auto response_is_success(std::string_view response) -> std::bool_t {
|
||||
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 {
|
||||
@@ -38,166 +38,142 @@ auto clear_callbacks(tcp_pcb *pcb) -> void {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
namespace vamp_pico {
|
||||
|
||||
auto handle_receive(void *argument, tcp_pcb *pcb, pbuf *buffer, err_t error) -> err_t {
|
||||
auto *state = static_cast<request_state *>(argument);
|
||||
if (state == nullptr) {
|
||||
if (buffer != nullptr) {
|
||||
pbuf_free(buffer);
|
||||
struct http_client_callbacks {
|
||||
static auto finish(http_client &client, std::bool_t success) -> std::int8_t {
|
||||
client.success_ = success;
|
||||
client.status_ = http_status::READY;
|
||||
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;
|
||||
}
|
||||
if (buffer == nullptr) {
|
||||
return close_request(*state, state->status_known && state->success);
|
||||
static auto error(void *argument, std::int8_t) -> void {
|
||||
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 {
|
||||
auto *state = static_cast<request_state *>(argument);
|
||||
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;
|
||||
vamp_pico::http_client::~http_client() {
|
||||
cancel();
|
||||
}
|
||||
|
||||
auto handle_error(void *argument, err_t) -> void {
|
||||
auto *state = static_cast<request_state *>(argument);
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
auto vamp_pico::http_client::start(remote_request request, std::uint64_t now_ms) -> http_start_result {
|
||||
auto lock = lwip_lock{};
|
||||
if (status_ != http_status::IDLE) {
|
||||
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{};
|
||||
if (body_size == 0U) {
|
||||
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);
|
||||
if (request.kind == request_kind::DISPLAY) {
|
||||
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 {
|
||||
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: "
|
||||
"close\r\n\r\n%s",
|
||||
method, path, VAMP_ZERO_HOST, VAMP_ZERO_PORT, body_size, body);
|
||||
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);
|
||||
}
|
||||
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{};
|
||||
if (ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) {
|
||||
return false;
|
||||
if (written <= 0 || static_cast<std::size_t>(written) >= request_buffer_.size() || ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) {
|
||||
http_client_callbacks::finish(*this, false);
|
||||
return http_start_result::STARTED;
|
||||
}
|
||||
cyw43_arch_lwip_begin();
|
||||
state.pcb = tcp_new_ip_type(IPADDR_TYPE_V4);
|
||||
if (state.pcb == nullptr) {
|
||||
cyw43_arch_lwip_end();
|
||||
return false;
|
||||
request_size_ = static_cast<std::size_t>(written);
|
||||
pcb_ = tcp_new_ip_type(IPADDR_TYPE_V4);
|
||||
if (pcb_ == nullptr) {
|
||||
http_client_callbacks::finish(*this, false);
|
||||
return http_start_result::STARTED;
|
||||
}
|
||||
tcp_arg(state.pcb, &state);
|
||||
tcp_err(state.pcb, handle_error);
|
||||
auto connected = tcp_connect(state.pcb, &address, static_cast<std::uint16_t>(VAMP_ZERO_PORT), handle_connected);
|
||||
if (connected != ERR_OK) {
|
||||
close_request(state, false);
|
||||
tcp_arg(pcb_, this);
|
||||
tcp_err(pcb_, http_client_callbacks::error);
|
||||
if (tcp_connect(pcb_, &address, static_cast<std::uint16_t>(VAMP_ZERO_PORT), http_client_callbacks::connected) != ERR_OK) {
|
||||
http_client_callbacks::finish(*this, false);
|
||||
}
|
||||
cyw43_arch_lwip_end();
|
||||
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;
|
||||
return http_start_result::STARTED;
|
||||
}
|
||||
|
||||
auto body_start(std::char_t const *response) -> std::char_t const * {
|
||||
auto *marker = std::strstr(response, "\r\n\r\n");
|
||||
return marker == nullptr ? response : marker + 4;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
auto vamp_pico::http_client::poll(std::uint64_t now_ms) -> http_status {
|
||||
auto lock = lwip_lock{};
|
||||
if (status_ == http_status::IN_FLIGHT && now_ms - started_ms_ >= timeout_ms_) {
|
||||
http_client_callbacks::finish(*this, false);
|
||||
}
|
||||
if (vamp_pico::parse_remote_display_body(body_start(response.data()), state)) {
|
||||
return true;
|
||||
}
|
||||
return vamp_pico::fetch_remote_display(state);
|
||||
return status_;
|
||||
}
|
||||
|
||||
auto vamp_pico::heartbeat() -> std::bool_t {
|
||||
return run_request("POST", vamp_protocol::remote_heartbeat_path, "{}", nullptr, 0, 700);
|
||||
auto vamp_pico::http_client::take_result() -> std::optional<http_result> {
|
||||
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 response = std::array<std::char_t, 1024>{};
|
||||
if (!run_request("GET", vamp_protocol::remote_display_path, nullptr, response.data(), response.size(), 1000)) {
|
||||
return false;
|
||||
}
|
||||
return vamp_pico::parse_remote_display_body(body_start(response.data()), state);
|
||||
auto vamp_pico::http_client::cancel() -> void {
|
||||
auto lock = lwip_lock{};
|
||||
http_client_callbacks::finish(*this, false);
|
||||
status_ = http_status::IDLE;
|
||||
response_size_ = 0U;
|
||||
}
|
||||
|
||||
@@ -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
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <vamp_pico/display.hpp>
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_pico/input.hpp>
|
||||
#include <vamp_pico/touch.hpp>
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
|
||||
@@ -15,14 +16,6 @@ enum class link_status {
|
||||
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 {
|
||||
switch (status) {
|
||||
case link_status::CONNECTING:
|
||||
@@ -35,12 +28,8 @@ auto color_for(link_status status) -> vamp_style::color {
|
||||
return vamp_style::retrying;
|
||||
}
|
||||
|
||||
auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote) -> void {
|
||||
display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active);
|
||||
}
|
||||
|
||||
auto clear_remote(vamp_pico::remote_display_state &remote) -> void {
|
||||
remote = vamp_pico::remote_display_state{};
|
||||
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, busy);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -118,25 +64,40 @@ auto main() -> int {
|
||||
}
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
auto status = link_status::RETRYING;
|
||||
auto gesture = gesture_state{};
|
||||
auto last_poll = get_absolute_time();
|
||||
auto poll_now = std::bool_t{true};
|
||||
auto gesture = vamp_pico::gesture_state{};
|
||||
auto scheduler = vamp_pico::request_scheduler{};
|
||||
// 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);
|
||||
while (true) {
|
||||
auto point = vamp_pico::touch_point{};
|
||||
update_gesture(gesture, touch.read(point), status, remote, display);
|
||||
auto now = get_absolute_time();
|
||||
if (poll_now || absolute_time_diff_us(last_poll, now) >= 1000000) {
|
||||
poll_now = false;
|
||||
last_poll = now;
|
||||
if (vamp_pico::fetch_remote_display(remote)) {
|
||||
auto touching = touch.read(point);
|
||||
auto now_ms = time_us_64() / 1000U;
|
||||
if (auto command = gesture.update(touching, now_ms); command) {
|
||||
if (scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) {
|
||||
busy_until_ms = now_ms + 1000U;
|
||||
}
|
||||
}
|
||||
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;
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+133
-17
@@ -1,6 +1,8 @@
|
||||
#include <csignal>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -10,11 +12,13 @@
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/signal_set.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
@@ -29,9 +33,11 @@
|
||||
#include <vamp_zero/audio.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/health.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/ui.hpp>
|
||||
|
||||
|
||||
@@ -65,17 +71,19 @@ auto write_text_atomic(std::filesystem::path const &destination, std::string con
|
||||
temporary += ".tmp";
|
||||
auto output = std::ofstream{temporary, std::ios::binary | std::ios::trunc};
|
||||
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.close();
|
||||
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);
|
||||
if (error) {
|
||||
auto message = error.message();
|
||||
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 {};
|
||||
}
|
||||
@@ -128,8 +136,10 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
|
||||
return std::unexpected(practice.error());
|
||||
}
|
||||
auto state = vamp_zero::app_state{
|
||||
.performance = std::move(*performance),
|
||||
.practice = std::move(*practice),
|
||||
.catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{
|
||||
.performance = std::move(*performance),
|
||||
.practice = std::move(*practice),
|
||||
}),
|
||||
.audio =
|
||||
{
|
||||
.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;
|
||||
}
|
||||
|
||||
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 io = boost::asio::io_context{};
|
||||
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) {
|
||||
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));
|
||||
}};
|
||||
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();
|
||||
}};
|
||||
auto ui_status = ui.run();
|
||||
server.stop();
|
||||
connection_timer.cancel();
|
||||
io.stop();
|
||||
boost::asio::post(io, [&] {
|
||||
connection_timer.cancel();
|
||||
server.stop();
|
||||
io.stop();
|
||||
});
|
||||
if (io_thread.joinable()) {
|
||||
io_thread.join();
|
||||
}
|
||||
@@ -308,22 +416,22 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
|
||||
if (!config) {
|
||||
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);
|
||||
if (command.check_config) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.health_check) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.ensure_database) {
|
||||
return ensure_database(*config);
|
||||
}
|
||||
if (command.exit_after_startup || config->runtime.exit_after_startup) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
auto ensured = ensure_database(*config);
|
||||
if (!ensured) {
|
||||
return ensured;
|
||||
@@ -336,6 +444,14 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
|
||||
if (!initial) {
|
||||
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 audio_controller = std::unique_ptr<vamp_zero::audio_controller>{};
|
||||
auto audio = vamp_zero::create_audio_controller(config->audio);
|
||||
|
||||
+155
-37
@@ -4,12 +4,15 @@
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/use_awaitable.hpp>
|
||||
|
||||
#include <noto.hpp>
|
||||
|
||||
@@ -189,6 +192,18 @@ CREATE TABLE IF NOT EXISTS practice_exercises (
|
||||
"NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)",
|
||||
"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) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
@@ -240,6 +255,39 @@ auto now_ms() -> std::int64_t {
|
||||
.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 sql = std::string{"DELETE FROM "};
|
||||
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 performance = vamp_zero::load_performance_library(assets_path);
|
||||
auto performance = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
|
||||
if (!performance) {
|
||||
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) {
|
||||
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 timestamp = now_ms();
|
||||
auto song_ids = std::vector<std::string>{};
|
||||
song_ids.reserve(performance->parts.size());
|
||||
for (auto const &part : performance->parts) {
|
||||
song_ids.reserve(performance->size());
|
||||
for (auto const &path : *performance) {
|
||||
if (!imported) {
|
||||
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);
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
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.source_path},
|
||||
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(
|
||||
R"sql(
|
||||
INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)
|
||||
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, ?10, ?11, ?12)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
composer = excluded.composer,
|
||||
@@ -370,6 +427,9 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
source_path = excluded.source_path,
|
||||
key_signature = excluded.key_signature,
|
||||
catalog_managed = 1,
|
||||
measure_count = excluded.measure_count,
|
||||
total_pages = excluded.total_pages,
|
||||
authored_key_signature = excluded.authored_key_signature,
|
||||
updated_at_ms = CASE
|
||||
WHEN songs.title != excluded.title
|
||||
OR songs.composer != excluded.composer
|
||||
@@ -388,10 +448,27 @@ WHERE songs.catalog_managed = 1
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
practice_ids.push_back(variant.id);
|
||||
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.composer},
|
||||
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{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(
|
||||
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)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1)
|
||||
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, ?13, ?14, ?15)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
practice_type_id = excluded.practice_type_id,
|
||||
practice_type_title = excluded.practice_type_title,
|
||||
@@ -423,6 +503,9 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
source_json = excluded.source_json,
|
||||
source_path = excluded.source_path,
|
||||
catalog_managed = 1,
|
||||
measure_count = excluded.measure_count,
|
||||
total_pages = excluded.total_pages,
|
||||
authored_key_signature = excluded.authored_key_signature,
|
||||
updated_at_ms = CASE
|
||||
WHEN practice_exercises.practice_type_id != excluded.practice_type_id
|
||||
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) {
|
||||
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 =
|
||||
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");
|
||||
if (!selected) {
|
||||
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;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() < 7U) {
|
||||
continue;
|
||||
}
|
||||
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());
|
||||
if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
|
||||
break;
|
||||
}
|
||||
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]),
|
||||
.part = text_value(row[3]),
|
||||
.source_path = text_value(row[4]),
|
||||
.source_json = std::move(source_json),
|
||||
.measure_count = parsed->measure_count,
|
||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||
.measure_count = count_value(row[5]),
|
||||
.total_pages = count_value(row[7]),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
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, "
|
||||
"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");
|
||||
if (!selected) {
|
||||
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;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() < 10U) {
|
||||
continue;
|
||||
}
|
||||
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());
|
||||
if (row.size() != 12U || count_value(row[9]) == 0U || count_value(row[10]) == 0U) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
|
||||
break;
|
||||
}
|
||||
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]),
|
||||
.key_signature = key_signature,
|
||||
.source_path = source_path,
|
||||
.source_json = source_json,
|
||||
.measure_count = parsed->measure_count,
|
||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||
.measure_count = count_value(row[9]),
|
||||
.total_pages = count_value(row[10]),
|
||||
};
|
||||
exercises.push_back({
|
||||
.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]),
|
||||
.title = text_value(row[5]),
|
||||
.composer = text_value(row[6]),
|
||||
.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,
|
||||
.authored_key_signature = text_value(row[11]),
|
||||
.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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -22,9 +23,6 @@ auto invalid(std::string message) -> modulus::error {
|
||||
}
|
||||
|
||||
auto human_label(std::string value) -> std::string {
|
||||
if (value.empty()) {
|
||||
return "Score";
|
||||
}
|
||||
auto capitalize_next = std::bool_t{true};
|
||||
for (auto &ch : value) {
|
||||
if (ch == '-' || ch == '_') {
|
||||
@@ -37,31 +35,57 @@ auto human_label(std::string value) -> std::string {
|
||||
}
|
||||
capitalize_next = false;
|
||||
}
|
||||
return value;
|
||||
return value.empty() ? "Score" : value;
|
||||
}
|
||||
|
||||
auto practice_type_label(std::string const &value) -> std::string {
|
||||
return human_label(value) + " Practice";
|
||||
auto key_rank(std::string const &key) -> std::size_t {
|
||||
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 exists = std::filesystem::exists(directory, error);
|
||||
auto exists = std::filesystem::exists(root, 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 (required) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + directory.string()));
|
||||
if (practice) {
|
||||
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 entries = std::filesystem::directory_iterator{directory, error};
|
||||
auto end = std::filesystem::directory_iterator{};
|
||||
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") {
|
||||
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());
|
||||
}
|
||||
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()));
|
||||
}
|
||||
std::sort(paths.begin(), paths.end());
|
||||
auto documents = std::vector<vamp_zero::score_document>{};
|
||||
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;
|
||||
return paths;
|
||||
}
|
||||
|
||||
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>{};
|
||||
for (auto const &part : path) {
|
||||
for (auto const &part : relative) {
|
||||
parts.push_back(part.string());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
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;
|
||||
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 const &primary = exercise.variants.front();
|
||||
exercise.key_signature = primary.key_signature;
|
||||
exercise.source_path = primary.source_path;
|
||||
exercise.source_json = primary.source_json;
|
||||
exercise.measure_count = primary.measure_count;
|
||||
exercise.total_pages = primary.total_pages;
|
||||
}
|
||||
|
||||
auto same_practice_exercise(vamp_zero::practice_exercise const &left, vamp_zero::practice_exercise const &right) -> std::bool_t {
|
||||
return left.id == right.id && left.practice_type_id == right.practice_type_id &&
|
||||
left.lesson_group_id == right.lesson_group_id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 id = parts[0] + "-" + parts[1] + "-" + parts[2];
|
||||
return vamp_zero::practice_exercise{
|
||||
.id = id,
|
||||
.practice_type_id = parts[0],
|
||||
.practice_type_title = human_label(parts[0]) + " Practice",
|
||||
.lesson_group_id = parts[1],
|
||||
.lesson_group_title = human_label(parts[1]),
|
||||
.title = document.title,
|
||||
.composer = document.composer,
|
||||
.authored_key_signature = document.authored_key_signature,
|
||||
.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,
|
||||
.total_pages = vamp_zero::score_page_count(document),
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (left.title != right.title) {
|
||||
return left.title < right.title;
|
||||
}
|
||||
return left.part < right.part;
|
||||
return std::tie(left.title, left.composer, left.part, left.id) < std::tie(right.title, right.composer, right.part, right.id);
|
||||
});
|
||||
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) {
|
||||
groups.push_back({
|
||||
.title = part.title,
|
||||
.composer = part.composer,
|
||||
});
|
||||
groups.push_back({.title = part.title, .composer = part.composer});
|
||||
}
|
||||
groups.back().parts.push_back(part);
|
||||
groups.back().parts.push_back(index);
|
||||
}
|
||||
return vamp_zero::library_state{
|
||||
.parts = std::move(parts),
|
||||
.groups = std::move(groups),
|
||||
};
|
||||
return {.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) {
|
||||
if (left.practice_type_id != right.practice_type_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;
|
||||
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);
|
||||
});
|
||||
auto merged = std::vector<vamp_zero::practice_exercise>{};
|
||||
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()));
|
||||
sync_primary_variant(merged.back());
|
||||
continue;
|
||||
} else {
|
||||
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>{};
|
||||
for (auto const &exercise : merged) {
|
||||
auto type =
|
||||
std::find_if(types.begin(), types.end(), [&](auto const &item) { return item.id == exercise.practice_type_id; });
|
||||
if (type == types.end()) {
|
||||
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;
|
||||
for (auto index = std::size_t{}; index < merged.size(); ++index) {
|
||||
auto &exercise = merged[index];
|
||||
std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const &left, auto const &right) {
|
||||
return std::tuple{key_rank(left.key_signature), left.id} < std::tuple{key_rank(right.key_signature), right.id};
|
||||
});
|
||||
if (lesson == type->lesson_groups.end()) {
|
||||
type->lesson_groups.push_back({
|
||||
.id = exercise.lesson_group_id,
|
||||
.title = exercise.lesson_group_title,
|
||||
});
|
||||
lesson = std::prev(type->lesson_groups.end());
|
||||
if (types.empty() || types.back().id != exercise.practice_type_id) {
|
||||
types.push_back({.id = exercise.practice_type_id, .title = exercise.practice_type_title});
|
||||
}
|
||||
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{
|
||||
.exercises = std::move(merged),
|
||||
.types = std::move(types),
|
||||
};
|
||||
return vamp_zero::practice_library{.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 documents = load_score_documents(std::filesystem::path{assets_path} / "songs", true);
|
||||
if (!documents) {
|
||||
return std::unexpected(documents.error());
|
||||
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
|
||||
if (!paths) {
|
||||
return std::unexpected(paths.error());
|
||||
}
|
||||
auto parts = std::vector<vamp_zero::library_part>{};
|
||||
for (auto &document : *documents) {
|
||||
parts.push_back({
|
||||
.id = document.id,
|
||||
.title = document.title,
|
||||
.composer = document.composer,
|
||||
.key_signature = document.key_signature,
|
||||
.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),
|
||||
});
|
||||
for (auto const &path : *paths) {
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
return std::unexpected(document.error());
|
||||
}
|
||||
parts.push_back(vamp_zero::performance_metadata(*document));
|
||||
}
|
||||
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 root = std::filesystem::path{assets_path} / "practice";
|
||||
auto error = std::error_code{};
|
||||
auto exists = std::filesystem::exists(root, error);
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect practice assets: " + error.message()));
|
||||
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
|
||||
if (!paths) {
|
||||
return std::unexpected(paths.error());
|
||||
}
|
||||
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>{};
|
||||
for (auto const &path : paths) {
|
||||
auto relative = std::filesystem::relative(path, root, error);
|
||||
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());
|
||||
for (auto const &path : *paths) {
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
return std::unexpected(document.error());
|
||||
}
|
||||
auto type_id = std::move(parts[0]);
|
||||
auto lesson_id = std::move(parts[1]);
|
||||
auto exercise_slug = std::move(parts[2]);
|
||||
auto key = document->key_signature.empty() ? std::string{"C"} : document->key_signature;
|
||||
auto exercise_id = type_id + "-" + lesson_id + "-" + exercise_slug;
|
||||
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)},
|
||||
});
|
||||
auto exercise = vamp_zero::practice_metadata(*document, assets_path);
|
||||
if (!exercise) {
|
||||
return std::unexpected(exercise.error());
|
||||
}
|
||||
exercises.push_back(std::move(*exercise));
|
||||
}
|
||||
return vamp_zero::group_practice_exercises(std::move(exercises));
|
||||
}
|
||||
|
||||
@@ -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 * {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
if (state.selected_song_index == 0U || state.catalog->performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
if (index >= state.catalog->performance.groups.size()) {
|
||||
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 * {
|
||||
@@ -31,28 +31,29 @@ auto vamp_zero::selected_part(vamp_zero::app_state const &state) -> vamp_zero::l
|
||||
if (index >= group->parts.size()) {
|
||||
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 * {
|
||||
if (state.active_part_id.empty()) {
|
||||
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 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 * {
|
||||
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;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
if (index >= state.catalog->practice.types.size()) {
|
||||
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 * {
|
||||
@@ -76,17 +77,18 @@ auto vamp_zero::selected_practice_exercise(vamp_zero::app_state const &state) ->
|
||||
if (index >= lesson->exercises.size()) {
|
||||
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 * {
|
||||
if (state.active_practice_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto found = std::find_if(state.practice.exercises.begin(),
|
||||
state.practice.exercises.end(),
|
||||
auto found = std::find_if(state.catalog->practice.exercises.begin(),
|
||||
state.catalog->practice.exercises.end(),
|
||||
[&](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 {
|
||||
@@ -131,7 +133,7 @@ auto vamp_zero::effective_practice_key(vamp_zero::app_state const &state, vamp_z
|
||||
variant != nullptr && !variant->key_signature.empty()) {
|
||||
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 {
|
||||
|
||||
@@ -86,6 +86,13 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
|
||||
return invalid(std::string{part.error().message()});
|
||||
}
|
||||
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")) {
|
||||
auto type = string_field(staff, "arrangement_part_type");
|
||||
if (!type) {
|
||||
|
||||
@@ -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 {
|
||||
if (!state.catalog) {
|
||||
state.catalog = vamp_zero::empty_catalogue();
|
||||
}
|
||||
state.audio = clamp_audio_state(state.audio);
|
||||
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
|
||||
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U);
|
||||
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) {
|
||||
state.selected_part_index = std::min(state.selected_part_index, menu_row_count(group->parts.size()) - 1U);
|
||||
} else {
|
||||
state.selected_part_index = 0U;
|
||||
}
|
||||
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) {
|
||||
state.selected_practice_lesson_index =
|
||||
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["title"] = exercise.title;
|
||||
result["composer"] = exercise.composer;
|
||||
result["key_signature"] = exercise.key_signature;
|
||||
result["source_path"] = exercise.source_path;
|
||||
result["measures"] = static_cast<std::uint64_t>(exercise.measure_count);
|
||||
result["pages"] = static_cast<std::uint64_t>(exercise.total_pages);
|
||||
result["authored_key_signature"] = exercise.authored_key_signature;
|
||||
if (!exercise.variants.empty()) {
|
||||
auto const &primary = exercise.variants.front();
|
||||
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["variants"] = std::move(variants);
|
||||
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{};
|
||||
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{};
|
||||
result["id"] = lesson.id;
|
||||
@@ -222,10 +229,10 @@ auto practice_type_exercise_count(vamp_zero::practice_type_group const &type) ->
|
||||
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{};
|
||||
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{};
|
||||
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 groups = boost::json::array{};
|
||||
for (auto const &group : state.performance.groups) {
|
||||
for (auto const &group : state.catalog->performance.groups) {
|
||||
auto parts = boost::json::array{};
|
||||
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{};
|
||||
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));
|
||||
}
|
||||
auto library = boost::json::object{};
|
||||
library["song_count"] = static_cast<std::uint64_t>(state.performance.groups.size());
|
||||
library["part_count"] = static_cast<std::uint64_t>(state.performance.parts.size());
|
||||
library["song_count"] = static_cast<std::uint64_t>(state.catalog->performance.groups.size());
|
||||
library["part_count"] = static_cast<std::uint64_t>(state.catalog->performance.parts.size());
|
||||
library["groups"] = std::move(groups);
|
||||
return library;
|
||||
}
|
||||
|
||||
auto practice_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
auto types = boost::json::array{};
|
||||
for (auto const &type : state.practice.types) {
|
||||
types.push_back(type_json(type));
|
||||
for (auto const &type : state.catalog->practice.types) {
|
||||
types.push_back(type_json(type, state.catalog->practice));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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["key_row_index"] = static_cast<std::uint64_t>(state.selected_practice_key_index);
|
||||
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) {
|
||||
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) {
|
||||
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{};
|
||||
practice["type_count"] = static_cast<std::uint64_t>(state.practice.types.size());
|
||||
practice["exercise_count"] = static_cast<std::uint64_t>(state.practice.exercises.size());
|
||||
practice["type_count"] = static_cast<std::uint64_t>(state.catalog->practice.types.size());
|
||||
practice["exercise_count"] = static_cast<std::uint64_t>(state.catalog->practice.exercises.size());
|
||||
practice["types"] = std::move(types);
|
||||
practice["exercises"] = std::move(exercises);
|
||||
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));
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
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;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
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;
|
||||
} else if (state.selected_song_index == 0U) {
|
||||
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:
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
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_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
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_exercise_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> {
|
||||
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) {
|
||||
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) {
|
||||
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"));
|
||||
}
|
||||
if (auto const *group = selected_group(state);
|
||||
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"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
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) {
|
||||
auto const *exercise = selected_practice_exercise(state);
|
||||
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) {
|
||||
|
||||
@@ -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 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>> {
|
||||
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);
|
||||
});
|
||||
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);
|
||||
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>> {
|
||||
static_cast<void>(state.heartbeat());
|
||||
auto updated = state.apply(vamp_protocol::page_command::PREVIOUS);
|
||||
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>> {
|
||||
static_cast<void>(state.heartbeat());
|
||||
auto updated = state.apply(vamp_protocol::page_command::SELECT);
|
||||
co_return remote_response(updated);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
#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 {
|
||||
switch (value) {
|
||||
case vamp_zero::hardware_mode::FAKE:
|
||||
|
||||
+166
-111
@@ -1,6 +1,7 @@
|
||||
#include <vamp_zero/ui.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
@@ -13,46 +14,71 @@
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
|
||||
#include <luma.hpp>
|
||||
|
||||
#include <vamp_style.hpp>
|
||||
#include <vamp_style/font.hpp>
|
||||
#include <vamp_zero/color.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.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)
|
||||
: 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 path = vamp_zero::validated_page_path(*document, static_cast<std::size_t>(std::distance(document->pages.begin(), found)));
|
||||
auto score = document.lock();
|
||||
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) {
|
||||
return std::unexpected(path.error());
|
||||
}
|
||||
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> {
|
||||
if (document && document->source_path == source_path && document->source_json == source_json) {
|
||||
return document;
|
||||
}
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, std::move(source_path));
|
||||
if (!parsed) {
|
||||
return std::unexpected(parsed.error());
|
||||
auto select(std::shared_ptr<vamp_zero::score_document const> const &score) -> void {
|
||||
if (score && document.lock() == score) {
|
||||
return;
|
||||
}
|
||||
textures.clear();
|
||||
document = std::make_shared<vamp_zero::score_document const>(std::move(*parsed));
|
||||
return document;
|
||||
document = score;
|
||||
}
|
||||
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"));
|
||||
}
|
||||
auto texture = textures.get(document->pages[index].path);
|
||||
auto texture = textures.get(score->pages[index].path);
|
||||
if (!texture) {
|
||||
return std::unexpected(texture.error());
|
||||
}
|
||||
@@ -66,11 +92,9 @@ struct vamp_zero::score_draw_cache {
|
||||
return {};
|
||||
}
|
||||
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_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,
|
||||
{.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height},
|
||||
"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);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height},
|
||||
"Practice Mode",
|
||||
std::to_string(state.practice.exercises.size()) + " EXERCISES",
|
||||
std::to_string(state.catalog->practice.exercises.size()) + " EXERCISES",
|
||||
state.selected_main_menu_index == 1U);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
|
||||
@@ -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 {
|
||||
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 row_height = std::float32_t{54.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 first = first_visible_row(state.selected_song_index, total_rows, max_rows);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
auto const &group = state.performance.groups[index - 1U];
|
||||
auto const &group = state.catalog->performance.groups[index - 1U];
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F,
|
||||
.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",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -254,7 +278,11 @@ auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds,
|
||||
state.selected_part_index == 0U);
|
||||
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,
|
||||
{.x = bounds.x + 36.0F,
|
||||
.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 {
|
||||
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 row_height = std::float32_t{54.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 first = first_visible_row(state.selected_practice_type_index, total_rows, max_rows);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
auto const &type = state.practice.types[index - 1U];
|
||||
auto const &type = state.catalog->practice.types[index - 1U];
|
||||
auto exercise_count = std::size_t{};
|
||||
for (auto const &lesson : type.lesson_groups) {
|
||||
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",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -385,7 +413,11 @@ auto draw_practice_exercise_menu(luma::draw_context &context, luma::rect bounds,
|
||||
state.selected_practice_exercise_index == 0U);
|
||||
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 =
|
||||
supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise);
|
||||
draw_row(context,
|
||||
@@ -429,8 +461,8 @@ auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp
|
||||
continue;
|
||||
}
|
||||
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"}
|
||||
: "SOURCE " + exercise->key_signature;
|
||||
auto source = exercise == nullptr || exercise->authored_key_signature.empty() ? std::string{}
|
||||
: "SOURCE " + exercise->authored_key_signature;
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F,
|
||||
.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 source_json = std::string_view{};
|
||||
auto source_path = std::string{};
|
||||
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void {
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const *part = active_part(state);
|
||||
if (part == nullptr) {
|
||||
draw_performance_song_menu(context, bounds, state);
|
||||
return;
|
||||
}
|
||||
source_json = part->source_json;
|
||||
source_path = part->source_path;
|
||||
} else {
|
||||
auto const *exercise = active_practice(state);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
source_json = variant->source_json;
|
||||
source_path = variant->source_path;
|
||||
}
|
||||
auto parsed = score_cache.get(source_json, source_path);
|
||||
if (!parsed) {
|
||||
if (loaded.loading) {
|
||||
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 + 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;
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
: state_{std::move(state)}, score_cache_{std::move(score_cache)}, on_touch_{std::move(on_touch)} {}
|
||||
vamp_root_control(std::shared_ptr<vamp_zero::ui_runtime> runtime, std::shared_ptr<vamp_zero::score_loader> loader, luma::application &application)
|
||||
: 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 {
|
||||
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
|
||||
}
|
||||
auto draw(luma::draw_context &context, luma::theme const &) const -> void override {
|
||||
auto state = vamp_zero::ui_state{};
|
||||
auto 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();
|
||||
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{
|
||||
.x = area.x,
|
||||
.y = area.y + 80.0F,
|
||||
.width = area.width,
|
||||
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
|
||||
};
|
||||
if (state_.app.mode == vamp_zero::app_mode::MAIN_MENU) {
|
||||
draw_main_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
draw_performance_song_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
|
||||
draw_performance_part_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_TYPE_MENU) {
|
||||
draw_practice_type_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU) {
|
||||
draw_practice_lesson_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) {
|
||||
draw_practice_exercise_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
|
||||
draw_practice_key_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE ||
|
||||
state_.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
if (score_cache_) {
|
||||
draw_score(context, content, state_.app, *score_cache_);
|
||||
}
|
||||
if (state.app.mode == vamp_zero::app_mode::MAIN_MENU) {
|
||||
draw_main_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
draw_performance_song_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU) {
|
||||
draw_performance_part_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PRACTICE_TYPE_MENU) {
|
||||
draw_practice_type_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU) {
|
||||
draw_practice_lesson_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU) {
|
||||
draw_practice_exercise_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
|
||||
draw_practice_key_menu(context, content, state.app);
|
||||
} else if (state.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE ||
|
||||
state.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
draw_score(context, content, state.app, loaded, score_cache_);
|
||||
} 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 {
|
||||
return on_touch_ ? on_touch_(event) : false;
|
||||
return runtime_->handle_touch(event);
|
||||
}
|
||||
private:
|
||||
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_{};
|
||||
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
|
||||
std::shared_ptr<vamp_zero::score_loader> loader_;
|
||||
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)
|
||||
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)},
|
||||
command_handler_{std::move(command_handler)}, score_cache_{std::make_shared<vamp_zero::score_draw_cache>(application_)} {}
|
||||
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)
|
||||
: application_{std::make_shared<luma::application>(std::move(config))},
|
||||
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_) {
|
||||
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;
|
||||
}
|
||||
|
||||
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_.async_wait([this](boost::system::error_code const &error) {
|
||||
if (error) {
|
||||
tap_timer_.async_wait([weak = weak_from_this()](boost::system::error_code const &error) {
|
||||
auto self = weak.lock();
|
||||
if (error || !self) {
|
||||
return;
|
||||
}
|
||||
auto send_next = std::bool_t{false};
|
||||
{
|
||||
auto lock = std::scoped_lock{touch_mutex_};
|
||||
send_next = pending_tap_;
|
||||
pending_tap_ = false;
|
||||
auto lock = std::scoped_lock{self->touch_mutex_};
|
||||
send_next = self->pending_tap_ && !self->stopped_;
|
||||
self->pending_tap_ = false;
|
||||
}
|
||||
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()) {
|
||||
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 lock = std::scoped_lock{touch_mutex_};
|
||||
if (stopped_) {
|
||||
return false;
|
||||
}
|
||||
for (auto const &point : event.points) {
|
||||
if (point.phase == luma::touch_phase::DOWN && !active_touch_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;
|
||||
}
|
||||
}
|
||||
if (arm_tap) {
|
||||
arm_pending_tap_locked();
|
||||
}
|
||||
}
|
||||
for (auto command : commands) {
|
||||
send_touch_command(command);
|
||||
}
|
||||
if (arm_tap) {
|
||||
arm_pending_tap();
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
|
||||
auto state = snapshot();
|
||||
auto root = make_root(state);
|
||||
if (!root) {
|
||||
return std::unexpected(root.error());
|
||||
}
|
||||
if (auto status = application_.set_root(std::move(*root)); !status) {
|
||||
if (auto status = application_->set_root(std::make_unique<vamp_root_control>(runtime_, score_loader_, *application_)); !status) {
|
||||
return status;
|
||||
}
|
||||
return application_.run();
|
||||
auto status = application_->run();
|
||||
stop();
|
||||
return status;
|
||||
}
|
||||
|
||||
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 lock = std::scoped_lock{mutex_};
|
||||
state_ = std::move(state);
|
||||
}
|
||||
return application_.dispatch([this] -> std::expected<void, modulus::error> {
|
||||
auto root = make_root(snapshot());
|
||||
if (!root) {
|
||||
return std::unexpected(root.error());
|
||||
auto lock = std::scoped_lock{runtime_->mutex_};
|
||||
if (!state.app.catalog) {
|
||||
state.app.catalog = vamp_zero::empty_catalogue();
|
||||
}
|
||||
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 lock = std::scoped_lock{mutex_};
|
||||
return 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 lock = std::scoped_lock{runtime_->mutex_};
|
||||
return runtime_->state_;
|
||||
}
|
||||
|
||||
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
|
||||
|
||||
Reference in New Issue
Block a user