@@ -0,0 +1,293 @@
|
||||
#include <vamp_pico/display.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include <vamp_style/font.hpp>
|
||||
|
||||
#include "hardware/gpio.h"
|
||||
#include "hardware/spi.h"
|
||||
#include "pico/stdlib.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto pin_lcd_cs = std::uint32_t{9};
|
||||
constexpr auto pin_lcd_sclk = std::uint32_t{10};
|
||||
constexpr auto pin_lcd_mosi = std::uint32_t{11};
|
||||
constexpr auto pin_lcd_miso = std::uint32_t{12};
|
||||
constexpr auto pin_lcd_rst = std::uint32_t{13};
|
||||
constexpr auto pin_lcd_dc = std::uint32_t{14};
|
||||
constexpr auto pin_lcd_bl = std::uint32_t{15};
|
||||
|
||||
auto select() -> void {
|
||||
gpio_put(pin_lcd_cs, 0);
|
||||
}
|
||||
|
||||
auto deselect() -> void {
|
||||
gpio_put(pin_lcd_cs, 1);
|
||||
}
|
||||
|
||||
auto command(std::uchar_t value) -> void {
|
||||
gpio_put(pin_lcd_dc, 0);
|
||||
select();
|
||||
spi_write_blocking(spi1, &value, 1);
|
||||
deselect();
|
||||
}
|
||||
|
||||
auto data(std::uchar_t const* values, std::size_t count) -> void {
|
||||
gpio_put(pin_lcd_dc, 1);
|
||||
select();
|
||||
spi_write_blocking(spi1, values, count);
|
||||
deselect();
|
||||
}
|
||||
|
||||
auto data8(std::uchar_t value) -> void {
|
||||
data(&value, 1);
|
||||
}
|
||||
|
||||
auto set_window(std::int32_t x0, std::int32_t y0, std::int32_t x1, std::int32_t y1) -> void {
|
||||
x0 = std::clamp(x0, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
|
||||
y0 = std::clamp(y0, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
|
||||
x1 = std::clamp(x1, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
|
||||
y1 = std::clamp(y1, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
|
||||
|
||||
command(0x2a);
|
||||
auto column = std::array<std::uchar_t, 4>{
|
||||
static_cast<std::uchar_t>((x0 >> 8) & 0xff),
|
||||
static_cast<std::uchar_t>(x0 & 0xff),
|
||||
static_cast<std::uchar_t>((x1 >> 8) & 0xff),
|
||||
static_cast<std::uchar_t>(x1 & 0xff),
|
||||
};
|
||||
data(column.data(), column.size());
|
||||
|
||||
command(0x2b);
|
||||
auto row = std::array<std::uchar_t, 4>{
|
||||
static_cast<std::uchar_t>((y0 >> 8) & 0xff),
|
||||
static_cast<std::uchar_t>(y0 & 0xff),
|
||||
static_cast<std::uchar_t>((y1 >> 8) & 0xff),
|
||||
static_cast<std::uchar_t>(y1 & 0xff),
|
||||
};
|
||||
data(row.data(), row.size());
|
||||
command(0x2c);
|
||||
}
|
||||
|
||||
auto same_color(vamp_style::color left, vamp_style::color right) -> std::bool_t {
|
||||
return left.r == right.r && left.g == right.g && left.b == right.b && left.a == right.a;
|
||||
}
|
||||
|
||||
template <std::size_t Count>
|
||||
auto same_text(std::array<std::char_t, Count> const& cached, std::char_t const* text) -> std::bool_t {
|
||||
auto const* value = text == nullptr ? "" : text;
|
||||
return std::strncmp(cached.data(), value, Count) == 0;
|
||||
}
|
||||
|
||||
template <std::size_t Count>
|
||||
auto copy_text(std::array<std::char_t, Count>& cached, std::char_t const* text) -> void {
|
||||
auto const* value = text == nullptr ? "" : text;
|
||||
auto count = std::min(Count - 1U, std::strlen(value));
|
||||
std::memcpy(cached.data(), value, count);
|
||||
cached[count] = '\0';
|
||||
}
|
||||
|
||||
auto blend_channel(std::uint8_t foreground, std::uint8_t background, std::uint8_t alpha) -> std::uint8_t {
|
||||
auto fg = static_cast<std::uint32_t>(foreground) * alpha;
|
||||
auto bg = static_cast<std::uint32_t>(background) * (15U - alpha);
|
||||
return static_cast<std::uint8_t>((fg + bg + 7U) / 15U);
|
||||
}
|
||||
|
||||
auto blend(vamp_style::color foreground, vamp_style::color background, std::uint8_t alpha) -> vamp_style::color {
|
||||
if (alpha >= 15U) {
|
||||
return foreground;
|
||||
}
|
||||
return {
|
||||
.r = blend_channel(foreground.r, background.r, alpha),
|
||||
.g = blend_channel(foreground.g, background.g, alpha),
|
||||
.b = blend_channel(foreground.b, background.b, alpha),
|
||||
.a = 255,
|
||||
};
|
||||
}
|
||||
|
||||
auto draw_centered_text(vamp_pico::display& display, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t preferred_scale, std::int32_t max_width) -> void {
|
||||
if (text == nullptr || text[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
auto scale = preferred_scale;
|
||||
while (scale > 1 && vamp_style::ui_text_width(text, scale) > max_width) {
|
||||
--scale;
|
||||
}
|
||||
auto width = vamp_style::ui_text_width(text, scale);
|
||||
auto x = std::max<std::int32_t>(12, (vamp_pico::display_width - width) / 2);
|
||||
display.draw_text(x, y, text, color, scale);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_pico::display::init() -> void {
|
||||
spi_init(spi1, 62'500'000);
|
||||
gpio_set_function(pin_lcd_sclk, GPIO_FUNC_SPI);
|
||||
gpio_set_function(pin_lcd_mosi, GPIO_FUNC_SPI);
|
||||
gpio_set_function(pin_lcd_miso, GPIO_FUNC_SPI);
|
||||
gpio_init(pin_lcd_cs);
|
||||
gpio_init(pin_lcd_dc);
|
||||
gpio_init(pin_lcd_rst);
|
||||
gpio_init(pin_lcd_bl);
|
||||
gpio_set_dir(pin_lcd_cs, GPIO_OUT);
|
||||
gpio_set_dir(pin_lcd_dc, GPIO_OUT);
|
||||
gpio_set_dir(pin_lcd_rst, GPIO_OUT);
|
||||
gpio_set_dir(pin_lcd_bl, GPIO_OUT);
|
||||
deselect();
|
||||
gpio_put(pin_lcd_bl, 1);
|
||||
|
||||
gpio_put(pin_lcd_rst, 0);
|
||||
sleep_ms(50);
|
||||
gpio_put(pin_lcd_rst, 1);
|
||||
sleep_ms(120);
|
||||
|
||||
command(0x01);
|
||||
sleep_ms(150);
|
||||
command(0x11);
|
||||
sleep_ms(120);
|
||||
command(0x3a);
|
||||
data8(0x55);
|
||||
command(0x36);
|
||||
data8(0x60);
|
||||
command(0x21);
|
||||
command(0x13);
|
||||
command(0x29);
|
||||
sleep_ms(20);
|
||||
fill(vamp_pico::color_black);
|
||||
remote_valid_ = false;
|
||||
}
|
||||
|
||||
auto vamp_pico::display::fill(std::uint16_t color) -> void {
|
||||
fill_rect(0, 0, vamp_pico::display_width, vamp_pico::display_height, color);
|
||||
}
|
||||
|
||||
auto vamp_pico::display::fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
auto x0 = std::clamp(x, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
|
||||
auto y0 = std::clamp(y, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
|
||||
auto x1 = std::clamp(x + width - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1});
|
||||
auto y1 = std::clamp(y + height - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1});
|
||||
if (x1 < x0 || y1 < y0) {
|
||||
return;
|
||||
}
|
||||
set_window(x0, y0, x1, y1);
|
||||
auto hi = static_cast<std::uchar_t>((color >> 8) & 0xff);
|
||||
auto lo = static_cast<std::uchar_t>(color & 0xff);
|
||||
auto row = std::array<std::uchar_t, vamp_pico::display_width * 2>{};
|
||||
for (auto i = std::size_t{}; i < row.size(); i += 2U) {
|
||||
row[i] = hi;
|
||||
row[i + 1U] = lo;
|
||||
}
|
||||
gpio_put(pin_lcd_dc, 1);
|
||||
select();
|
||||
for (auto yy = y0; yy <= y1; ++yy) {
|
||||
spi_write_blocking(spi1, row.data(), static_cast<std::size_t>(x1 - x0 + 1) * 2U);
|
||||
}
|
||||
deselect();
|
||||
}
|
||||
|
||||
auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t scale) -> void {
|
||||
if (text == nullptr || scale <= 0) {
|
||||
return;
|
||||
}
|
||||
auto foreground = vamp_style::color{
|
||||
.r = static_cast<std::uint8_t>(((color >> 11U) & 0x1fU) * 255U / 31U),
|
||||
.g = static_cast<std::uint8_t>(((color >> 5U) & 0x3fU) * 255U / 63U),
|
||||
.b = static_cast<std::uint8_t>((color & 0x1fU) * 255U / 31U),
|
||||
.a = 255,
|
||||
};
|
||||
auto cursor = x;
|
||||
auto previous = std::char_t{};
|
||||
for (auto i = std::size_t{}; i < std::strlen(text); ++i) {
|
||||
if (previous != std::char_t{}) {
|
||||
cursor += vamp_style::ui_font_pair_adjust(previous, text[i]) * scale;
|
||||
}
|
||||
auto glyph = vamp_style::ui_font_glyph(text[i]);
|
||||
for (auto row = 0; row < vamp_style::ui_font_height; ++row) {
|
||||
auto col = std::int32_t{};
|
||||
while (col < static_cast<std::int32_t>(glyph.width)) {
|
||||
auto row_offset = static_cast<std::size_t>(row * vamp_style::ui_font_max_width);
|
||||
while (col < static_cast<std::int32_t>(glyph.width) && glyph.alpha[row_offset + static_cast<std::size_t>(col)] == 0U) {
|
||||
++col;
|
||||
}
|
||||
auto start = col;
|
||||
auto alpha = start < static_cast<std::int32_t>(glyph.width)
|
||||
? glyph.alpha[row_offset + static_cast<std::size_t>(start)]
|
||||
: std::uint8_t{};
|
||||
while (col < static_cast<std::int32_t>(glyph.width) && glyph.alpha[row_offset + static_cast<std::size_t>(col)] == alpha) {
|
||||
++col;
|
||||
}
|
||||
if (col > start) {
|
||||
auto run_color = vamp_style::rgb565(blend(foreground, vamp_style::background, alpha));
|
||||
fill_rect(cursor + start * scale, y + row * scale, (col - start) * scale, scale, run_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor += static_cast<std::int32_t>(glyph.advance) * scale;
|
||||
if (cursor >= vamp_pico::display_width) {
|
||||
return;
|
||||
}
|
||||
previous = text[i];
|
||||
}
|
||||
}
|
||||
|
||||
auto vamp_pico::display::show_boot(std::char_t const* line1, std::char_t const* line2, std::uint16_t accent) -> void {
|
||||
remote_valid_ = false;
|
||||
fill(vamp_style::rgb565(vamp_style::background));
|
||||
fill_rect(0, 0, vamp_pico::display_width, 28, accent);
|
||||
draw_text(14, 48, "VAMP", vamp_style::rgb565(vamp_style::ink), 2);
|
||||
if (line1 != nullptr) {
|
||||
draw_text(92, 54, line1, vamp_style::rgb565(vamp_style::ink), 1);
|
||||
}
|
||||
if (line2 != nullptr) {
|
||||
draw_text(14, 96, line2, vamp_style::rgb565(vamp_style::muted), 1);
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
|| !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));
|
||||
}
|
||||
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);
|
||||
if (pages != 0U) {
|
||||
auto page_text = std::array<std::char_t, 32>{};
|
||||
auto written = std::snprintf(page_text.data(), page_text.size(), "PAGE %lu/%lu", static_cast<unsigned long>(page), static_cast<unsigned long>(pages));
|
||||
if (written > 0) {
|
||||
draw_centered_text(*this, 168, page_text.data(), vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_status_ = status;
|
||||
last_active_ = active;
|
||||
copy_text(last_title_, title_text);
|
||||
copy_text(last_part_, part_text);
|
||||
last_page_ = page;
|
||||
last_pages_ = pages;
|
||||
remote_valid_ = true;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#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"
|
||||
|
||||
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{};
|
||||
};
|
||||
|
||||
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 clear_callbacks(tcp_pcb* pcb) -> void {
|
||||
tcp_arg(pcb, nullptr);
|
||||
tcp_recv(pcb, nullptr);
|
||||
tcp_sent(pcb, nullptr);
|
||||
tcp_poll(pcb, nullptr, 0);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return ERR_OK;
|
||||
}
|
||||
if (buffer == nullptr) {
|
||||
return close_request(*state, state->status_known && state->success);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
auto handle_error(void* argument, err_t) -> void {
|
||||
auto* state = static_cast<request_state*>(argument);
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
);
|
||||
} else {
|
||||
written = std::snprintf(
|
||||
state.request,
|
||||
sizeof(state.request),
|
||||
"%s %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: %u\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n%s",
|
||||
method,
|
||||
path,
|
||||
VAMP_ZERO_HOST,
|
||||
VAMP_ZERO_PORT,
|
||||
static_cast<unsigned>(body_size),
|
||||
body
|
||||
);
|
||||
}
|
||||
if (written <= 0 || static_cast<std::size_t>(written) >= sizeof(state.request)) {
|
||||
return false;
|
||||
}
|
||||
state.request_size = static_cast<std::size_t>(written);
|
||||
|
||||
auto address = ip_addr_t{};
|
||||
if (ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cyw43_arch_lwip_begin();
|
||||
state.pcb = tcp_new_ip_type(IPADDR_TYPE_V4);
|
||||
if (state.pcb == nullptr) {
|
||||
cyw43_arch_lwip_end();
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 copy_value(std::char_t const* begin, std::size_t size, std::char_t* output, std::size_t capacity) -> void {
|
||||
if (capacity == 0U) {
|
||||
return;
|
||||
}
|
||||
auto count = std::min(capacity - 1U, size);
|
||||
std::memcpy(output, begin, count);
|
||||
output[count] = '\0';
|
||||
}
|
||||
|
||||
auto parse_uint(std::char_t const* begin, std::size_t size) -> std::uint32_t {
|
||||
auto buffer = std::array<std::char_t, 16>{};
|
||||
copy_value(begin, std::min<std::size_t>(size, buffer.size() - 1U), buffer.data(), buffer.size());
|
||||
return static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 10));
|
||||
}
|
||||
|
||||
auto parse_display_body(std::char_t const* body, vamp_pico::remote_display_state& state) -> std::bool_t {
|
||||
auto parsed = vamp_pico::remote_display_state{};
|
||||
auto saw_version = std::bool_t{};
|
||||
auto* cursor = body;
|
||||
while (cursor != nullptr && *cursor != '\0') {
|
||||
auto* line_end = std::strchr(cursor, '\n');
|
||||
auto size = line_end == nullptr ? std::strlen(cursor) : static_cast<std::size_t>(line_end - cursor);
|
||||
if (size != 0U && cursor[size - 1U] == '\r') {
|
||||
--size;
|
||||
}
|
||||
auto* equals = static_cast<std::char_t const*>(std::memchr(cursor, '=', size));
|
||||
if (equals != nullptr) {
|
||||
auto key_size = static_cast<std::size_t>(equals - cursor);
|
||||
auto* value = equals + 1;
|
||||
auto value_size = size - key_size - 1U;
|
||||
if (key_size == 7U && std::memcmp(cursor, "version", 7U) == 0) {
|
||||
saw_version = true;
|
||||
} else if (key_size == 6U && std::memcmp(cursor, "active", 6U) == 0) {
|
||||
parsed.active = value_size != 0U && value[0] == '1';
|
||||
} else if (key_size == 5U && std::memcmp(cursor, "title", 5U) == 0) {
|
||||
copy_value(value, value_size, parsed.title, sizeof(parsed.title));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "part", 4U) == 0) {
|
||||
copy_value(value, value_size, parsed.part, sizeof(parsed.part));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "mode", 4U) == 0) {
|
||||
copy_value(value, value_size, parsed.mode, sizeof(parsed.mode));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "page", 4U) == 0) {
|
||||
parsed.page = parse_uint(value, value_size);
|
||||
} else if (key_size == 5U && std::memcmp(cursor, "pages", 5U) == 0) {
|
||||
parsed.pages = parse_uint(value, value_size);
|
||||
}
|
||||
}
|
||||
cursor = line_end == nullptr ? nullptr : line_end + 1;
|
||||
}
|
||||
if (saw_version) {
|
||||
state = parsed;
|
||||
}
|
||||
return saw_version;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t {
|
||||
auto response = std::array<std::char_t, 1024>{};
|
||||
if (!run_request("POST", vamp_protocol::page_command_path(command), "{}", response.data(), response.size(), 700)) {
|
||||
return false;
|
||||
}
|
||||
if (parse_display_body(body_start(response.data()), state)) {
|
||||
return true;
|
||||
}
|
||||
return vamp_pico::fetch_remote_display(state);
|
||||
}
|
||||
|
||||
auto vamp_pico::heartbeat() -> std::bool_t {
|
||||
return run_request("POST", vamp_protocol::remote_heartbeat_path, "{}", nullptr, 0, 700);
|
||||
}
|
||||
|
||||
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 parse_display_body(body_start(response.data()), state);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#include <vamp_pico/display.hpp>
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_pico/touch.hpp>
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
|
||||
#include "pico/cyw43_arch.h"
|
||||
#include "pico/stdlib.h"
|
||||
|
||||
namespace {
|
||||
|
||||
enum class link_status {
|
||||
CONNECTING,
|
||||
RETRYING,
|
||||
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:
|
||||
return vamp_style::connecting;
|
||||
case link_status::RETRYING:
|
||||
return vamp_style::retrying;
|
||||
case link_status::CONNECTED:
|
||||
return vamp_style::connected;
|
||||
}
|
||||
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 connect_wifi(vamp_pico::display& display) -> std::bool_t {
|
||||
auto empty = vamp_pico::remote_display_state{};
|
||||
render(display, link_status::CONNECTING, empty);
|
||||
if (cyw43_arch_init() != 0) {
|
||||
display.show_boot("WIFI", "ERROR", vamp_style::rgb565(vamp_style::danger));
|
||||
return false;
|
||||
}
|
||||
cyw43_arch_enable_sta_mode();
|
||||
while (cyw43_arch_wifi_connect_timeout_ms(VAMP_PICO_WIFI_SSID, VAMP_PICO_WIFI_PASSPHRASE, CYW43_AUTH_WPA2_AES_PSK, 15000) != 0) {
|
||||
render(display, link_status::RETRYING, empty);
|
||||
sleep_ms(2000);
|
||||
render(display, link_status::CONNECTING, empty);
|
||||
}
|
||||
render(display, link_status::RETRYING, empty);
|
||||
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;
|
||||
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 {
|
||||
stdio_init_all();
|
||||
|
||||
auto display = vamp_pico::display{};
|
||||
auto touch = vamp_pico::touch_controller{};
|
||||
display.init();
|
||||
touch.init();
|
||||
|
||||
if (!connect_wifi(display)) {
|
||||
while (true) {
|
||||
sleep_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
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 = true;
|
||||
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)) {
|
||||
status = link_status::CONNECTED;
|
||||
} else {
|
||||
status = link_status::RETRYING;
|
||||
}
|
||||
render(display, status, remote);
|
||||
}
|
||||
sleep_ms(8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#include <vamp_pico/touch.hpp>
|
||||
|
||||
#include <vamp_pico/display.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
#include "hardware/gpio.h"
|
||||
#include "hardware/i2c.h"
|
||||
#include "pico/stdlib.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto pin_tp_rst = std::uint32_t{5};
|
||||
constexpr auto pin_tp_sda = std::uint32_t{6};
|
||||
constexpr auto pin_tp_scl = std::uint32_t{7};
|
||||
constexpr auto pin_tp_int = std::uint32_t{8};
|
||||
constexpr auto cst816_address = std::uchar_t{0x15};
|
||||
|
||||
}
|
||||
|
||||
auto vamp_pico::touch_controller::init() -> void {
|
||||
i2c_init(i2c1, 400'000);
|
||||
gpio_set_function(pin_tp_sda, GPIO_FUNC_I2C);
|
||||
gpio_set_function(pin_tp_scl, GPIO_FUNC_I2C);
|
||||
gpio_pull_up(pin_tp_sda);
|
||||
gpio_pull_up(pin_tp_scl);
|
||||
|
||||
gpio_init(pin_tp_rst);
|
||||
gpio_set_dir(pin_tp_rst, GPIO_OUT);
|
||||
gpio_put(pin_tp_rst, 0);
|
||||
sleep_ms(10);
|
||||
gpio_put(pin_tp_rst, 1);
|
||||
sleep_ms(50);
|
||||
|
||||
gpio_init(pin_tp_int);
|
||||
gpio_set_dir(pin_tp_int, GPIO_IN);
|
||||
gpio_pull_up(pin_tp_int);
|
||||
}
|
||||
|
||||
auto vamp_pico::touch_controller::read(vamp_pico::touch_point& point) -> std::bool_t {
|
||||
auto reg = std::uchar_t{0x02};
|
||||
auto data = std::array<std::uchar_t, 5>{};
|
||||
if (i2c_write_blocking(i2c1, cst816_address, ®, 1, true) != 1) {
|
||||
return false;
|
||||
}
|
||||
if (i2c_read_blocking(i2c1, cst816_address, data.data(), data.size(), false) != static_cast<int>(data.size())) {
|
||||
return false;
|
||||
}
|
||||
auto fingers = data[0] & 0x0f;
|
||||
if (fingers == 0 || fingers > 5) {
|
||||
return false;
|
||||
}
|
||||
auto raw_x = static_cast<std::int32_t>(((data[1] & 0x0f) << 8) | data[2]);
|
||||
auto raw_y = static_cast<std::int32_t>(((data[3] & 0x0f) << 8) | data[4]);
|
||||
point.x = raw_y;
|
||||
point.y = vamp_pico::display_height - 1 - raw_x;
|
||||
return point.x >= 0 && point.x < vamp_pico::display_width && point.y >= 0 && point.y < vamp_pico::display_height;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
|
||||
auto vamp_protocol::page_command_path(vamp_protocol::page_command command) -> std::char_t const* {
|
||||
switch (command) {
|
||||
case vamp_protocol::page_command::PREVIOUS:
|
||||
return vamp_protocol::remote_previous_path;
|
||||
case vamp_protocol::page_command::NEXT:
|
||||
return vamp_protocol::remote_next_path;
|
||||
case vamp_protocol::page_command::SELECT:
|
||||
return vamp_protocol::remote_select_path;
|
||||
}
|
||||
return vamp_protocol::remote_next_path;
|
||||
}
|
||||
|
||||
auto vamp_protocol::page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const* {
|
||||
switch (command) {
|
||||
case vamp_protocol::page_command::PREVIOUS:
|
||||
return vamp_protocol::previous_page_path;
|
||||
case vamp_protocol::page_command::NEXT:
|
||||
return vamp_protocol::next_page_path;
|
||||
case vamp_protocol::page_command::SELECT:
|
||||
return vamp_protocol::remote_select_path;
|
||||
}
|
||||
return vamp_protocol::next_page_path;
|
||||
}
|
||||
|
||||
auto vamp_protocol::page_command_name(vamp_protocol::page_command command) -> std::char_t const* {
|
||||
switch (command) {
|
||||
case vamp_protocol::page_command::PREVIOUS:
|
||||
return "previous";
|
||||
case vamp_protocol::page_command::NEXT:
|
||||
return "next";
|
||||
case vamp_protocol::page_command::SELECT:
|
||||
return "select";
|
||||
}
|
||||
return "next";
|
||||
}
|
||||
|
||||
auto vamp_protocol::page_command_display_label(vamp_protocol::page_command command) -> std::char_t const* {
|
||||
switch (command) {
|
||||
case vamp_protocol::page_command::PREVIOUS:
|
||||
return "PREV";
|
||||
case vamp_protocol::page_command::NEXT:
|
||||
return "NEXT";
|
||||
case vamp_protocol::page_command::SELECT:
|
||||
return "SELECT";
|
||||
}
|
||||
return "NEXT";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#include <vamp_style.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
auto vamp_style::rgb565(vamp_style::color value) -> std::uint16_t {
|
||||
auto r = static_cast<std::uint16_t>((value.r >> 3U) & 0x1fU);
|
||||
auto g = static_cast<std::uint16_t>((value.g >> 2U) & 0x3fU);
|
||||
auto b = static_cast<std::uint16_t>((value.b >> 3U) & 0x1fU);
|
||||
return static_cast<std::uint16_t>((r << 11U) | (g << 5U) | b);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
#include <vamp_zero/app.hpp>
|
||||
|
||||
#include <csignal>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/signal_set.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/serialize.hpp>
|
||||
|
||||
#include <ligo.hpp>
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/ui.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid(std::string message) -> modulus::error {
|
||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
||||
}
|
||||
|
||||
auto usage() -> std::string {
|
||||
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--migrate]";
|
||||
}
|
||||
|
||||
auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
|
||||
auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint);
|
||||
if (!endpoint) {
|
||||
return 8080;
|
||||
}
|
||||
return endpoint->port == 0 ? 8080 : endpoint->port;
|
||||
}
|
||||
|
||||
auto write_text_atomic(std::filesystem::path const& destination, std::string const& text) -> std::expected<void, modulus::error> {
|
||||
auto error = std::error_code{};
|
||||
if (!destination.parent_path().empty()) {
|
||||
std::filesystem::create_directories(destination.parent_path(), error);
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create runtime directory: " + error.message()));
|
||||
}
|
||||
}
|
||||
auto temporary = destination;
|
||||
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()));
|
||||
}
|
||||
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::rename(temporary, destination, error);
|
||||
if (error) {
|
||||
std::filesystem::remove(temporary, error);
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to publish heartbeat: " + error.message()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto write_heartbeat(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
|
||||
auto heartbeat = boost::json::object{};
|
||||
heartbeat["version"] = VAMP_VERSION;
|
||||
heartbeat["git_sha"] = VAMP_GIT_SHA;
|
||||
heartbeat["status"] = "ok";
|
||||
heartbeat["endpoint"] = "http://" + config.control.zero_ip + ":" + std::to_string(bind_port(config));
|
||||
return write_text_atomic(config.runtime.heartbeat_path, boost::json::serialize(heartbeat));
|
||||
}
|
||||
|
||||
auto migrate(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_migrate_database(io.get_executor(), config.database), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto make_server_endpoint(vamp_zero::config const& config) -> std::expected<ligo::http::endpoint, 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;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
auto import_library(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto load_initial_state(vamp_zero::config const& config) -> std::expected<vamp_zero::app_state, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
io.run();
|
||||
auto performance = performance_future.get();
|
||||
if (!performance) {
|
||||
return std::unexpected(performance.error());
|
||||
}
|
||||
auto practice = practice_future.get();
|
||||
if (!practice) {
|
||||
return std::unexpected(practice.error());
|
||||
}
|
||||
auto state = vamp_zero::app_state{
|
||||
.performance = std::move(*performance),
|
||||
.practice = std::move(*practice),
|
||||
};
|
||||
if (auto valid = vamp_zero::validate_app_state(state); !valid) {
|
||||
return std::unexpected(valid.error());
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& state) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto endpoint = make_server_endpoint(config);
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto server = ligo::http::server{io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)};
|
||||
auto started = server.start();
|
||||
if (!started) {
|
||||
return std::unexpected(started.error());
|
||||
}
|
||||
auto heartbeat = write_heartbeat(config);
|
||||
if (!heartbeat) {
|
||||
server.stop();
|
||||
return std::unexpected(heartbeat.error());
|
||||
}
|
||||
auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM};
|
||||
signals.async_wait([&](auto const&, int) {
|
||||
server.stop();
|
||||
io.stop();
|
||||
});
|
||||
std::cout << "vamp-zero listening on " << server.selected_port() << '\n';
|
||||
io.run();
|
||||
return {};
|
||||
}
|
||||
|
||||
auto run_display(vamp_zero::config const& config, vamp_zero::control_state& state) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto endpoint = make_server_endpoint(config);
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display};
|
||||
state.set_observer([&ui, &config](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
|
||||
if (!status) {
|
||||
modulus::log_error("failed to publish UI state: " + status.error().describe());
|
||||
}
|
||||
});
|
||||
auto published = ui.publish(vamp_zero::make_ui_state(config, state.snapshot()));
|
||||
if (!published) {
|
||||
return std::unexpected(published.error());
|
||||
}
|
||||
auto server = ligo::http::server{io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)};
|
||||
auto started = server.start();
|
||||
if (!started) {
|
||||
return std::unexpected(started.error());
|
||||
}
|
||||
auto heartbeat = write_heartbeat(config);
|
||||
if (!heartbeat) {
|
||||
server.stop();
|
||||
return std::unexpected(heartbeat.error());
|
||||
}
|
||||
auto connection_timer = boost::asio::steady_timer{io};
|
||||
auto schedule_connection_refresh = std::function<void()>{};
|
||||
schedule_connection_refresh = [&] {
|
||||
connection_timer.expires_after(std::chrono::milliseconds{500});
|
||||
connection_timer.async_wait([&](auto const& error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
static_cast<void>(state.refresh_connection());
|
||||
schedule_connection_refresh();
|
||||
});
|
||||
};
|
||||
schedule_connection_refresh();
|
||||
auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM};
|
||||
signals.async_wait([&](auto const&, int) {
|
||||
connection_timer.cancel();
|
||||
server.stop();
|
||||
ui.stop();
|
||||
io.stop();
|
||||
});
|
||||
auto io_thread = std::thread{[&io] {
|
||||
io.run();
|
||||
}};
|
||||
auto ui_status = ui.run();
|
||||
server.stop();
|
||||
connection_timer.cancel();
|
||||
io.stop();
|
||||
if (io_thread.joinable()) {
|
||||
io_thread.join();
|
||||
}
|
||||
return ui_status;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected<vamp_zero::command_line, modulus::error> {
|
||||
if (argc < 1 || argv == nullptr) {
|
||||
return std::unexpected(invalid("process arguments are invalid"));
|
||||
}
|
||||
auto result = vamp_zero::command_line{};
|
||||
auto require_value = [&](std::int32_t& index, std::string_view option) -> std::expected<std::string_view, modulus::error> {
|
||||
++index;
|
||||
if (index >= argc) {
|
||||
return std::unexpected(invalid(std::string{option} + " requires a value"));
|
||||
}
|
||||
auto value = std::string_view{argv[index]};
|
||||
if (value.empty()) {
|
||||
return std::unexpected(invalid(std::string{option} + " value must not be empty"));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
for (auto index = std::int32_t{1}; index < argc; ++index) {
|
||||
auto argument = std::string_view{argv[index]};
|
||||
if (argument == "--config") {
|
||||
auto value = require_value(index, "--config");
|
||||
if (!value) {
|
||||
return std::unexpected(value.error());
|
||||
}
|
||||
result.config_path = *value;
|
||||
} else if (argument == "--check-config") {
|
||||
result.check_config = true;
|
||||
} else if (argument == "--health-check") {
|
||||
result.health_check = true;
|
||||
} else if (argument == "--version") {
|
||||
result.print_version = true;
|
||||
} else if (argument == "--once") {
|
||||
result.exit_after_startup = true;
|
||||
} else if (argument == "--migrate") {
|
||||
result.migrate = true;
|
||||
} else if (argument == "--help" || argument == "-h") {
|
||||
return std::unexpected(invalid(usage()));
|
||||
} else {
|
||||
return std::unexpected(invalid("unknown argument: " + std::string{argument}));
|
||||
}
|
||||
}
|
||||
auto command_count = static_cast<std::uint32_t>(result.check_config ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.health_check ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.print_version ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.migrate ? 1 : 0);
|
||||
if (command_count > 1U) {
|
||||
return std::unexpected(invalid("only one command flag may be selected"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modulus::error> {
|
||||
if (command.print_version) {
|
||||
std::cout << VAMP_VERSION << " " << VAMP_GIT_SHA << '\n';
|
||||
return {};
|
||||
}
|
||||
auto config = vamp_zero::load_config(command.config_path);
|
||||
if (!config) {
|
||||
return std::unexpected(config.error());
|
||||
}
|
||||
modulus::configure(config->modulus);
|
||||
if (command.check_config) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.health_check) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.migrate) {
|
||||
return migrate(*config);
|
||||
}
|
||||
if (command.exit_after_startup || config->runtime.exit_after_startup) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
auto migrated = migrate(*config);
|
||||
if (!migrated) {
|
||||
return migrated;
|
||||
}
|
||||
auto imported = import_library(*config);
|
||||
if (!imported) {
|
||||
return imported;
|
||||
}
|
||||
auto initial = load_initial_state(*config);
|
||||
if (!initial) {
|
||||
return std::unexpected(initial.error());
|
||||
}
|
||||
auto state = vamp_zero::control_state{*config, std::move(*initial)};
|
||||
if (config->runtime.headless) {
|
||||
return run_headless(*config, state);
|
||||
}
|
||||
return run_display(*config, state);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#include <vamp_zero/color.hpp>
|
||||
|
||||
auto vamp_zero::to_luma(vamp_style::color value) -> luma::color {
|
||||
return {
|
||||
.r = value.r,
|
||||
.g = value.g,
|
||||
.b = value.b,
|
||||
.a = value.a,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#include <vamp_zero/config.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/parse.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto json_options = modulus::json_config::options{
|
||||
.category = "product-vamp.config",
|
||||
.invalid_argument_code = static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT),
|
||||
.not_found_code = static_cast<std::int32_t>(vamp_zero::error_code::NOT_FOUND),
|
||||
};
|
||||
|
||||
auto invalid(std::string message) -> modulus::error {
|
||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
||||
}
|
||||
|
||||
auto require_object(boost::json::value const& value, std::string_view name) -> std::expected<boost::json::object const*, modulus::error> {
|
||||
if (!value.is_object()) {
|
||||
return std::unexpected(invalid(std::string{name} + " must be an object"));
|
||||
}
|
||||
return &value.as_object();
|
||||
}
|
||||
|
||||
auto optional_object(boost::json::object const& object, std::string_view field) -> std::expected<boost::json::object const*, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
return require_object(found->value(), field);
|
||||
}
|
||||
|
||||
auto optional_string(boost::json::object const& object, std::string_view field, std::string fallback) -> std::expected<std::string, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return fallback;
|
||||
}
|
||||
if (!found->value().is_string()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be a string"));
|
||||
}
|
||||
auto value = std::string{found->value().as_string()};
|
||||
if (value.empty()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must not be empty"));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
auto optional_bool(boost::json::object const& object, std::string_view field, std::bool_t fallback) -> std::expected<std::bool_t, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return fallback;
|
||||
}
|
||||
if (!found->value().is_bool()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be a boolean"));
|
||||
}
|
||||
return found->value().as_bool();
|
||||
}
|
||||
|
||||
auto parse_hardware(std::string const& value) -> std::expected<vamp_zero::hardware_mode, modulus::error> {
|
||||
if (value == "fake") {
|
||||
return vamp_zero::hardware_mode::FAKE;
|
||||
}
|
||||
if (value == "real") {
|
||||
return vamp_zero::hardware_mode::REAL;
|
||||
}
|
||||
return std::unexpected(invalid("runtime.hardware must be fake or real"));
|
||||
}
|
||||
|
||||
auto parse_runtime(boost::json::object const& root, vamp_zero::runtime_config fallback) -> std::expected<vamp_zero::runtime_config, modulus::error> {
|
||||
auto object = optional_object(root, "runtime");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
if (*object == nullptr) {
|
||||
return fallback;
|
||||
}
|
||||
auto hardware_text = optional_string(**object, "hardware", vamp_zero::to_string(fallback.hardware));
|
||||
auto headless = optional_bool(**object, "headless", fallback.headless);
|
||||
auto exit_after_startup = optional_bool(**object, "exit_after_startup", fallback.exit_after_startup);
|
||||
auto assets_path = optional_string(**object, "assets_path", fallback.assets_path);
|
||||
auto heartbeat_path = optional_string(**object, "heartbeat_path", fallback.heartbeat_path);
|
||||
if (!hardware_text || !headless || !exit_after_startup || !assets_path || !heartbeat_path) {
|
||||
return std::unexpected(!hardware_text ? hardware_text.error() : (!headless ? headless.error() : (!exit_after_startup ? exit_after_startup.error() : (!assets_path ? assets_path.error() : heartbeat_path.error()))));
|
||||
}
|
||||
auto hardware = parse_hardware(*hardware_text);
|
||||
if (!hardware) {
|
||||
return std::unexpected(hardware.error());
|
||||
}
|
||||
return vamp_zero::runtime_config{
|
||||
.hardware = *hardware,
|
||||
.headless = *headless,
|
||||
.exit_after_startup = *exit_after_startup,
|
||||
.assets_path = std::move(*assets_path),
|
||||
.heartbeat_path = std::move(*heartbeat_path),
|
||||
};
|
||||
}
|
||||
|
||||
auto parse_database(boost::json::object const& root, vamp_zero::database_config fallback) -> std::expected<vamp_zero::database_config, modulus::error> {
|
||||
auto object = optional_object(root, "database");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
if (*object == nullptr) {
|
||||
return fallback;
|
||||
}
|
||||
auto path = optional_string(**object, "path", fallback.path);
|
||||
auto migrations_path = optional_string(**object, "migrations_path", fallback.migrations_path);
|
||||
if (!path || !migrations_path) {
|
||||
return std::unexpected(!path ? path.error() : migrations_path.error());
|
||||
}
|
||||
return vamp_zero::database_config{
|
||||
.path = std::move(*path),
|
||||
.migrations_path = std::move(*migrations_path),
|
||||
};
|
||||
}
|
||||
|
||||
auto parse_control(boost::json::object const& root, vamp_zero::control_config fallback) -> std::expected<vamp_zero::control_config, modulus::error> {
|
||||
auto object = optional_object(root, "control");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
if (*object == nullptr) {
|
||||
return fallback;
|
||||
}
|
||||
auto result = fallback;
|
||||
if (auto found = (*object)->find("endpoint"); found != (*object)->end()) {
|
||||
auto endpoint = ligo::parse_endpoint_config(found->value());
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
if (endpoint->protocol != ligo::protocol::HTTP || endpoint->bind.empty()) {
|
||||
return std::unexpected(invalid("control.endpoint must be an HTTP bind endpoint"));
|
||||
}
|
||||
result.endpoint = std::move(*endpoint);
|
||||
}
|
||||
auto ap_ssid = optional_string(**object, "ap_ssid", result.ap_ssid);
|
||||
auto ap_passphrase = optional_string(**object, "ap_passphrase", result.ap_passphrase);
|
||||
auto ap_interface = optional_string(**object, "ap_interface", result.ap_interface);
|
||||
auto zero_ip = optional_string(**object, "zero_ip", result.zero_ip);
|
||||
auto zero_cidr = optional_string(**object, "zero_cidr", result.zero_cidr);
|
||||
if (!ap_ssid || !ap_passphrase || !ap_interface || !zero_ip || !zero_cidr) {
|
||||
return std::unexpected(!ap_ssid ? ap_ssid.error() : (!ap_passphrase ? ap_passphrase.error() : (!ap_interface ? ap_interface.error() : (!zero_ip ? zero_ip.error() : zero_cidr.error()))));
|
||||
}
|
||||
if (ap_passphrase->size() < 8U) {
|
||||
return std::unexpected(invalid("control.ap_passphrase must be at least 8 characters"));
|
||||
}
|
||||
result.ap_ssid = std::move(*ap_ssid);
|
||||
result.ap_passphrase = std::move(*ap_passphrase);
|
||||
result.ap_interface = std::move(*ap_interface);
|
||||
result.zero_ip = std::move(*zero_ip);
|
||||
result.zero_cidr = std::move(*zero_cidr);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::parse_config(boost::json::value const& value) -> std::expected<vamp_zero::config, modulus::error> {
|
||||
auto root = require_object(value, "Product Vamp config root");
|
||||
if (!root) {
|
||||
return std::unexpected(root.error());
|
||||
}
|
||||
auto result = vamp_zero::config{};
|
||||
if (auto found = (*root)->find("modulus"); found != (*root)->end()) {
|
||||
auto parsed = modulus::parse_config(found->value());
|
||||
if (!parsed) {
|
||||
return std::unexpected(parsed.error());
|
||||
}
|
||||
result.modulus = *parsed;
|
||||
}
|
||||
auto runtime = parse_runtime(**root, result.runtime);
|
||||
auto database = parse_database(**root, result.database);
|
||||
auto control = parse_control(**root, result.control);
|
||||
if (!runtime || !database || !control) {
|
||||
return std::unexpected(!runtime ? runtime.error() : (!database ? database.error() : control.error()));
|
||||
}
|
||||
result.runtime = std::move(*runtime);
|
||||
result.database = std::move(*database);
|
||||
result.control = std::move(*control);
|
||||
if (auto found = (*root)->find("display"); found != (*root)->end()) {
|
||||
auto display = luma::parse_application_config(found->value());
|
||||
if (!display) {
|
||||
return std::unexpected(display.error());
|
||||
}
|
||||
result.display = std::move(*display);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto vamp_zero::load_config(std::string const& path) -> std::expected<vamp_zero::config, modulus::error> {
|
||||
auto value = modulus::json_config::load_value(path, json_options);
|
||||
if (!value) {
|
||||
return std::unexpected(value.error());
|
||||
}
|
||||
return vamp_zero::parse_config(*value);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
#include <vamp_zero/database.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
|
||||
#include <noto.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto application_id = std::int64_t{0x56414D50};
|
||||
constexpr auto statement_delimiter = std::string_view{"-- vamp-statement"};
|
||||
|
||||
auto trim(std::string value) -> std::string {
|
||||
auto first = value.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
auto last = value.find_last_not_of(" \t\r\n");
|
||||
return value.substr(first, last - first + 1U);
|
||||
}
|
||||
|
||||
auto read_text(std::string const& path) -> std::expected<std::string, 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 migration: " + path));
|
||||
}
|
||||
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
if (!input.good() && !input.eof()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read migration: " + path));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
auto execute_statements(noto::connection& connection, std::vector<std::string> statements) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
for (auto& statement : statements) {
|
||||
auto result = co_await connection.async_execute(std::move(statement));
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto connection_config(vamp_zero::database_config const& config) -> noto::connection_config {
|
||||
auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp") : noto::file_connection_config(config.path, "product-vamp");
|
||||
result.busy_timeout = std::chrono::seconds{5};
|
||||
result.foreign_keys = true;
|
||||
result.pragmas = {
|
||||
{.name = "synchronous", .value = std::string{"NORMAL"}},
|
||||
{.name = "temp_store", .value = std::string{"MEMORY"}},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
auto read_migration(vamp_zero::database_config const& config, std::string filename) -> std::expected<std::vector<std::string>, modulus::error> {
|
||||
auto migration_text = read_text(config.migrations_path + "/" + filename);
|
||||
if (!migration_text) {
|
||||
return std::unexpected(migration_text.error());
|
||||
}
|
||||
return vamp_zero::split_migration_statements(*migration_text);
|
||||
}
|
||||
|
||||
auto text_value(noto::sqlite_value const& value) -> std::string {
|
||||
auto text = std::get_if<std::string>(&value.value);
|
||||
return text == nullptr ? std::string{} : *text;
|
||||
}
|
||||
|
||||
auto path_parts(std::filesystem::path const& path) -> std::vector<std::string> {
|
||||
auto parts = std::vector<std::string>{};
|
||||
for (auto const& part : path) {
|
||||
parts.push_back(part.string());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
auto logical_practice_exercise_id(std::string const& type_id, std::string const& lesson_id, std::string const& source_path, std::string const& fallback) -> std::string {
|
||||
auto parts = path_parts(std::filesystem::path{source_path});
|
||||
for (auto index = std::size_t{}; index + 3U < parts.size(); ++index) {
|
||||
if (parts[index] == "practice" && parts[index + 1U] == type_id && parts[index + 2U] == lesson_id) {
|
||||
return type_id + "-" + lesson_id + "-" + parts[index + 3U];
|
||||
}
|
||||
}
|
||||
auto stem = std::filesystem::path{source_path}.parent_path().filename().string();
|
||||
if (stem.empty()) {
|
||||
stem = fallback;
|
||||
}
|
||||
return type_id + "-" + lesson_id + "-" + stem;
|
||||
}
|
||||
|
||||
auto now_ms() -> std::int64_t {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error> {
|
||||
auto statements = std::vector<std::string>{};
|
||||
auto current = std::string{};
|
||||
auto stream = std::istringstream{input};
|
||||
auto line = std::string{};
|
||||
while (std::getline(stream, line)) {
|
||||
if (trim(line) == statement_delimiter) {
|
||||
current = trim(std::move(current));
|
||||
if (current.empty()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration contains an empty statement"));
|
||||
}
|
||||
statements.push_back(std::move(current));
|
||||
current.clear();
|
||||
continue;
|
||||
}
|
||||
current += line;
|
||||
current.push_back('\n');
|
||||
}
|
||||
current = trim(std::move(current));
|
||||
if (!current.empty()) {
|
||||
statements.push_back(std::move(current));
|
||||
}
|
||||
if (statements.empty()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration must contain at least one statement"));
|
||||
}
|
||||
for (auto const& statement : statements) {
|
||||
if (statement.empty() || statement.back() != ';') {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "every migration statement must end with a semicolon"));
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (config.path.empty() || config.migrations_path.empty()) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database paths must not be empty"));
|
||||
}
|
||||
if (config.path != ":memory:") {
|
||||
auto parent = std::filesystem::path{config.path}.parent_path();
|
||||
if (!parent.empty()) {
|
||||
auto error = std::error_code{};
|
||||
std::filesystem::create_directories(parent, error);
|
||||
if (error) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create database directory: " + error.message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
if (config.path != ":memory:") {
|
||||
auto journal = co_await connection.async_journal_mode("WAL");
|
||||
if (!journal || *journal != "wal") {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after WAL error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(journal ? vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "SQLite did not enter WAL mode") : journal.error());
|
||||
}
|
||||
}
|
||||
auto current_application_id = co_await connection.async_application_id();
|
||||
if (!current_application_id) {
|
||||
co_return std::unexpected(current_application_id.error());
|
||||
}
|
||||
if (*current_application_id != 0 && *current_application_id != application_id) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "database application id does not belong to Product Vamp"));
|
||||
}
|
||||
if (*current_application_id == 0) {
|
||||
auto set = co_await connection.async_set_application_id(application_id);
|
||||
if (!set) {
|
||||
co_return std::unexpected(set.error());
|
||||
}
|
||||
}
|
||||
|
||||
auto foundation_statements = read_migration(config, "001_foundation.sql");
|
||||
if (!foundation_statements) {
|
||||
co_return std::unexpected(foundation_statements.error());
|
||||
}
|
||||
auto import_statements = read_migration(config, "002_song_import_metadata.sql");
|
||||
if (!import_statements) {
|
||||
co_return std::unexpected(import_statements.error());
|
||||
}
|
||||
auto practice_statements = read_migration(config, "003_practice_catalog.sql");
|
||||
if (!practice_statements) {
|
||||
co_return std::unexpected(practice_statements.error());
|
||||
}
|
||||
auto practice_hierarchy_statements = read_migration(config, "004_practice_hierarchy.sql");
|
||||
if (!practice_hierarchy_statements) {
|
||||
co_return std::unexpected(practice_hierarchy_statements.error());
|
||||
}
|
||||
auto migrator = noto::migrator{connection};
|
||||
auto migrations = std::vector<noto::migration>{};
|
||||
auto foundation = noto::migration{};
|
||||
foundation.id = "001_foundation";
|
||||
foundation.description = "initial Product Vamp appliance tables";
|
||||
foundation.checksum = "001_foundation_v1";
|
||||
foundation.up = [statements = std::move(*foundation_statements)](noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(foundation));
|
||||
auto import = noto::migration{};
|
||||
import.id = "002_song_import_metadata";
|
||||
import.description = "song import metadata";
|
||||
import.checksum = "002_song_import_metadata_v1";
|
||||
import.up = [statements = std::move(*import_statements)](noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(import));
|
||||
auto practice = noto::migration{};
|
||||
practice.id = "003_practice_catalog";
|
||||
practice.description = "practice exercise catalog";
|
||||
practice.checksum = "003_practice_catalog_v1";
|
||||
practice.up = [statements = std::move(*practice_statements)](noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(practice));
|
||||
auto practice_hierarchy = noto::migration{};
|
||||
practice_hierarchy.id = "004_practice_hierarchy";
|
||||
practice_hierarchy.description = "practice type and lesson hierarchy";
|
||||
practice_hierarchy.checksum = "004_practice_hierarchy_v1";
|
||||
practice_hierarchy.up = [statements = std::move(*practice_hierarchy_statements)](noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(practice_hierarchy));
|
||||
|
||||
auto migrated = co_await migrator.async_migrate(std::move(migrations));
|
||||
if (!migrated) {
|
||||
co_return std::unexpected(migrated.error());
|
||||
}
|
||||
auto version = co_await connection.async_set_user_version(4);
|
||||
if (!version) {
|
||||
co_return std::unexpected(version.error());
|
||||
}
|
||||
co_return co_await connection.async_close();
|
||||
}
|
||||
|
||||
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);
|
||||
if (!performance) {
|
||||
co_return std::unexpected(performance.error());
|
||||
}
|
||||
auto practice = vamp_zero::load_practice_library(assets_path);
|
||||
if (!practice) {
|
||||
co_return std::unexpected(practice.error());
|
||||
}
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
|
||||
if (!begin) {
|
||||
co_return std::unexpected(begin.error());
|
||||
}
|
||||
auto imported = std::expected<void, modulus::error>{};
|
||||
auto cleared = co_await connection.async_execute("DELETE FROM songs");
|
||||
if (!cleared) {
|
||||
imported = std::unexpected(cleared.error());
|
||||
}
|
||||
if (imported) {
|
||||
auto cleared_practice = co_await connection.async_execute("DELETE FROM practice_exercises");
|
||||
if (!cleared_practice) {
|
||||
imported = std::unexpected(cleared_practice.error());
|
||||
}
|
||||
}
|
||||
auto timestamp = now_ms();
|
||||
for (auto const& part : performance->parts) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{part.id},
|
||||
noto::sqlite_value{part.title},
|
||||
noto::sqlite_value{part.composer},
|
||||
noto::sqlite_value{part.source_json},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{part.part},
|
||||
noto::sqlite_value{part.source_path},
|
||||
noto::sqlite_value{part.key_signature},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
}
|
||||
}
|
||||
for (auto const& exercise : practice->exercises) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
for (auto const& variant : exercise.variants) {
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{variant.id},
|
||||
noto::sqlite_value{exercise.practice_type_id},
|
||||
noto::sqlite_value{exercise.practice_type_title},
|
||||
noto::sqlite_value{exercise.lesson_group_id},
|
||||
noto::sqlite_value{exercise.lesson_group_title},
|
||||
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{variant.source_path},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{timestamp},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute("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) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto committed = co_await connection.async_execute("COMMIT");
|
||||
if (!committed) {
|
||||
imported = std::unexpected(committed.error());
|
||||
}
|
||||
} else {
|
||||
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
|
||||
}
|
||||
if (!imported) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after import error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(imported.error());
|
||||
}
|
||||
co_return co_await connection.async_close();
|
||||
}
|
||||
|
||||
auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>> {
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected = co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after load error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
auto parts = std::vector<vamp_zero::library_part>{};
|
||||
for (auto const& row : selected->rows) {
|
||||
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) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after parse error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(parsed.error());
|
||||
}
|
||||
parts.push_back({
|
||||
.id = text_value(row[0]),
|
||||
.title = text_value(row[1]),
|
||||
.composer = text_value(row[2]),
|
||||
.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->measures.size(),
|
||||
.total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U,
|
||||
});
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
if (!closed) {
|
||||
co_return std::unexpected(closed.error());
|
||||
}
|
||||
co_return vamp_zero::group_library_parts(std::move(parts));
|
||||
}
|
||||
|
||||
auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>> {
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected = co_await connection.async_execute("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, lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after practice load error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
auto exercises = std::vector<vamp_zero::practice_exercise>{};
|
||||
for (auto const& row : selected->rows) {
|
||||
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) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after practice parse error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(parsed.error());
|
||||
}
|
||||
auto type_id = text_value(row[1]);
|
||||
auto lesson_id = text_value(row[3]);
|
||||
auto source_path = text_value(row[8]);
|
||||
auto key_signature = text_value(row[7]).empty() ? std::string{"C"} : text_value(row[7]);
|
||||
auto variant = vamp_zero::practice_exercise::score_variant{
|
||||
.id = text_value(row[0]),
|
||||
.key_signature = key_signature,
|
||||
.source_path = source_path,
|
||||
.source_json = source_json,
|
||||
.measure_count = parsed->measures.size(),
|
||||
.total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U,
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])),
|
||||
.practice_type_id = std::move(type_id),
|
||||
.practice_type_title = text_value(row[2]),
|
||||
.lesson_group_id = std::move(lesson_id),
|
||||
.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,
|
||||
.variants = {std::move(variant)},
|
||||
});
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
if (!closed) {
|
||||
co_return std::unexpected(closed.error());
|
||||
}
|
||||
co_return vamp_zero::group_practice_exercises(std::move(exercises));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
auto vamp_zero::make_error(vamp_zero::error_code code, std::string message, modulus::source_location location) -> modulus::error {
|
||||
return modulus::make_error("product-vamp", static_cast<std::int32_t>(code), std::move(message), location);
|
||||
}
|
||||
|
||||
auto vamp_zero::fail_void(vamp_zero::error_code code, std::string message, modulus::source_location location) -> std::expected<void, modulus::error> {
|
||||
return modulus::fail<void>(vamp_zero::make_error(code, std::move(message), location));
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
#include <vamp_zero/library.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json/array.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/parse.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid(std::string message) -> modulus::error {
|
||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
||||
}
|
||||
|
||||
auto required_string(boost::json::object const& object, std::char_t const* field) -> std::expected<std::string, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end() || !found->value().is_string()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be a string"));
|
||||
}
|
||||
auto value = std::string{found->value().as_string()};
|
||||
if (value.empty()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must not be empty"));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
auto optional_string_value(boost::json::object const& object, std::char_t const* field, std::string fallback = {}) -> std::expected<std::string, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return fallback;
|
||||
}
|
||||
if (!found->value().is_string()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be a string"));
|
||||
}
|
||||
return std::string{found->value().as_string()};
|
||||
}
|
||||
|
||||
auto optional_bool_value(boost::json::object const& object, std::char_t const* field, std::bool_t fallback = false) -> std::expected<std::bool_t, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return fallback;
|
||||
}
|
||||
if (!found->value().is_bool()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be a boolean"));
|
||||
}
|
||||
return found->value().as_bool();
|
||||
}
|
||||
|
||||
auto optional_int64_value(boost::json::object const& object, std::char_t const* field, std::int64_t fallback = 0) -> std::expected<std::int64_t, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end()) {
|
||||
return fallback;
|
||||
}
|
||||
if (found->value().is_int64()) {
|
||||
return found->value().as_int64();
|
||||
}
|
||||
if (found->value().is_uint64()) {
|
||||
auto value = found->value().as_uint64();
|
||||
if (value <= static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
|
||||
return static_cast<std::int64_t>(value);
|
||||
}
|
||||
}
|
||||
return std::unexpected(invalid(std::string{field} + " must be an integer"));
|
||||
}
|
||||
|
||||
auto require_object(boost::json::value const& value, std::string_view label) -> std::expected<boost::json::object const*, modulus::error> {
|
||||
if (!value.is_object()) {
|
||||
return std::unexpected(invalid(std::string{label} + " must be an object"));
|
||||
}
|
||||
return &value.as_object();
|
||||
}
|
||||
|
||||
auto require_array(boost::json::object const& object, std::char_t const* field) -> std::expected<boost::json::array const*, modulus::error> {
|
||||
auto found = object.find(field);
|
||||
if (found == object.end() || !found->value().is_array()) {
|
||||
return std::unexpected(invalid(std::string{field} + " must be an array"));
|
||||
}
|
||||
return &found->value().as_array();
|
||||
}
|
||||
|
||||
auto human_label(std::string value) -> std::string {
|
||||
if (value.empty()) {
|
||||
return "Score";
|
||||
}
|
||||
auto capitalize_next = true;
|
||||
for (auto& ch : value) {
|
||||
if (ch == '-' || ch == '_') {
|
||||
ch = ' ';
|
||||
capitalize_next = true;
|
||||
continue;
|
||||
}
|
||||
if (capitalize_next && ch >= 'a' && ch <= 'z') {
|
||||
ch = static_cast<std::char_t>(ch - ('a' - 'A'));
|
||||
}
|
||||
capitalize_next = false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
auto practice_type_label(std::string const& value) -> std::string {
|
||||
return human_label(value) + " Practice";
|
||||
}
|
||||
|
||||
auto generated_source_id(std::string const& source_path, std::string const& title, std::string const& part) -> std::string {
|
||||
if (!source_path.empty()) {
|
||||
auto stem = std::filesystem::path{source_path}.stem().string();
|
||||
if (!stem.empty()) {
|
||||
return stem;
|
||||
}
|
||||
}
|
||||
auto value = title + "-" + part;
|
||||
for (auto& ch : value) {
|
||||
if (ch >= 'A' && ch <= 'Z') {
|
||||
ch = static_cast<std::char_t>(ch + ('a' - 'A'));
|
||||
} else if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))) {
|
||||
ch = '-';
|
||||
}
|
||||
}
|
||||
return value.empty() ? "score" : value;
|
||||
}
|
||||
|
||||
auto parse_pitch(boost::json::value const& value) -> std::expected<vamp_zero::score_pitch, modulus::error> {
|
||||
auto object = require_object(value, "pitch");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
auto letter = optional_string_value(**object, "letter", "c");
|
||||
auto octave = optional_int64_value(**object, "octave", 4);
|
||||
auto accidental = optional_string_value(**object, "accidental", {});
|
||||
if (!letter || !octave || !accidental) {
|
||||
return std::unexpected(!letter ? letter.error() : (!octave ? octave.error() : accidental.error()));
|
||||
}
|
||||
return vamp_zero::score_pitch{
|
||||
.letter = letter->empty() ? 'c' : (*letter)[0],
|
||||
.octave = static_cast<std::int32_t>(*octave),
|
||||
.accidental = std::move(*accidental),
|
||||
};
|
||||
}
|
||||
|
||||
auto parse_note(boost::json::value const& value) -> std::expected<vamp_zero::score_note, modulus::error> {
|
||||
auto object = require_object(value, "note");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
auto id = optional_string_value(**object, "id", {});
|
||||
auto kind = optional_string_value(**object, "kind", "note");
|
||||
auto hidden = optional_bool_value(**object, "hidden", false);
|
||||
auto duration = optional_int64_value(**object, "duration_ticks", 0);
|
||||
auto display = std::expected<std::int64_t, modulus::error>{0};
|
||||
if (duration) {
|
||||
display = optional_int64_value(**object, "display_ticks", *duration);
|
||||
}
|
||||
auto notehead = optional_string_value(**object, "notehead", {});
|
||||
auto chord = optional_string_value(**object, "chord_symbol", {});
|
||||
auto lyric = optional_string_value(**object, "lyric", {});
|
||||
auto tie_to_next = optional_bool_value(**object, "tie_to_next", false);
|
||||
auto tie_from_previous = optional_bool_value(**object, "tie_from_previous", false);
|
||||
if (!id || !kind || !hidden || !duration || !display || !notehead || !chord || !lyric || !tie_to_next || !tie_from_previous) {
|
||||
return std::unexpected(!id ? id.error() : (!kind ? kind.error() : (!hidden ? hidden.error() : (!duration ? duration.error() : (!display ? display.error() : (!notehead ? notehead.error() : (!chord ? chord.error() : (!lyric ? lyric.error() : (!tie_to_next ? tie_to_next.error() : tie_from_previous.error())))))))));
|
||||
}
|
||||
auto note = vamp_zero::score_note{
|
||||
.id = std::move(*id),
|
||||
.kind = std::move(*kind),
|
||||
.hidden = *hidden,
|
||||
.duration_ticks = *duration,
|
||||
.display_ticks = *display,
|
||||
.notehead = std::move(*notehead),
|
||||
.chord_symbol = std::move(*chord),
|
||||
.lyric = std::move(*lyric),
|
||||
.tie_to_next = *tie_to_next,
|
||||
.tie_from_previous = *tie_from_previous,
|
||||
};
|
||||
if (auto found = (*object)->find("pitches"); found != (*object)->end()) {
|
||||
if (!found->value().is_array()) {
|
||||
return std::unexpected(invalid("pitches must be an array"));
|
||||
}
|
||||
for (auto const& pitch_value : found->value().as_array()) {
|
||||
auto pitch = parse_pitch(pitch_value);
|
||||
if (!pitch) {
|
||||
return std::unexpected(pitch.error());
|
||||
}
|
||||
note.pitches.push_back(std::move(*pitch));
|
||||
}
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
auto parse_measure(boost::json::value const& value) -> std::expected<vamp_zero::score_measure, modulus::error> {
|
||||
auto object = require_object(value, "measure");
|
||||
if (!object) {
|
||||
return std::unexpected(object.error());
|
||||
}
|
||||
auto id = optional_string_value(**object, "id", {});
|
||||
auto section = optional_string_value(**object, "section_label", {});
|
||||
auto time = optional_string_value(**object, "time_signature", {});
|
||||
auto key = optional_string_value(**object, "key_signature", {});
|
||||
if (!id || !section || !time || !key) {
|
||||
return std::unexpected(!id ? id.error() : (!section ? section.error() : (!time ? time.error() : key.error())));
|
||||
}
|
||||
auto measure = vamp_zero::score_measure{
|
||||
.id = std::move(*id),
|
||||
.section_label = std::move(*section),
|
||||
.time_signature = std::move(*time),
|
||||
.key_signature = std::move(*key),
|
||||
};
|
||||
auto voices = require_array(**object, "voices");
|
||||
if (!voices) {
|
||||
return std::unexpected(voices.error());
|
||||
}
|
||||
for (auto const& voice_value : **voices) {
|
||||
auto voice = require_object(voice_value, "voice");
|
||||
if (!voice) {
|
||||
return std::unexpected(voice.error());
|
||||
}
|
||||
auto notes = require_array(**voice, "notes");
|
||||
if (!notes) {
|
||||
return std::unexpected(notes.error());
|
||||
}
|
||||
for (auto const& note_value : **notes) {
|
||||
auto note = parse_note(note_value);
|
||||
if (!note) {
|
||||
return std::unexpected(note.error());
|
||||
}
|
||||
measure.notes.push_back(std::move(*note));
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
}
|
||||
|
||||
auto score_page_count(std::size_t measure_count) -> std::size_t {
|
||||
constexpr auto measures_per_page = std::size_t{12};
|
||||
return measure_count == 0U ? 1U : (measure_count + measures_per_page - 1U) / measures_per_page;
|
||||
}
|
||||
|
||||
auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected<std::vector<vamp_zero::score_document>, modulus::error> {
|
||||
auto error = std::error_code{};
|
||||
if (!std::filesystem::exists(directory, error)) {
|
||||
if (required) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + directory.string()));
|
||||
}
|
||||
return std::vector<vamp_zero::score_document>{};
|
||||
}
|
||||
|
||||
auto paths = std::vector<std::filesystem::path>{};
|
||||
for (auto const& entry : std::filesystem::directory_iterator{directory, error}) {
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message()));
|
||||
}
|
||||
if (!entry.is_regular_file() || entry.path().extension() != ".json") {
|
||||
continue;
|
||||
}
|
||||
paths.push_back(entry.path());
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
auto path_parts(std::filesystem::path const& path) -> std::vector<std::string> {
|
||||
auto parts = std::vector<std::string>{};
|
||||
for (auto const& part : path) {
|
||||
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 found = std::find(keys.begin(), keys.end(), key);
|
||||
return found == keys.end() ? keys.size() : static_cast<std::size_t>(std::distance(keys.begin(), found));
|
||||
}
|
||||
|
||||
auto sync_primary_variant(vamp_zero::practice_exercise& exercise) -> void {
|
||||
std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const& left, auto const& right) {
|
||||
auto left_rank = key_rank(left.key_signature);
|
||||
auto right_rank = key_rank(right.key_signature);
|
||||
if (left_rank != right_rank) {
|
||||
return left_rank < right_rank;
|
||||
}
|
||||
return left.id < right.id;
|
||||
});
|
||||
if (exercise.variants.empty()) {
|
||||
return;
|
||||
}
|
||||
auto const& primary = exercise.variants.front();
|
||||
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> {
|
||||
auto error = boost::system::error_code{};
|
||||
auto parsed = boost::json::parse(json, error);
|
||||
if (error) {
|
||||
return std::unexpected(invalid("score JSON is invalid: " + error.message()));
|
||||
}
|
||||
auto root = require_object(parsed, "score root");
|
||||
if (!root) {
|
||||
return std::unexpected(root.error());
|
||||
}
|
||||
auto metadata_value = (*root)->find("metadata");
|
||||
if (metadata_value == (*root)->end()) {
|
||||
return std::unexpected(invalid("score metadata is required"));
|
||||
}
|
||||
auto metadata = require_object(metadata_value->value(), "metadata");
|
||||
if (!metadata) {
|
||||
return std::unexpected(metadata.error());
|
||||
}
|
||||
auto title = required_string(**metadata, "title");
|
||||
auto composer = optional_string_value(**metadata, "composer", {});
|
||||
auto key = optional_string_value(**metadata, "key_signature", {});
|
||||
if (!title || !composer || !key) {
|
||||
return std::unexpected(!title ? title.error() : (!composer ? composer.error() : key.error()));
|
||||
}
|
||||
|
||||
auto staves = require_array(**root, "staves");
|
||||
if (!staves) {
|
||||
return std::unexpected(staves.error());
|
||||
}
|
||||
if ((*staves)->empty() || !(*staves)->front().is_object()) {
|
||||
return std::unexpected(invalid("score must contain at least one staff"));
|
||||
}
|
||||
auto const& staff = (*staves)->front().as_object();
|
||||
auto part = optional_string_value(staff, "arrangement_part_type", {});
|
||||
if (!part || part->empty()) {
|
||||
part = optional_string_value(staff, "role", {});
|
||||
}
|
||||
if (!part || part->empty()) {
|
||||
part = optional_string_value(staff, "name", "score");
|
||||
}
|
||||
if (!part) {
|
||||
return std::unexpected(part.error());
|
||||
}
|
||||
|
||||
auto measures = require_array(staff, "measures");
|
||||
if (!measures) {
|
||||
return std::unexpected(measures.error());
|
||||
}
|
||||
auto document = vamp_zero::score_document{
|
||||
.id = generated_source_id(source_path, *title, *part),
|
||||
.title = std::move(*title),
|
||||
.composer = std::move(*composer),
|
||||
.key_signature = std::move(*key),
|
||||
.part = human_label(*part),
|
||||
.arrangement_part_type = std::move(*part),
|
||||
.source_path = std::move(source_path),
|
||||
.source_json = std::string{json},
|
||||
};
|
||||
for (auto const& measure_value : **measures) {
|
||||
auto measure = parse_measure(measure_value);
|
||||
if (!measure) {
|
||||
return std::unexpected(measure.error());
|
||||
}
|
||||
document.measures.push_back(std::move(*measure));
|
||||
}
|
||||
return 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;
|
||||
});
|
||||
|
||||
auto groups = std::vector<vamp_zero::song_group>{};
|
||||
for (auto const& part : parts) {
|
||||
if (groups.empty() || groups.back().title != part.title || groups.back().composer != part.composer) {
|
||||
groups.push_back({
|
||||
.title = part.title,
|
||||
.composer = part.composer,
|
||||
});
|
||||
}
|
||||
groups.back().parts.push_back(part);
|
||||
}
|
||||
return vamp_zero::library_state{
|
||||
.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 {
|
||||
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;
|
||||
});
|
||||
|
||||
auto merged = std::vector<vamp_zero::practice_exercise>{};
|
||||
for (auto& exercise : exercises) {
|
||||
if (!merged.empty() && same_practice_exercise(merged.back(), exercise)) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
});
|
||||
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());
|
||||
}
|
||||
lesson->exercises.push_back(exercise);
|
||||
}
|
||||
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 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.measures.size(),
|
||||
.total_pages = score_page_count(document.measures.size()),
|
||||
});
|
||||
}
|
||||
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{};
|
||||
if (!std::filesystem::exists(root, error)) {
|
||||
return vamp_zero::practice_library{};
|
||||
}
|
||||
|
||||
auto paths = std::vector<std::filesystem::path>{};
|
||||
for (auto const& entry : std::filesystem::recursive_directory_iterator{root, error}) {
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan practice assets: " + error.message()));
|
||||
}
|
||||
if (!entry.is_regular_file() || entry.path().extension() != ".json") {
|
||||
continue;
|
||||
}
|
||||
paths.push_back(entry.path());
|
||||
}
|
||||
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());
|
||||
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->measures.size(),
|
||||
.total_pages = score_page_count(document->measures.size()),
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = std::move(exercise_id),
|
||||
.practice_type_id = type_id,
|
||||
.practice_type_title = practice_type_label(type_id),
|
||||
.lesson_group_id = lesson_id,
|
||||
.lesson_group_title = human_label(lesson_id),
|
||||
.title = document->title,
|
||||
.composer = document->composer,
|
||||
.key_signature = variant.key_signature,
|
||||
.source_path = variant.source_path,
|
||||
.source_json = variant.source_json,
|
||||
.measure_count = variant.measure_count,
|
||||
.total_pages = variant.total_pages,
|
||||
.variants = {std::move(variant)},
|
||||
});
|
||||
}
|
||||
return vamp_zero::group_practice_exercises(std::move(exercises));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#include <vamp_zero/app.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <utility>
|
||||
|
||||
auto main(std::int32_t argc, std::char_t** argv) -> std::int32_t {
|
||||
try {
|
||||
auto command = vamp_zero::parse_command_line(argc, argv);
|
||||
if (!command) {
|
||||
std::cerr << command.error().describe() << '\n';
|
||||
return 2;
|
||||
}
|
||||
auto status = vamp_zero::run(std::move(*command));
|
||||
if (!status) {
|
||||
std::cerr << status.error().describe() << '\n';
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} catch (std::exception const& error) {
|
||||
std::cerr << "vamp-zero: unhandled exception: " << error.what() << '\n';
|
||||
return 1;
|
||||
} catch (...) {
|
||||
std::cerr << "vamp-zero: unhandled unknown exception\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/json/array.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto main_menu_count = std::size_t{2};
|
||||
|
||||
auto wrap_next(std::size_t value, std::size_t count) -> std::size_t {
|
||||
if (count == 0U) {
|
||||
return 0U;
|
||||
}
|
||||
return (value + 1U) % count;
|
||||
}
|
||||
|
||||
auto wrap_previous(std::size_t value, std::size_t count) -> std::size_t {
|
||||
if (count == 0U) {
|
||||
return 0U;
|
||||
}
|
||||
return value == 0U ? count - 1U : value - 1U;
|
||||
}
|
||||
|
||||
auto menu_row_count(std::size_t item_count) -> std::size_t {
|
||||
return item_count + 1U;
|
||||
}
|
||||
|
||||
auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.performance.groups[index];
|
||||
}
|
||||
|
||||
auto selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
auto const* group = selected_group(state);
|
||||
if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_part_index - 1U;
|
||||
if (index >= group->parts.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &group->parts[index];
|
||||
}
|
||||
|
||||
auto 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) {
|
||||
return part.id == state.active_part_id;
|
||||
});
|
||||
return found == state.performance.parts.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto 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()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.practice.types[index];
|
||||
}
|
||||
|
||||
auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* {
|
||||
auto const* type = selected_practice_type(state);
|
||||
if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_lesson_index - 1U;
|
||||
if (index >= type->lesson_groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &type->lesson_groups[index];
|
||||
}
|
||||
|
||||
auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto const* lesson = selected_practice_lesson(state);
|
||||
if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_exercise_index - 1U;
|
||||
if (index >= lesson->exercises.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &lesson->exercises[index];
|
||||
}
|
||||
|
||||
auto 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 const& exercise) {
|
||||
return exercise.id == state.active_practice_id;
|
||||
});
|
||||
return found == state.practice.exercises.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t {
|
||||
return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id);
|
||||
}
|
||||
|
||||
auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* {
|
||||
if (exercise.variants.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!supports_key_selection(exercise)) {
|
||||
return &exercise.variants.front();
|
||||
}
|
||||
if (state.selected_practice_key_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_key_index - 1U;
|
||||
if (index >= exercise.variants.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &exercise.variants[index];
|
||||
}
|
||||
|
||||
auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view> {
|
||||
auto const* variant = selected_practice_variant(state, exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return variant->key_signature;
|
||||
}
|
||||
|
||||
auto practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t {
|
||||
auto const* exercise = selected_practice_exercise(state);
|
||||
if (exercise == nullptr || !supports_key_selection(*exercise)) {
|
||||
return 1U;
|
||||
}
|
||||
return menu_row_count(exercise->variants.size());
|
||||
}
|
||||
|
||||
auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string {
|
||||
if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) {
|
||||
return variant->key_signature;
|
||||
}
|
||||
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature;
|
||||
}
|
||||
|
||||
auto active_pages(vamp_zero::app_state const& state) -> std::size_t {
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const* part = active_part(state);
|
||||
return part == nullptr ? 0U : part->total_pages;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
auto const* variant = exercise == nullptr ? nullptr : selected_practice_variant(state, *exercise);
|
||||
return variant == nullptr ? 0U : variant->total_pages;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
auto practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode {
|
||||
if (selected_practice_lesson(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
}
|
||||
if (selected_practice_type(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
}
|
||||
return vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
}
|
||||
|
||||
auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
|
||||
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U);
|
||||
if (auto const* group = selected_group(state); group != nullptr) {
|
||||
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);
|
||||
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);
|
||||
} else {
|
||||
state.selected_practice_lesson_index = 0U;
|
||||
}
|
||||
if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) {
|
||||
state.selected_practice_exercise_index = std::min(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size()) - 1U);
|
||||
} else {
|
||||
state.selected_practice_exercise_index = 0U;
|
||||
}
|
||||
state.selected_practice_key_index = std::min(state.selected_practice_key_index, practice_key_row_count(state) - 1U);
|
||||
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU && selected_group(state) == nullptr) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU && selected_practice_type(state) == nullptr) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU && selected_practice_lesson(state) == nullptr) {
|
||||
state.mode = selected_practice_type(state) == nullptr ? vamp_zero::app_mode::PRACTICE_TYPE_MENU : vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
|
||||
auto const* exercise = selected_practice_exercise(state);
|
||||
if (exercise == nullptr) {
|
||||
state.mode = practice_fallback_menu(state);
|
||||
} else if (!supports_key_selection(*exercise) || exercise->variants.empty()) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state.selected_practice_key_index = 0U;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && active_part(state) == nullptr) {
|
||||
state.mode = selected_group(state) == nullptr ? vamp_zero::app_mode::PERFORMANCE_SONG_MENU : vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
if (exercise == nullptr) {
|
||||
state.mode = practice_fallback_menu(state);
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (selected_practice_variant(state, *exercise) == nullptr) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_KEY_MENU;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (auto pages = active_pages(state); pages != 0U) {
|
||||
state.page_index = std::min(state.page_index, pages - 1U);
|
||||
} else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && state.mode != vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
state.page_index = 0;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
auto command_json(vamp_zero::app_state const& state) -> boost::json::object {
|
||||
auto commands = boost::json::object{};
|
||||
commands["last"] = state.last_command;
|
||||
commands["next_count"] = state.next_count;
|
||||
commands["previous_count"] = state.previous_count;
|
||||
commands["select_count"] = state.select_count;
|
||||
commands["heartbeat_count"] = state.heartbeat_count;
|
||||
return commands;
|
||||
}
|
||||
|
||||
auto part_json(vamp_zero::library_part const& part) -> boost::json::object {
|
||||
auto result = boost::json::object{};
|
||||
result["id"] = part.id;
|
||||
result["title"] = part.title;
|
||||
result["composer"] = part.composer;
|
||||
result["key_signature"] = part.key_signature;
|
||||
result["part"] = part.part;
|
||||
result["source_path"] = part.source_path;
|
||||
result["measures"] = static_cast<std::uint64_t>(part.measure_count);
|
||||
result["pages"] = static_cast<std::uint64_t>(part.total_pages);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto exercise_json(vamp_zero::practice_exercise const& exercise) -> boost::json::object {
|
||||
auto variants = boost::json::array{};
|
||||
for (auto const& variant : exercise.variants) {
|
||||
auto item = boost::json::object{};
|
||||
item["id"] = variant.id;
|
||||
item["key_signature"] = variant.key_signature;
|
||||
item["source_path"] = variant.source_path;
|
||||
item["measures"] = static_cast<std::uint64_t>(variant.measure_count);
|
||||
item["pages"] = static_cast<std::uint64_t>(variant.total_pages);
|
||||
variants.push_back(std::move(item));
|
||||
}
|
||||
auto result = boost::json::object{};
|
||||
result["id"] = exercise.id;
|
||||
result["practice_type_id"] = exercise.practice_type_id;
|
||||
result["practice_type_title"] = exercise.practice_type_title;
|
||||
result["lesson_group_id"] = exercise.lesson_group_id;
|
||||
result["lesson_group_title"] = exercise.lesson_group_title;
|
||||
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["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 exercises = boost::json::array{};
|
||||
for (auto const& exercise : lesson.exercises) {
|
||||
exercises.push_back(exercise_json(exercise));
|
||||
}
|
||||
auto result = boost::json::object{};
|
||||
result["id"] = lesson.id;
|
||||
result["title"] = lesson.title;
|
||||
result["exercise_count"] = static_cast<std::uint64_t>(lesson.exercises.size());
|
||||
result["exercises"] = std::move(exercises);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto practice_type_exercise_count(vamp_zero::practice_type_group const& type) -> std::size_t {
|
||||
auto count = std::size_t{};
|
||||
for (auto const& lesson : type.lesson_groups) {
|
||||
count += lesson.exercises.size();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
auto type_json(vamp_zero::practice_type_group const& type) -> boost::json::object {
|
||||
auto lessons = boost::json::array{};
|
||||
for (auto const& lesson : type.lesson_groups) {
|
||||
lessons.push_back(lesson_json(lesson));
|
||||
}
|
||||
auto result = boost::json::object{};
|
||||
result["id"] = type.id;
|
||||
result["title"] = type.title;
|
||||
result["lesson_group_count"] = static_cast<std::uint64_t>(type.lesson_groups.size());
|
||||
result["exercise_count"] = static_cast<std::uint64_t>(practice_type_exercise_count(type));
|
||||
result["lesson_groups"] = std::move(lessons);
|
||||
return result;
|
||||
}
|
||||
|
||||
auto performance_json(vamp_zero::app_state const& state) -> boost::json::object {
|
||||
auto groups = boost::json::array{};
|
||||
for (auto const& group : state.performance.groups) {
|
||||
auto parts = boost::json::array{};
|
||||
for (auto const& part : group.parts) {
|
||||
parts.push_back(part_json(part));
|
||||
}
|
||||
auto item = boost::json::object{};
|
||||
item["title"] = group.title;
|
||||
item["composer"] = group.composer;
|
||||
item["parts"] = std::move(parts);
|
||||
groups.push_back(std::move(item));
|
||||
}
|
||||
|
||||
auto library = boost::json::object{};
|
||||
library["song_count"] = static_cast<std::uint64_t>(state.performance.groups.size());
|
||||
library["part_count"] = static_cast<std::uint64_t>(state.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));
|
||||
}
|
||||
auto exercises = boost::json::array{};
|
||||
for (auto const& exercise : state.practice.exercises) {
|
||||
exercises.push_back(exercise_json(exercise));
|
||||
}
|
||||
auto keys = boost::json::array{};
|
||||
for (auto key : vamp_zero::practice_key_names()) {
|
||||
keys.push_back(boost::json::value{std::string{key}});
|
||||
}
|
||||
|
||||
auto selection = boost::json::object{};
|
||||
selection["type_row_index"] = static_cast<std::uint64_t>(state.selected_practice_type_index);
|
||||
selection["lesson_row_index"] = static_cast<std::uint64_t>(state.selected_practice_lesson_index);
|
||||
selection["exercise_row_index"] = static_cast<std::uint64_t>(state.selected_practice_exercise_index);
|
||||
selection["key_row_index"] = static_cast<std::uint64_t>(state.selected_practice_key_index);
|
||||
if (auto const* type = selected_practice_type(state); type != nullptr) {
|
||||
selection["type"] = type_json(*type);
|
||||
}
|
||||
if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) {
|
||||
selection["lesson_group"] = lesson_json(*lesson);
|
||||
}
|
||||
if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) {
|
||||
selection["exercise"] = exercise_json(*exercise);
|
||||
auto variant = selected_practice_variant(state, *exercise);
|
||||
if (variant != nullptr) {
|
||||
auto item = boost::json::object{};
|
||||
item["id"] = variant->id;
|
||||
item["key_signature"] = variant->key_signature;
|
||||
item["source_path"] = variant->source_path;
|
||||
item["measures"] = static_cast<std::uint64_t>(variant->measure_count);
|
||||
item["pages"] = static_cast<std::uint64_t>(variant->total_pages);
|
||||
selection["variant"] = std::move(item);
|
||||
}
|
||||
}
|
||||
if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) {
|
||||
if (auto key = selected_practice_key(state, *exercise)) {
|
||||
selection["key"] = std::string{*key};
|
||||
}
|
||||
}
|
||||
|
||||
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["types"] = std::move(types);
|
||||
practice["exercises"] = std::move(exercises);
|
||||
practice["key_count"] = static_cast<std::uint64_t>(vamp_zero::practice_key_names().size());
|
||||
practice["keys"] = std::move(keys);
|
||||
practice["selection"] = std::move(selection);
|
||||
return practice;
|
||||
}
|
||||
|
||||
auto sanitize_line_value(std::string value) -> std::string {
|
||||
for (auto& ch : value) {
|
||||
if (ch == '\r' || ch == '\n') {
|
||||
ch = ' ';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state {
|
||||
state = clamp_state(std::move(state));
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
++state.next_count;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
++state.previous_count;
|
||||
} else {
|
||||
++state.select_count;
|
||||
}
|
||||
state.last_command = vamp_protocol::page_command_name(command);
|
||||
|
||||
switch (state.mode) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_main_menu_index = wrap_next(state.selected_main_menu_index, main_menu_count);
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_main_menu_index = wrap_previous(state.selected_main_menu_index, main_menu_count);
|
||||
} else if (state.selected_main_menu_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
state.selected_song_index = 0;
|
||||
state.selected_part_index = 0;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
state.selected_practice_type_index = 0;
|
||||
state.selected_practice_lesson_index = 0;
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
}
|
||||
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_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()));
|
||||
state.selected_part_index = 0;
|
||||
} else if (state.selected_song_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::MAIN_MENU;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.selected_part_index = 0;
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
if (auto const* group = selected_group(state); group != nullptr) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_part_index = wrap_next(state.selected_part_index, menu_row_count(group->parts.size()));
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_part_index = wrap_previous(state.selected_part_index, menu_row_count(group->parts.size()));
|
||||
} else if (state.selected_part_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (auto const* part = selected_part(state); part != nullptr) {
|
||||
state.active_part_id = part->id;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
if (auto const* part = active_part(state); part != nullptr) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.page_index = std::min(state.page_index + 1U, part->total_pages == 0U ? 0U : part->total_pages - 1U);
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.page_index = state.page_index == 0U ? 0U : state.page_index - 1U;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
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()));
|
||||
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()));
|
||||
state.selected_practice_lesson_index = 0;
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (state.selected_practice_type_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::MAIN_MENU;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
state.selected_practice_lesson_index = 0;
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PRACTICE_LESSON_MENU:
|
||||
if (auto const* type = selected_practice_type(state); type != nullptr) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_practice_lesson_index = wrap_next(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()));
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_practice_lesson_index = wrap_previous(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()));
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (state.selected_practice_lesson_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
} else {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state.selected_practice_exercise_index = 0;
|
||||
state.selected_practice_key_index = 0;
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PRACTICE_EXERCISE_MENU:
|
||||
if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_practice_exercise_index = wrap_next(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size()));
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_practice_exercise_index = wrap_previous(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size()));
|
||||
state.selected_practice_key_index = 0;
|
||||
} else if (state.selected_practice_exercise_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) {
|
||||
state.active_part_id.clear();
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
if (supports_key_selection(*exercise) && !exercise->variants.empty()) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_KEY_MENU;
|
||||
state.selected_practice_key_index = 0;
|
||||
} else {
|
||||
state.active_practice_id = exercise->id;
|
||||
state.selected_practice_key_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
|
||||
}
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PRACTICE_KEY_MENU:
|
||||
if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr && supports_key_selection(*exercise)) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_practice_key_index = wrap_next(state.selected_practice_key_index, menu_row_count(exercise->variants.size()));
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_practice_key_index = wrap_previous(state.selected_practice_key_index, menu_row_count(exercise->variants.size()));
|
||||
} else if (state.selected_practice_key_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (selected_practice_variant(state, *exercise) != nullptr) {
|
||||
state.active_practice_id = exercise->id;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
case vamp_zero::app_mode::PRACTICE_SCORE:
|
||||
if (auto const* exercise = active_practice(state); exercise != nullptr) {
|
||||
auto const* variant = selected_practice_variant(state, *exercise);
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
auto pages = variant == nullptr ? 0U : variant->total_pages;
|
||||
state.page_index = std::min(state.page_index + 1U, pages == 0U ? 0U : pages - 1U);
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.page_index = state.page_index == 0U ? 0U : state.page_index - 1U;
|
||||
} else {
|
||||
state.mode = supports_key_selection(*exercise) ? vamp_zero::app_mode::PRACTICE_KEY_MENU : vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
}
|
||||
|
||||
auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::json::value {
|
||||
auto selection = boost::json::object{};
|
||||
selection["main_menu_index"] = static_cast<std::uint64_t>(state.selected_main_menu_index);
|
||||
selection["song_row_index"] = static_cast<std::uint64_t>(state.selected_song_index);
|
||||
selection["part_row_index"] = static_cast<std::uint64_t>(state.selected_part_index);
|
||||
if (auto const* group = selected_group(state); group != nullptr) {
|
||||
selection["title"] = group->title;
|
||||
selection["composer"] = group->composer;
|
||||
}
|
||||
if (auto const* part = selected_part(state); part != nullptr) {
|
||||
selection["part"] = part_json(*part);
|
||||
}
|
||||
|
||||
auto active = boost::json::object{};
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
if (auto const* part = active_part(state); part != nullptr) {
|
||||
active["selected"] = true;
|
||||
active["kind"] = "performance";
|
||||
active["part"] = part_json(*part);
|
||||
active["page_index"] = static_cast<std::uint64_t>(state.page_index);
|
||||
active["page_number"] = static_cast<std::uint64_t>(state.page_index + 1U);
|
||||
active["page_total"] = static_cast<std::uint64_t>(part->total_pages);
|
||||
}
|
||||
} else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
if (auto const* exercise = active_practice(state); exercise != nullptr) {
|
||||
auto const* variant = selected_practice_variant(state, *exercise);
|
||||
active["selected"] = true;
|
||||
active["kind"] = "practice";
|
||||
active["exercise"] = exercise_json(*exercise);
|
||||
active["key"] = effective_practice_key(state, *exercise);
|
||||
active["page_index"] = static_cast<std::uint64_t>(state.page_index);
|
||||
active["page_number"] = static_cast<std::uint64_t>(state.page_index + 1U);
|
||||
active["page_total"] = static_cast<std::uint64_t>(variant == nullptr ? 0U : variant->total_pages);
|
||||
}
|
||||
}
|
||||
if (active.empty()) {
|
||||
active["selected"] = false;
|
||||
active["page_index"] = 0;
|
||||
active["page_number"] = 0;
|
||||
active["page_total"] = 0;
|
||||
}
|
||||
|
||||
auto remote = boost::json::object{};
|
||||
remote["pico_connected"] = state.pico_connected;
|
||||
remote["heartbeat_count"] = state.heartbeat_count;
|
||||
|
||||
auto performance = performance_json(state);
|
||||
auto root = boost::json::object{};
|
||||
root["mode"] = vamp_zero::to_string(state.mode);
|
||||
root["library"] = performance;
|
||||
root["performance"] = std::move(performance);
|
||||
root["practice"] = practice_json(state);
|
||||
root["selection"] = std::move(selection);
|
||||
root["active"] = std::move(active);
|
||||
root["commands"] = command_json(state);
|
||||
root["remote"] = std::move(remote);
|
||||
return root;
|
||||
}
|
||||
|
||||
auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::string {
|
||||
auto title = std::string{};
|
||||
auto part = std::string{};
|
||||
auto page = std::size_t{};
|
||||
auto pages = std::size_t{};
|
||||
auto active = false;
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
if (auto const* selected = active_part(state); selected != nullptr) {
|
||||
active = true;
|
||||
title = selected->title;
|
||||
part = selected->part;
|
||||
page = state.page_index + 1U;
|
||||
pages = selected->total_pages;
|
||||
}
|
||||
} else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
if (auto const* selected = active_practice(state); selected != nullptr) {
|
||||
auto const* variant = selected_practice_variant(state, *selected);
|
||||
active = true;
|
||||
title = selected->title;
|
||||
part = "Key: " + effective_practice_key(state, *selected);
|
||||
page = state.page_index + 1U;
|
||||
pages = variant == nullptr ? 0U : variant->total_pages;
|
||||
}
|
||||
}
|
||||
|
||||
auto output = std::ostringstream{};
|
||||
output << "version=1\n";
|
||||
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
|
||||
output << "active=" << (active ? "1" : "0") << "\n";
|
||||
output << "title=" << sanitize_line_value(title) << "\n";
|
||||
output << "part=" << sanitize_line_value(part) << "\n";
|
||||
output << "page=" << page << "\n";
|
||||
output << "pages=" << pages << "\n";
|
||||
return output.str();
|
||||
}
|
||||
|
||||
auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::expected<void, modulus::error> {
|
||||
if (state.selected_main_menu_index >= main_menu_count) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range"));
|
||||
}
|
||||
if (state.selected_song_index >= menu_row_count(state.performance.groups.size())) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range"));
|
||||
}
|
||||
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())) {
|
||||
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); type != nullptr && state.selected_practice_lesson_index >= menu_row_count(type->lesson_groups.size())) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice lesson row is out of range"));
|
||||
}
|
||||
if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr && state.selected_practice_exercise_index >= menu_row_count(lesson->exercises.size())) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice exercise row is out of range"));
|
||||
}
|
||||
if (state.selected_practice_key_index >= practice_key_row_count(state)) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice key row is out of range"));
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_LESSON_MENU && selected_practice_type(state) == nullptr) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice lesson mode requires a selected practice type"));
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU && selected_practice_lesson(state) == nullptr) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice exercise mode requires a selected lesson group"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const* part = active_part(state);
|
||||
if (part == nullptr) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part"));
|
||||
}
|
||||
if (part->total_pages == 0U || state.page_index >= part->total_pages) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score page index is out of range"));
|
||||
}
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
if (exercise == nullptr) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires an active exercise"));
|
||||
}
|
||||
auto const* variant = selected_practice_variant(state, *exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires a selected generated variant"));
|
||||
}
|
||||
if (variant->total_pages == 0U || state.page_index >= variant->total_pages) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score page index is out of range"));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto portal_html() -> std::string {
|
||||
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
|
||||
async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);}
|
||||
document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();};
|
||||
document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();};
|
||||
document.getElementById('next').onclick=async()=>{await fetch('/api/remote/next',{method:'POST'});refresh();};
|
||||
refresh();
|
||||
</script></main></body></html>)html";
|
||||
}
|
||||
|
||||
auto endpoint_port(std::string const& endpoint) -> std::string {
|
||||
auto separator = endpoint.rfind(':');
|
||||
if (separator == std::string::npos || separator + 1U >= endpoint.size()) {
|
||||
return "8080";
|
||||
}
|
||||
return endpoint.substr(separator + 1U);
|
||||
}
|
||||
|
||||
auto remote_response(vamp_zero::app_state const& state) -> ligo::service_response {
|
||||
return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::control_state::control_state(vamp_zero::config config, vamp_zero::app_state state) : config_{std::move(config)}, state_{std::move(state)} {}
|
||||
|
||||
auto vamp_zero::control_state::config() const -> vamp_zero::config {
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
return config_;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::pico_connected_locked() const -> std::bool_t {
|
||||
return last_heartbeat_ != std::chrono::steady_clock::time_point{} && std::chrono::steady_clock::now() - last_heartbeat_ < std::chrono::seconds{3};
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::snapshot_locked() const -> vamp_zero::app_state {
|
||||
auto copy = state_;
|
||||
copy.pico_connected = pico_connected_locked();
|
||||
return copy;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::snapshot() const -> vamp_zero::app_state {
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
return snapshot_locked();
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vamp_zero::app_state {
|
||||
auto observer = vamp_zero::control_state::observer{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
state_ = vamp_zero::apply_app_command(std::move(state_), command);
|
||||
state_.pico_connected = pico_connected_locked();
|
||||
updated = state_;
|
||||
observer = observer_;
|
||||
}
|
||||
if (observer) {
|
||||
observer(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state {
|
||||
auto observer = vamp_zero::control_state::observer{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
last_heartbeat_ = std::chrono::steady_clock::now();
|
||||
++state_.heartbeat_count;
|
||||
auto notify = !state_.pico_connected;
|
||||
state_.pico_connected = true;
|
||||
updated = state_;
|
||||
if (notify) {
|
||||
observer = observer_;
|
||||
}
|
||||
}
|
||||
if (observer) {
|
||||
observer(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::refresh_connection() -> std::optional<vamp_zero::app_state> {
|
||||
auto observer = vamp_zero::control_state::observer{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
auto connected = pico_connected_locked();
|
||||
if (connected == state_.pico_connected) {
|
||||
return std::nullopt;
|
||||
}
|
||||
state_.pico_connected = connected;
|
||||
updated = state_;
|
||||
observer = observer_;
|
||||
}
|
||||
if (observer) {
|
||||
observer(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::set_observer(vamp_zero::control_state::observer observer) -> void {
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
observer_ = std::move(observer);
|
||||
}
|
||||
|
||||
auto vamp_zero::portal_status_json(vamp_zero::config const& config, vamp_zero::app_state const& state) -> boost::json::value {
|
||||
auto control = boost::json::object{};
|
||||
control["ap_ssid"] = config.control.ap_ssid;
|
||||
control["zero_ip"] = config.control.zero_ip;
|
||||
control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind);
|
||||
|
||||
auto root = boost::json::object{};
|
||||
root["device"] = "zero";
|
||||
root["version"] = VAMP_VERSION;
|
||||
root["git_sha"] = VAMP_GIT_SHA;
|
||||
root["control"] = std::move(control);
|
||||
root["state"] = vamp_zero::app_status_json(state);
|
||||
return root;
|
||||
}
|
||||
|
||||
auto vamp_zero::make_router(vamp_zero::control_state& state) -> ligo::service_router {
|
||||
auto router = ligo::service_router{};
|
||||
router.add_response_route("GET", vamp_protocol::root_path, [](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
co_return ligo::text_response("text/html; charset=utf-8", portal_html());
|
||||
});
|
||||
router.add_route("GET", vamp_protocol::status_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
||||
co_return vamp_zero::portal_status_json(state.config(), state.snapshot());
|
||||
});
|
||||
router.add_response_route("GET", vamp_protocol::remote_display_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto updated = state.heartbeat();
|
||||
co_return remote_response(updated);
|
||||
});
|
||||
router.add_response_route("POST", vamp_protocol::remote_heartbeat_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto updated = state.heartbeat();
|
||||
co_return remote_response(updated);
|
||||
});
|
||||
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>> {
|
||||
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>> {
|
||||
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>> {
|
||||
auto updated = state.apply(vamp_protocol::page_command::SELECT);
|
||||
co_return remote_response(updated);
|
||||
});
|
||||
router.add_route("POST", vamp_protocol::next_page_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
||||
auto updated = state.apply(vamp_protocol::page_command::NEXT);
|
||||
co_return vamp_zero::portal_status_json(state.config(), updated);
|
||||
});
|
||||
router.add_route("POST", vamp_protocol::previous_page_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
|
||||
auto updated = state.apply(vamp_protocol::page_command::PREVIOUS);
|
||||
co_return vamp_zero::portal_status_json(state.config(), updated);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <raylib.h>
|
||||
|
||||
#include <vamp_style/font.hpp>
|
||||
#include <vamp_zero/color.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto whole_note_ticks = std::int64_t{16384};
|
||||
constexpr auto half_note_ticks = std::int64_t{8192};
|
||||
constexpr auto quarter_note_ticks = std::int64_t{4096};
|
||||
constexpr auto eighth_note_ticks = std::int64_t{2048};
|
||||
|
||||
enum class duration_base {
|
||||
WHOLE,
|
||||
HALF,
|
||||
QUARTER,
|
||||
EIGHTH,
|
||||
};
|
||||
|
||||
struct duration_shape {
|
||||
duration_base base{duration_base::QUARTER};
|
||||
std::int32_t dots{};
|
||||
std::int64_t ticks{quarter_note_ticks};
|
||||
};
|
||||
|
||||
struct smufl_box {
|
||||
std::float32_t west{};
|
||||
std::float32_t south{};
|
||||
std::float32_t east{};
|
||||
std::float32_t north{};
|
||||
};
|
||||
|
||||
struct smufl_anchor {
|
||||
std::float32_t x{};
|
||||
std::float32_t y{};
|
||||
};
|
||||
|
||||
struct notehead_instance {
|
||||
std::float32_t center_y{};
|
||||
luma::point origin{};
|
||||
};
|
||||
|
||||
constexpr auto glyph_notehead_whole = std::int32_t{0xe0a2};
|
||||
constexpr auto glyph_notehead_half = std::int32_t{0xe0a3};
|
||||
constexpr auto glyph_notehead_black = std::int32_t{0xe0a4};
|
||||
constexpr auto glyph_notehead_slash = std::int32_t{0xe101};
|
||||
constexpr auto glyph_rest_whole = std::int32_t{0xe4e3};
|
||||
constexpr auto glyph_rest_half = std::int32_t{0xe4e4};
|
||||
constexpr auto glyph_rest_quarter = std::int32_t{0xe4e5};
|
||||
constexpr auto glyph_rest_eighth = std::int32_t{0xe4e6};
|
||||
constexpr auto glyph_flag_eighth_up = std::int32_t{0xe240};
|
||||
constexpr auto glyph_flag_eighth_down = std::int32_t{0xe241};
|
||||
constexpr auto glyph_augmentation_dot = std::int32_t{0xe1e7};
|
||||
constexpr auto glyph_accidental_flat = std::int32_t{0xe260};
|
||||
constexpr auto glyph_accidental_natural = std::int32_t{0xe261};
|
||||
constexpr auto glyph_accidental_sharp = std::int32_t{0xe262};
|
||||
|
||||
auto to_raylib(luma::color color) -> Color {
|
||||
return {
|
||||
.r = color.r,
|
||||
.g = color.g,
|
||||
.b = color.b,
|
||||
.a = color.a,
|
||||
};
|
||||
}
|
||||
|
||||
auto encode_utf8(std::int32_t codepoint) -> std::string {
|
||||
auto output = std::string{};
|
||||
if (codepoint <= 0x7f) {
|
||||
output.push_back(static_cast<std::char_t>(codepoint));
|
||||
} else if (codepoint <= 0x7ff) {
|
||||
output.push_back(static_cast<std::char_t>(0xc0 | ((codepoint >> 6) & 0x1f)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | (codepoint & 0x3f)));
|
||||
} else if (codepoint <= 0xffff) {
|
||||
output.push_back(static_cast<std::char_t>(0xe0 | ((codepoint >> 12) & 0x0f)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | ((codepoint >> 6) & 0x3f)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | (codepoint & 0x3f)));
|
||||
} else {
|
||||
output.push_back(static_cast<std::char_t>(0xf0 | ((codepoint >> 18) & 0x07)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | ((codepoint >> 12) & 0x3f)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | ((codepoint >> 6) & 0x3f)));
|
||||
output.push_back(static_cast<std::char_t>(0x80 | (codepoint & 0x3f)));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
auto music_font(std::float32_t size) -> Font {
|
||||
auto const key = std::max(std::int32_t{1}, static_cast<std::int32_t>(std::ceil(size)));
|
||||
static auto fonts = std::unordered_map<std::int32_t, Font>{};
|
||||
if (auto found = fonts.find(key); found != fonts.end()) {
|
||||
return found->second;
|
||||
}
|
||||
|
||||
auto codepoints = std::vector<std::int32_t>{
|
||||
glyph_notehead_whole,
|
||||
glyph_notehead_half,
|
||||
glyph_notehead_black,
|
||||
glyph_notehead_slash,
|
||||
glyph_rest_whole,
|
||||
glyph_rest_half,
|
||||
glyph_rest_quarter,
|
||||
glyph_rest_eighth,
|
||||
glyph_flag_eighth_up,
|
||||
glyph_flag_eighth_down,
|
||||
glyph_augmentation_dot,
|
||||
glyph_accidental_flat,
|
||||
glyph_accidental_natural,
|
||||
glyph_accidental_sharp,
|
||||
};
|
||||
auto path = std::string{vamp_style::score_music_font_path};
|
||||
auto loaded = LoadFontEx(path.c_str(), key, codepoints.data(), static_cast<std::int32_t>(codepoints.size()));
|
||||
if (loaded.texture.id == 0) {
|
||||
return GetFontDefault();
|
||||
}
|
||||
auto inserted = fonts.emplace(key, loaded);
|
||||
return inserted.first->second;
|
||||
}
|
||||
|
||||
auto glyph_box(std::int32_t codepoint) -> smufl_box {
|
||||
switch (codepoint) {
|
||||
case glyph_notehead_whole:
|
||||
return {.west = 0.0F, .south = -0.744684F, .east = 1.520666F, .north = 0.66F};
|
||||
case glyph_notehead_half:
|
||||
return {.west = 0.0F, .south = -0.696F, .east = 1.336F, .north = 0.7F};
|
||||
case glyph_notehead_black:
|
||||
return {.west = 0.0F, .south = -0.656F, .east = 1.336186F, .north = 0.656F};
|
||||
case glyph_notehead_slash:
|
||||
return {.west = 0.0F, .south = -1.016F, .east = 1.952F, .north = 1.016F};
|
||||
case glyph_rest_whole:
|
||||
return {.west = 0.003563F, .south = -0.48F, .east = 2.0F, .north = 0.056F};
|
||||
case glyph_rest_half:
|
||||
return {.west = 0.0F, .south = 0.0F, .east = 2.073599F, .north = 0.648F};
|
||||
case glyph_rest_quarter:
|
||||
return {.west = -0.001514F, .south = -1.66F, .east = 1.052F, .north = 1.66F};
|
||||
case glyph_rest_eighth:
|
||||
return {.west = 0.0F, .south = -1.04F, .east = 1.156F, .north = 1.044F};
|
||||
case glyph_flag_eighth_up:
|
||||
return {.west = 0.0F, .south = -3.276F, .east = 1.044F, .north = 0.0F};
|
||||
case glyph_flag_eighth_down:
|
||||
return {.west = 0.0F, .south = 0.0F, .east = 1.044F, .north = 3.276F};
|
||||
case glyph_augmentation_dot:
|
||||
return {.west = 0.0F, .south = -0.228F, .east = 0.384F, .north = 0.228F};
|
||||
case glyph_accidental_flat:
|
||||
return {.west = 0.004F, .south = -0.832F, .east = 0.836F, .north = 1.888F};
|
||||
case glyph_accidental_natural:
|
||||
return {.west = 0.0F, .south = -1.824F, .east = 0.854F, .north = 1.848F};
|
||||
case glyph_accidental_sharp:
|
||||
return {.west = -0.088F, .south = -1.536F, .east = 1.56F, .north = 1.532F};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
auto glyph_center_x(std::int32_t codepoint) -> std::float32_t {
|
||||
auto box = glyph_box(codepoint);
|
||||
return (box.west + box.east) * 0.5F;
|
||||
}
|
||||
|
||||
auto glyph_center_y(std::int32_t codepoint) -> std::float32_t {
|
||||
auto box = glyph_box(codepoint);
|
||||
return (box.south + box.north) * 0.5F;
|
||||
}
|
||||
|
||||
auto glyph_width(std::int32_t codepoint, std::float32_t staff_spacing) -> std::float32_t {
|
||||
auto box = glyph_box(codepoint);
|
||||
return (box.east - box.west) * staff_spacing;
|
||||
}
|
||||
|
||||
auto draw_music_origin(std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing, luma::color color) -> void {
|
||||
auto font_size = staff_spacing * 4.0F;
|
||||
auto font = music_font(font_size);
|
||||
auto glyph = GetGlyphInfo(font, codepoint);
|
||||
auto scale = font_size / std::max(1.0F, static_cast<std::float32_t>(font.baseSize));
|
||||
auto box = glyph_box(codepoint);
|
||||
auto screen_left = origin.x + box.west * staff_spacing;
|
||||
auto screen_top = origin.y - box.north * staff_spacing;
|
||||
auto draw_x = screen_left - static_cast<std::float32_t>(glyph.offsetX) * scale + static_cast<std::float32_t>(font.glyphPadding) * scale;
|
||||
auto draw_y = screen_top - static_cast<std::float32_t>(glyph.offsetY) * scale + static_cast<std::float32_t>(font.glyphPadding) * scale;
|
||||
auto text = encode_utf8(codepoint);
|
||||
DrawTextEx(font, text.c_str(), {.x = draw_x, .y = draw_y}, font_size, 0.0F, to_raylib(color));
|
||||
}
|
||||
|
||||
auto origin_for_center(std::int32_t codepoint, std::float32_t center_x, std::float32_t center_y, std::float32_t staff_spacing) -> luma::point {
|
||||
return {
|
||||
.x = center_x - glyph_center_x(codepoint) * staff_spacing,
|
||||
.y = center_y + glyph_center_y(codepoint) * staff_spacing,
|
||||
};
|
||||
}
|
||||
|
||||
auto draw_music_centered(std::int32_t codepoint, std::float32_t center_x, std::float32_t center_y, std::float32_t staff_spacing, luma::color color) -> void {
|
||||
draw_music_origin(codepoint, origin_for_center(codepoint, center_x, center_y, staff_spacing), staff_spacing, color);
|
||||
}
|
||||
|
||||
auto draw_music_centered_at_origin_y(std::int32_t codepoint, std::float32_t center_x, std::float32_t origin_y, std::float32_t staff_spacing, luma::color color) -> void {
|
||||
draw_music_origin(codepoint, {.x = center_x - glyph_center_x(codepoint) * staff_spacing, .y = origin_y}, staff_spacing, color);
|
||||
}
|
||||
|
||||
auto draw_music_left_of(std::int32_t codepoint, std::float32_t right_x, std::float32_t center_y, std::float32_t staff_spacing, luma::color color) -> void {
|
||||
auto box = glyph_box(codepoint);
|
||||
auto origin = luma::point{
|
||||
.x = right_x - box.east * staff_spacing,
|
||||
.y = center_y + glyph_center_y(codepoint) * staff_spacing,
|
||||
};
|
||||
draw_music_origin(codepoint, origin, staff_spacing, color);
|
||||
}
|
||||
|
||||
auto anchor_point(luma::point origin, smufl_anchor anchor, std::float32_t staff_spacing) -> luma::point {
|
||||
return {
|
||||
.x = origin.x + anchor.x * staff_spacing,
|
||||
.y = origin.y - anchor.y * staff_spacing,
|
||||
};
|
||||
}
|
||||
|
||||
auto stem_up_anchor(std::int32_t notehead) -> smufl_anchor {
|
||||
switch (notehead) {
|
||||
case glyph_notehead_half:
|
||||
return {.x = 1.312F, .y = 0.284F};
|
||||
case glyph_notehead_slash:
|
||||
return {.x = 1.952F, .y = 0.884F};
|
||||
case glyph_notehead_black:
|
||||
default:
|
||||
return {.x = 1.336F, .y = 0.288F};
|
||||
}
|
||||
}
|
||||
|
||||
auto stem_down_anchor(std::int32_t notehead) -> smufl_anchor {
|
||||
switch (notehead) {
|
||||
case glyph_notehead_half:
|
||||
return {.x = 0.0F, .y = -0.252F};
|
||||
case glyph_notehead_slash:
|
||||
return {.x = 0.0F, .y = -0.928F};
|
||||
case glyph_notehead_black:
|
||||
default:
|
||||
return {.x = 0.0F, .y = -0.236F};
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_eighth_flag(std::bool_t stem_up, std::float32_t stem_x, std::float32_t stem_tip_y, std::float32_t staff_spacing, luma::color color) -> void {
|
||||
auto codepoint = stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down;
|
||||
auto anchor = stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F};
|
||||
auto origin = luma::point{
|
||||
.x = stem_x - anchor.x * staff_spacing,
|
||||
.y = stem_tip_y + anchor.y * staff_spacing,
|
||||
};
|
||||
draw_music_origin(codepoint, origin, staff_spacing, color);
|
||||
}
|
||||
|
||||
auto text_style(std::int32_t scale) -> luma::text_style {
|
||||
auto size = 14.0F;
|
||||
if (scale == 2) {
|
||||
size = 22.0F;
|
||||
} else if (scale >= 3) {
|
||||
size = 30.0F;
|
||||
}
|
||||
return {
|
||||
.font = std::string{vamp_style::score_text_font_path},
|
||||
.size = size,
|
||||
.line_height = size + 5.0F,
|
||||
};
|
||||
}
|
||||
|
||||
auto draw_text(luma::draw_context& context, std::float32_t x, std::float32_t y, std::string const& text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void {
|
||||
if (text.empty() || max_width <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
auto style = text_style(scale);
|
||||
context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height});
|
||||
context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color));
|
||||
context.pop_clip();
|
||||
}
|
||||
|
||||
auto duration_for(std::int64_t ticks) -> duration_shape {
|
||||
if (ticks >= whole_note_ticks) {
|
||||
return {.base = duration_base::WHOLE, .dots = 0, .ticks = whole_note_ticks};
|
||||
}
|
||||
if (ticks >= half_note_ticks + quarter_note_ticks) {
|
||||
return {.base = duration_base::HALF, .dots = 1, .ticks = half_note_ticks + quarter_note_ticks};
|
||||
}
|
||||
if (ticks >= half_note_ticks) {
|
||||
return {.base = duration_base::HALF, .dots = 0, .ticks = half_note_ticks};
|
||||
}
|
||||
if (ticks >= quarter_note_ticks + eighth_note_ticks) {
|
||||
return {.base = duration_base::QUARTER, .dots = 1, .ticks = quarter_note_ticks + eighth_note_ticks};
|
||||
}
|
||||
if (ticks >= quarter_note_ticks) {
|
||||
return {.base = duration_base::QUARTER, .dots = 0, .ticks = quarter_note_ticks};
|
||||
}
|
||||
return {.base = duration_base::EIGHTH, .dots = 0, .ticks = eighth_note_ticks};
|
||||
}
|
||||
|
||||
auto spacing_ticks(vamp_zero::score_note const& note) -> std::int64_t {
|
||||
return std::max<std::int64_t>(1, note.duration_ticks);
|
||||
}
|
||||
|
||||
auto display_ticks(vamp_zero::score_note const& note) -> std::int64_t {
|
||||
return note.display_ticks <= 0 ? note.duration_ticks : note.display_ticks;
|
||||
}
|
||||
|
||||
auto measure_ticks(vamp_zero::score_measure const& measure) -> std::int64_t {
|
||||
auto total = std::int64_t{};
|
||||
for (auto const& note : measure.notes) {
|
||||
total += spacing_ticks(note);
|
||||
}
|
||||
return std::max(whole_note_ticks, total);
|
||||
}
|
||||
|
||||
auto has_stem(duration_shape duration) -> std::bool_t {
|
||||
return duration.base != duration_base::WHOLE;
|
||||
}
|
||||
|
||||
auto has_flag(duration_shape duration) -> std::bool_t {
|
||||
return duration.base == duration_base::EIGHTH;
|
||||
}
|
||||
|
||||
auto letter_index(std::char_t letter) -> std::int32_t {
|
||||
switch (letter) {
|
||||
case 'c':
|
||||
case 'C':
|
||||
return 0;
|
||||
case 'd':
|
||||
case 'D':
|
||||
return 1;
|
||||
case 'e':
|
||||
case 'E':
|
||||
return 2;
|
||||
case 'f':
|
||||
case 'F':
|
||||
return 3;
|
||||
case 'g':
|
||||
case 'G':
|
||||
return 4;
|
||||
case 'a':
|
||||
case 'A':
|
||||
return 5;
|
||||
case 'b':
|
||||
case 'B':
|
||||
return 6;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
auto pitch_y(vamp_zero::score_pitch const& pitch, std::float32_t staff_top, std::float32_t staff_spacing) -> std::float32_t {
|
||||
constexpr auto e4_index = std::int32_t{30};
|
||||
auto pitch_index = pitch.octave * 7 + letter_index(pitch.letter);
|
||||
auto bottom_line = staff_top + staff_spacing * 4.0F;
|
||||
return bottom_line - static_cast<std::float32_t>(pitch_index - e4_index) * (staff_spacing * 0.5F);
|
||||
}
|
||||
|
||||
auto accidental_glyph(std::string const& accidental) -> std::optional<std::int32_t> {
|
||||
if (accidental == "#" || accidental == "sharp") {
|
||||
return glyph_accidental_sharp;
|
||||
}
|
||||
if (accidental == "b" || accidental == "flat") {
|
||||
return glyph_accidental_flat;
|
||||
}
|
||||
if (accidental == "natural" || accidental == "n") {
|
||||
return glyph_accidental_natural;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto draw_curve(luma::draw_context& context, luma::point start, luma::point control, luma::point end, std::float32_t thickness, luma::color color) -> void {
|
||||
constexpr auto segments = std::int32_t{10};
|
||||
auto previous = start;
|
||||
for (auto index = std::int32_t{1}; index <= segments; ++index) {
|
||||
auto t = static_cast<std::float32_t>(index) / static_cast<std::float32_t>(segments);
|
||||
auto omt = 1.0F - t;
|
||||
auto current = luma::point{
|
||||
.x = omt * omt * start.x + 2.0F * omt * t * control.x + t * t * end.x,
|
||||
.y = omt * omt * start.y + 2.0F * omt * t * control.y + t * t * end.y,
|
||||
};
|
||||
context.stroke_line(previous, current, thickness, color);
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_staff(luma::draw_context& context, std::float32_t x, std::float32_t y, std::float32_t width, std::float32_t spacing) -> void {
|
||||
auto ink = vamp_zero::to_luma(vamp_style::paper_ink);
|
||||
for (auto line = std::int32_t{}; line < 5; ++line) {
|
||||
auto yy = y + static_cast<std::float32_t>(line) * spacing;
|
||||
context.stroke_line({.x = x, .y = yy}, {.x = x + width, .y = yy}, 1.15F, ink);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_duration_dot(luma::draw_context& context, std::float32_t x, std::float32_t y, std::float32_t spacing, luma::color ink) -> void {
|
||||
static_cast<void>(context);
|
||||
draw_music_centered(glyph_augmentation_dot, x, y, spacing, ink);
|
||||
}
|
||||
|
||||
auto dot_y_for(std::float32_t note_y, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t {
|
||||
auto half_step = (note_y - staff_top) / (spacing * 0.5F);
|
||||
auto rounded = std::round(half_step);
|
||||
if (std::abs(half_step - rounded) < 0.08F && static_cast<std::int32_t>(rounded) % 2 == 0) {
|
||||
return note_y - spacing * 0.5F;
|
||||
}
|
||||
return note_y;
|
||||
}
|
||||
|
||||
auto draw_ledger_lines(luma::draw_context& context, std::float32_t x, std::float32_t note_y, std::float32_t staff_top, std::float32_t spacing, luma::color ink) -> void {
|
||||
auto bottom = staff_top + spacing * 4.0F;
|
||||
if (note_y < staff_top) {
|
||||
for (auto yy = staff_top - spacing; yy >= note_y - 0.1F; yy -= spacing) {
|
||||
context.stroke_line({.x = x - 12.0F, .y = yy}, {.x = x + 12.0F, .y = yy}, 1.1F, ink);
|
||||
}
|
||||
} else if (note_y > bottom) {
|
||||
for (auto yy = bottom + spacing; yy <= note_y + 0.1F; yy += spacing) {
|
||||
context.stroke_line({.x = x - 12.0F, .y = yy}, {.x = x + 12.0F, .y = yy}, 1.1F, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_rest(luma::draw_context& context, duration_shape duration, std::float32_t x, std::float32_t staff_top, std::float32_t spacing) -> void {
|
||||
static_cast<void>(context);
|
||||
auto ink = vamp_zero::to_luma(vamp_style::paper_ink);
|
||||
auto center = staff_top + spacing * 2.0F;
|
||||
auto codepoint = glyph_rest_quarter;
|
||||
auto origin_y = center;
|
||||
auto centered = false;
|
||||
switch (duration.base) {
|
||||
case duration_base::WHOLE:
|
||||
codepoint = glyph_rest_whole;
|
||||
origin_y = staff_top + spacing;
|
||||
break;
|
||||
case duration_base::HALF:
|
||||
codepoint = glyph_rest_half;
|
||||
origin_y = staff_top + spacing * 2.0F;
|
||||
break;
|
||||
case duration_base::QUARTER:
|
||||
codepoint = glyph_rest_quarter;
|
||||
centered = true;
|
||||
break;
|
||||
case duration_base::EIGHTH:
|
||||
codepoint = glyph_rest_eighth;
|
||||
centered = true;
|
||||
break;
|
||||
}
|
||||
if (centered) {
|
||||
draw_music_centered(codepoint, x, center, spacing, ink);
|
||||
} else {
|
||||
draw_music_centered_at_origin_y(codepoint, x, origin_y, spacing, ink);
|
||||
}
|
||||
if (duration.dots != 0) {
|
||||
draw_duration_dot(context, x + glyph_width(codepoint, spacing) * 0.5F + spacing, center, spacing, ink);
|
||||
}
|
||||
}
|
||||
|
||||
auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int32_t {
|
||||
if (slash) {
|
||||
return glyph_notehead_slash;
|
||||
}
|
||||
switch (duration.base) {
|
||||
case duration_base::WHOLE:
|
||||
return glyph_notehead_whole;
|
||||
case duration_base::HALF:
|
||||
return glyph_notehead_half;
|
||||
case duration_base::QUARTER:
|
||||
case duration_base::EIGHTH:
|
||||
return glyph_notehead_black;
|
||||
}
|
||||
return glyph_notehead_black;
|
||||
}
|
||||
|
||||
auto draw_notehead(duration_shape duration, std::bool_t slash, std::float32_t x, std::float32_t y, std::float32_t spacing, luma::color ink) -> notehead_instance {
|
||||
auto codepoint = notehead_codepoint(duration, slash);
|
||||
auto origin = origin_for_center(codepoint, x, y, spacing);
|
||||
draw_music_origin(codepoint, origin, spacing, ink);
|
||||
return {.center_y = y, .origin = origin};
|
||||
}
|
||||
|
||||
auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::float32_t next_x, std::bool_t slash) -> void {
|
||||
if (note.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto duration = duration_for(display_ticks(note));
|
||||
if (note.kind == "rest") {
|
||||
draw_rest(context, duration, x, staff_top, spacing);
|
||||
return;
|
||||
}
|
||||
|
||||
auto ink = vamp_zero::to_luma(vamp_style::paper_ink);
|
||||
auto center = staff_top + spacing * 2.0F;
|
||||
auto bottom = staff_top + spacing * 4.0F;
|
||||
auto low_y = center;
|
||||
auto high_y = center;
|
||||
auto heads = std::vector<notehead_instance>{};
|
||||
if (slash || note.pitches.empty()) {
|
||||
heads.push_back(draw_notehead(duration, slash, x, center, spacing, ink));
|
||||
low_y = center;
|
||||
high_y = center;
|
||||
}
|
||||
if (!slash) {
|
||||
for (auto const& pitch : note.pitches) {
|
||||
auto yy = std::clamp(pitch_y(pitch, staff_top, spacing), staff_top - spacing * 2.0F, bottom + spacing * 2.0F);
|
||||
low_y = std::max(low_y, yy);
|
||||
high_y = std::min(high_y, yy);
|
||||
}
|
||||
for (auto const& pitch : note.pitches) {
|
||||
auto yy = std::clamp(pitch_y(pitch, staff_top, spacing), staff_top - spacing * 2.0F, bottom + spacing * 2.0F);
|
||||
draw_ledger_lines(context, x, yy, staff_top, spacing, ink);
|
||||
if (auto accidental = accidental_glyph(pitch.accidental)) {
|
||||
auto head_origin = origin_for_center(notehead_codepoint(duration, false), x, yy, spacing);
|
||||
auto head_left = head_origin.x + glyph_box(notehead_codepoint(duration, false)).west * spacing;
|
||||
draw_music_left_of(*accidental, head_left - spacing * 0.65F, yy, spacing, ink);
|
||||
}
|
||||
heads.push_back(draw_notehead(duration, false, x, yy, spacing, ink));
|
||||
}
|
||||
}
|
||||
|
||||
auto notehead = notehead_codepoint(duration, slash);
|
||||
if (has_stem(duration) && !heads.empty()) {
|
||||
auto stem_up = (low_y + high_y) * 0.5F >= center;
|
||||
if (stem_up) {
|
||||
auto base_origin = origin_for_center(notehead, x, low_y, spacing);
|
||||
auto base = anchor_point(base_origin, stem_up_anchor(notehead), spacing);
|
||||
auto stem_top = std::min(high_y, center) - spacing * 3.6F;
|
||||
context.stroke_line(base, {.x = base.x, .y = stem_top}, 1.6F, ink);
|
||||
if (has_flag(duration)) {
|
||||
draw_eighth_flag(true, base.x, stem_top, spacing, ink);
|
||||
}
|
||||
} else {
|
||||
auto base_origin = origin_for_center(notehead, x, high_y, spacing);
|
||||
auto base = anchor_point(base_origin, stem_down_anchor(notehead), spacing);
|
||||
auto stem_bottom = std::max(low_y, center) + spacing * 3.6F;
|
||||
context.stroke_line(base, {.x = base.x, .y = stem_bottom}, 1.6F, ink);
|
||||
if (has_flag(duration)) {
|
||||
draw_eighth_flag(false, base.x, stem_bottom, spacing, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duration.dots != 0) {
|
||||
auto dot_origin = origin_for_center(notehead, x, low_y, spacing);
|
||||
auto dot_x = dot_origin.x + glyph_box(notehead).east * spacing + spacing * 0.85F;
|
||||
draw_duration_dot(context, dot_x, dot_y_for(low_y, staff_top, spacing), spacing, ink);
|
||||
}
|
||||
if (note.tie_to_next) {
|
||||
draw_curve(context, {.x = x + 7.0F, .y = low_y + 9.0F}, {.x = (x + next_x) * 0.5F, .y = low_y + 19.0F}, {.x = next_x - 7.0F, .y = low_y + 9.0F}, 1.4F, ink);
|
||||
}
|
||||
}
|
||||
|
||||
auto note_x(std::float32_t start_x, std::float32_t width, std::int64_t total_ticks, std::int64_t elapsed_ticks, std::int64_t duration_ticks) -> std::float32_t {
|
||||
auto center_ticks = static_cast<std::float32_t>(elapsed_ticks) + static_cast<std::float32_t>(duration_ticks) * 0.5F;
|
||||
auto ratio = std::clamp(center_ticks / static_cast<std::float32_t>(total_ticks), 0.0F, 1.0F);
|
||||
return start_x + width * ratio;
|
||||
}
|
||||
|
||||
auto draw_measure(luma::draw_context& context, vamp_zero::score_measure const& measure, luma::rect bounds, std::size_t measure_number) -> void {
|
||||
constexpr auto spacing = std::float32_t{8.0F};
|
||||
auto staff_top = bounds.y + 52.0F;
|
||||
draw_text(context, bounds.x + 5.0F, bounds.y + 1.0F, std::to_string(measure_number), 1, vamp_style::muted, 42.0F);
|
||||
if (!measure.section_label.empty()) {
|
||||
draw_text(context, bounds.x + 28.0F, bounds.y - 2.0F, measure.section_label, 2, vamp_style::selection, bounds.width - 32.0F);
|
||||
}
|
||||
if (!measure.time_signature.empty() && measure_number == 1U) {
|
||||
draw_text(context, bounds.x + 8.0F, staff_top + 2.0F, measure.time_signature, 1, vamp_style::paper_ink, 42.0F);
|
||||
}
|
||||
|
||||
auto notation_x = bounds.x + (measure_number == 1U ? 43.0F : 24.0F);
|
||||
auto notation_width = std::max(24.0F, bounds.width - (notation_x - bounds.x) - 16.0F);
|
||||
auto total_ticks = measure_ticks(measure);
|
||||
auto elapsed = std::int64_t{};
|
||||
auto previous_chord = std::string{};
|
||||
for (auto index = std::size_t{}; index < measure.notes.size(); ++index) {
|
||||
auto const& note = measure.notes[index];
|
||||
auto duration_ticks = spacing_ticks(note);
|
||||
auto x = note_x(notation_x, notation_width, total_ticks, elapsed, duration_ticks);
|
||||
auto next_duration = index + 1U < measure.notes.size() ? spacing_ticks(measure.notes[index + 1U]) : duration_ticks;
|
||||
auto next_x = note_x(notation_x, notation_width, total_ticks, elapsed + duration_ticks, next_duration);
|
||||
if (!note.hidden && !note.chord_symbol.empty() && note.chord_symbol != previous_chord) {
|
||||
draw_text(context, x - 18.0F, bounds.y + 24.0F, note.chord_symbol, 2, vamp_style::paper_ink, std::min(92.0F, bounds.width - (x - bounds.x)));
|
||||
previous_chord = note.chord_symbol;
|
||||
}
|
||||
draw_note(context, note, x, staff_top, spacing, next_x, note.notehead == "slash");
|
||||
if (!note.hidden && !note.lyric.empty()) {
|
||||
draw_text(context, x - 12.0F, staff_top + 46.0F, note.lyric, 1, vamp_style::muted, 66.0F);
|
||||
}
|
||||
elapsed += duration_ticks;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void {
|
||||
context.fill_rect(bounds, vamp_zero::to_luma(vamp_style::paper));
|
||||
context.stroke_rect(bounds, 2.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||
context.push_clip(bounds);
|
||||
|
||||
auto margin = std::float32_t{28.0F};
|
||||
auto header_y = bounds.y + 16.0F;
|
||||
draw_text(context, bounds.x + margin, header_y, score.title, 3, vamp_style::paper_ink, bounds.width * 0.55F);
|
||||
draw_text(context, bounds.x + bounds.width - 220.0F, header_y + 2.0F, score.part, 3, vamp_style::selection, 190.0F);
|
||||
draw_text(context, bounds.x + margin, header_y + 34.0F, score.composer, 1, vamp_style::muted, bounds.width * 0.6F);
|
||||
draw_text(context, bounds.x + bounds.width - 122.0F, header_y + 39.0F, "PAGE " + std::to_string(page_index + 1U), 1, vamp_style::muted, 110.0F);
|
||||
|
||||
auto content_x = bounds.x + margin;
|
||||
auto content_y = bounds.y + 84.0F;
|
||||
auto content_width = bounds.width - margin * 2.0F;
|
||||
auto system_height = (bounds.height - 108.0F) / static_cast<std::float32_t>(vamp_zero::score_systems_per_page);
|
||||
auto measure_width = content_width / static_cast<std::float32_t>(vamp_zero::score_measures_per_system);
|
||||
auto first_measure = page_index * vamp_zero::score_measures_per_page;
|
||||
|
||||
for (auto system = std::size_t{}; system < vamp_zero::score_systems_per_page; ++system) {
|
||||
auto system_y = content_y + static_cast<std::float32_t>(system) * system_height;
|
||||
auto staff_top = system_y + 52.0F;
|
||||
draw_staff(context, content_x, staff_top, content_width, 8.0F);
|
||||
for (auto measure = std::size_t{}; measure < vamp_zero::score_measures_per_system; ++measure) {
|
||||
auto measure_index = first_measure + system * vamp_zero::score_measures_per_system + measure;
|
||||
auto x = content_x + static_cast<std::float32_t>(measure) * measure_width;
|
||||
context.stroke_line({.x = x, .y = staff_top}, {.x = x, .y = staff_top + 32.0F}, 1.2F, vamp_zero::to_luma(vamp_style::paper_ink));
|
||||
if (measure_index < score.measures.size()) {
|
||||
draw_measure(context, score.measures[measure_index], {.x = x, .y = system_y, .width = measure_width, .height = system_height}, measure_index + 1U);
|
||||
}
|
||||
}
|
||||
context.stroke_line({.x = content_x + content_width, .y = staff_top}, {.x = content_x + content_width, .y = staff_top + 32.0F}, 2.0F, vamp_zero::to_luma(vamp_style::paper_ink));
|
||||
}
|
||||
|
||||
context.pop_clip();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
auto vamp_zero::to_string(vamp_zero::hardware_mode value) -> std::string {
|
||||
switch (value) {
|
||||
case vamp_zero::hardware_mode::FAKE:
|
||||
return "fake";
|
||||
case vamp_zero::hardware_mode::REAL:
|
||||
return "real";
|
||||
}
|
||||
return "fake";
|
||||
}
|
||||
|
||||
auto vamp_zero::to_string(vamp_zero::app_mode value) -> std::string {
|
||||
switch (value) {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return "main_menu";
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return "performance_song_menu";
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
return "performance_part_menu";
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return "performance_score";
|
||||
case vamp_zero::app_mode::PRACTICE_TYPE_MENU:
|
||||
return "practice_type_menu";
|
||||
case vamp_zero::app_mode::PRACTICE_LESSON_MENU:
|
||||
return "practice_lesson_menu";
|
||||
case vamp_zero::app_mode::PRACTICE_EXERCISE_MENU:
|
||||
return "practice_exercise_menu";
|
||||
case vamp_zero::app_mode::PRACTICE_KEY_MENU:
|
||||
return "practice_key_menu";
|
||||
case vamp_zero::app_mode::PRACTICE_SCORE:
|
||||
return "practice_score";
|
||||
}
|
||||
return "main_menu";
|
||||
}
|
||||
|
||||
auto vamp_zero::practice_key_names() -> std::vector<std::string_view> const& {
|
||||
static auto const keys = std::vector<std::string_view>{
|
||||
"C",
|
||||
"Db",
|
||||
"D",
|
||||
"Eb",
|
||||
"E",
|
||||
"F",
|
||||
"Gb",
|
||||
"G",
|
||||
"Ab",
|
||||
"A",
|
||||
"Bb",
|
||||
"B",
|
||||
};
|
||||
return keys;
|
||||
}
|
||||
|
||||
auto vamp_zero::practice_type_supports_key_selection(std::string_view practice_type_id) -> std::bool_t {
|
||||
return practice_type_id == "melody";
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
#include <vamp_zero/ui.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
|
||||
#include <luma.hpp>
|
||||
|
||||
#include <vamp_style.hpp>
|
||||
#include <vamp_style/font.hpp>
|
||||
#include <vamp_zero/color.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
|
||||
auto const& bind = config.control.endpoint.bind;
|
||||
auto colon = bind.rfind(':');
|
||||
if (colon == std::string::npos) {
|
||||
return 8080;
|
||||
}
|
||||
try {
|
||||
return static_cast<std::uint16_t>(std::stoul(bind.substr(colon + 1U)));
|
||||
} catch (...) {
|
||||
return 8080;
|
||||
}
|
||||
}
|
||||
|
||||
auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) {
|
||||
return part.id == state.active_part_id;
|
||||
});
|
||||
return found == state.performance.parts.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.performance.groups[index];
|
||||
}
|
||||
|
||||
auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) {
|
||||
return exercise.id == state.active_practice_id;
|
||||
});
|
||||
return found == state.practice.exercises.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto 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()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.practice.types[index];
|
||||
}
|
||||
|
||||
auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* {
|
||||
auto const* type = selected_practice_type(state);
|
||||
if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_lesson_index - 1U;
|
||||
if (index >= type->lesson_groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &type->lesson_groups[index];
|
||||
}
|
||||
|
||||
auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto const* lesson = selected_practice_lesson(state);
|
||||
if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_exercise_index - 1U;
|
||||
if (index >= lesson->exercises.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &lesson->exercises[index];
|
||||
}
|
||||
|
||||
auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t {
|
||||
return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id);
|
||||
}
|
||||
|
||||
auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* {
|
||||
if (exercise.variants.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!supports_key_selection(exercise)) {
|
||||
return &exercise.variants.front();
|
||||
}
|
||||
if (state.selected_practice_key_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_key_index - 1U;
|
||||
if (index >= exercise.variants.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &exercise.variants[index];
|
||||
}
|
||||
|
||||
auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view> {
|
||||
auto const* variant = selected_practice_variant(state, exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return variant->key_signature;
|
||||
}
|
||||
|
||||
auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string {
|
||||
if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) {
|
||||
return variant->key_signature;
|
||||
}
|
||||
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature;
|
||||
}
|
||||
|
||||
auto status_color(vamp_zero::app_state const& state) -> vamp_style::color {
|
||||
return state.pico_connected ? vamp_style::connected : vamp_style::retrying;
|
||||
}
|
||||
|
||||
auto text_style(std::int32_t scale) -> luma::text_style {
|
||||
auto size = 13.0F;
|
||||
if (scale == 2) {
|
||||
size = 20.0F;
|
||||
} else if (scale == 3) {
|
||||
size = 26.0F;
|
||||
} else if (scale >= 4) {
|
||||
size = 34.0F;
|
||||
}
|
||||
return {
|
||||
.font = std::string{vamp_style::ui_font_path},
|
||||
.size = size,
|
||||
.line_height = size + 5.0F,
|
||||
};
|
||||
}
|
||||
|
||||
auto draw_text(luma::draw_context& context, std::float32_t x, std::float32_t y, std::string const& text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void {
|
||||
if (text.empty() || max_width <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
auto style = text_style(scale);
|
||||
context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height});
|
||||
context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color));
|
||||
context.pop_clip();
|
||||
}
|
||||
|
||||
auto draw_header(luma::draw_context& context, luma::rect bounds, vamp_zero::ui_state const& state) -> void {
|
||||
context.fill_rect({.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel));
|
||||
context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F}, vamp_zero::to_luma(vamp_style::accent));
|
||||
draw_text(context, bounds.x + 28.0F, bounds.y + 18.0F, "PRODUCT VAMP", 4, vamp_style::ink, 360.0F);
|
||||
draw_text(context, bounds.x + 30.0F, bounds.y + 55.0F, "AP " + state.ap_ssid + " " + state.zero_ip + ":" + std::to_string(state.port), 1, vamp_style::muted, 520.0F);
|
||||
|
||||
auto indicator = status_color(state.app);
|
||||
auto x = bounds.x + bounds.width - 148.0F;
|
||||
context.fill_rect({.x = x, .y = bounds.y + 22.0F, .width = 24.0F, .height = 24.0F}, vamp_zero::to_luma(indicator));
|
||||
draw_text(context, x + 36.0F, bounds.y + 26.0F, "PICO", 2, vamp_style::ink, 88.0F);
|
||||
}
|
||||
|
||||
auto draw_row(luma::draw_context& context, luma::rect row, std::string const& primary, std::string const& secondary, std::bool_t selected) -> void {
|
||||
if (selected) {
|
||||
context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection));
|
||||
}
|
||||
context.stroke_line({.x = row.x, .y = row.y + row.height}, {.x = row.x + row.width, .y = row.y + row.height}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||
draw_text(context, row.x + 16.0F, row.y + 10.0F, primary, 2, vamp_style::ink, row.width * 0.62F);
|
||||
if (!secondary.empty()) {
|
||||
draw_text(context, row.x + row.width - 250.0F, row.y + 17.0F, secondary, 1, selected ? vamp_style::ink : vamp_style::muted, 230.0F);
|
||||
}
|
||||
}
|
||||
|
||||
auto first_visible_row(std::size_t selected, std::size_t total, std::size_t max_rows) -> std::size_t {
|
||||
if (total <= max_rows || selected < max_rows) {
|
||||
return 0U;
|
||||
}
|
||||
return selected - max_rows + 1U;
|
||||
}
|
||||
|
||||
auto draw_main_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "MODE", 4, vamp_style::ink, 260.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT WORKFLOW", 1, vamp_style::muted, 320.0F);
|
||||
auto list_y = bounds.y + 112.0F;
|
||||
auto row_height = std::float32_t{54.0F};
|
||||
auto row_width = bounds.width - 72.0F;
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height}, "Performance Mode", std::to_string(state.performance.groups.size()) + " SONGS", state.selected_main_menu_index == 0U);
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height}, "Practice Mode", std::to_string(state.practice.exercises.size()) + " EXERCISES", state.selected_main_menu_index == 1U);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 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) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_song_index == 0U);
|
||||
continue;
|
||||
}
|
||||
auto const& group = state.performance.groups[index - 1U];
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, group.title, std::to_string(group.parts.size()) + " PARTS", index == state.selected_song_index);
|
||||
}
|
||||
if (state.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);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_performance_part_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto const* group = selected_group(state);
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, group == nullptr ? std::string{"PARTS"} : group->title, 4, vamp_style::ink, bounds.width - 72.0F);
|
||||
if (group == nullptr) {
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO PARTS FOUND", 3, vamp_style::retrying, 420.0F);
|
||||
return;
|
||||
}
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, group->composer, 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
|
||||
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 = group->parts.size() + 1U;
|
||||
auto max_rows = std::min<std::size_t>(total_rows, 7U);
|
||||
auto first = first_visible_row(state.selected_part_index, total_rows, max_rows);
|
||||
for (auto row = std::size_t{}; row < max_rows; ++row) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_part_index == 0U);
|
||||
continue;
|
||||
}
|
||||
auto const& part = group->parts[index - 1U];
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, part.part, std::to_string(part.total_pages) + " PAGES", index == state.selected_part_index);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 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) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_type_index == 0U);
|
||||
continue;
|
||||
}
|
||||
auto const& type = state.practice.types[index - 1U];
|
||||
auto exercise_count = std::size_t{};
|
||||
for (auto const& lesson : type.lesson_groups) {
|
||||
exercise_count += lesson.exercises.size();
|
||||
}
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, type.title, std::to_string(exercise_count) + " EXERCISES", index == state.selected_practice_type_index);
|
||||
}
|
||||
if (state.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);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_practice_lesson_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto const* type = selected_practice_type(state);
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, type == nullptr ? std::string{"PRACTICE"} : type->title, 4, vamp_style::ink, bounds.width - 72.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT LESSON GROUP", 1, vamp_style::muted, 360.0F);
|
||||
if (type == nullptr) {
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO LESSON GROUPS FOUND", 3, vamp_style::retrying, 520.0F);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = type->lesson_groups.size() + 1U;
|
||||
auto max_rows = std::min<std::size_t>(total_rows, 7U);
|
||||
auto first = first_visible_row(state.selected_practice_lesson_index, total_rows, max_rows);
|
||||
for (auto row = std::size_t{}; row < max_rows; ++row) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_lesson_index == 0U);
|
||||
continue;
|
||||
}
|
||||
auto const& lesson = type->lesson_groups[index - 1U];
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, lesson.title, std::to_string(lesson.exercises.size()) + " EXERCISES", index == state.selected_practice_lesson_index);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_practice_exercise_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto const* lesson = selected_practice_lesson(state);
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, lesson == nullptr ? std::string{"EXERCISES"} : lesson->title, 4, vamp_style::ink, bounds.width - 72.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT EXERCISE", 1, vamp_style::muted, 320.0F);
|
||||
if (lesson == nullptr) {
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO EXERCISES FOUND", 3, vamp_style::retrying, 440.0F);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = lesson->exercises.size() + 1U;
|
||||
auto max_rows = std::min<std::size_t>(total_rows, 7U);
|
||||
auto first = first_visible_row(state.selected_practice_exercise_index, total_rows, max_rows);
|
||||
for (auto row = std::size_t{}; row < max_rows; ++row) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_exercise_index == 0U);
|
||||
continue;
|
||||
}
|
||||
auto const& exercise = lesson->exercises[index - 1U];
|
||||
auto secondary = supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise);
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, exercise.title, secondary, index == state.selected_practice_exercise_index);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_practice_key_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto const* exercise = selected_practice_exercise(state);
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, exercise == nullptr ? std::string{"KEY"} : exercise->title, 4, vamp_style::ink, bounds.width - 72.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT PRACTICE KEY", 1, vamp_style::muted, 360.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 = (exercise == nullptr ? 0U : exercise->variants.size()) + 1U;
|
||||
auto max_rows = std::min<std::size_t>(total_rows, 7U);
|
||||
auto first = first_visible_row(state.selected_practice_key_index, total_rows, max_rows);
|
||||
for (auto row = std::size_t{}; row < max_rows; ++row) {
|
||||
auto index = first + row;
|
||||
if (index == 0U) {
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_key_index == 0U);
|
||||
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;
|
||||
draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast<std::float32_t>(row) * row_height, .width = row_width, .height = row_height}, key, source, index == state.selected_practice_key_index);
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto source_json = std::string{};
|
||||
auto source_path = std::string{};
|
||||
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) {
|
||||
draw_practice_type_menu(context, bounds, state);
|
||||
return;
|
||||
}
|
||||
auto const* variant = selected_practice_variant(state, *exercise);
|
||||
if (variant == nullptr) {
|
||||
draw_practice_key_menu(context, bounds, state);
|
||||
return;
|
||||
}
|
||||
source_json = variant->source_json;
|
||||
source_path = variant->source_path;
|
||||
}
|
||||
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, source_path);
|
||||
if (!parsed) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
if (exercise != nullptr) {
|
||||
parsed->part = "Key: " + effective_practice_key(state, *exercise);
|
||||
}
|
||||
}
|
||||
auto paper_bounds = luma::rect{
|
||||
.x = bounds.x + 24.0F,
|
||||
.y = bounds.y + 18.0F,
|
||||
.width = bounds.width - 48.0F,
|
||||
.height = bounds.height - 36.0F,
|
||||
};
|
||||
vamp_zero::draw_score_page(context, paper_bounds, *parsed, state.page_index);
|
||||
}
|
||||
|
||||
class vamp_root_control final : public luma::control {
|
||||
public:
|
||||
explicit vamp_root_control(vamp_zero::ui_state state) : state_{std::move(state)} {}
|
||||
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 area = bounds();
|
||||
context.fill_rect(area, vamp_zero::to_luma(vamp_style::background));
|
||||
draw_header(context, area, state_);
|
||||
auto content = luma::rect{
|
||||
.x = area.x,
|
||||
.y = area.y + 80.0F,
|
||||
.width = area.width,
|
||||
.height = std::max(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) {
|
||||
draw_score(context, content, state_.app);
|
||||
} else {
|
||||
draw_main_menu(context, content, state_.app);
|
||||
}
|
||||
}
|
||||
private:
|
||||
vamp_zero::ui_state state_{};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config) : executor_{std::move(executor)}, application_{std::move(config)} {}
|
||||
|
||||
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) {
|
||||
return status;
|
||||
}
|
||||
return application_.run();
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::stop() -> void {
|
||||
application_.request_stop();
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error> {
|
||||
{
|
||||
auto 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());
|
||||
}
|
||||
return application_.set_root(std::move(*root));
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
auto vamp_zero::make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state {
|
||||
return vamp_zero::ui_state{
|
||||
.app = std::move(app),
|
||||
.ap_ssid = config.control.ap_ssid,
|
||||
.zero_ip = config.control.zero_ip,
|
||||
.port = bind_port(config),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user