249 lines
11 KiB
C++
249 lines
11 KiB
C++
#include <boost/test/unit_test.hpp>
|
|
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <string>
|
|
|
|
#include <boost/json/parse.hpp>
|
|
#include <boost/json/object.hpp>
|
|
|
|
#include <raylib.h>
|
|
|
|
#include <vamp_pico/screen.hpp>
|
|
#include <vamp_zero/menu_view.hpp>
|
|
#include <vamp_zero/page_control.hpp>
|
|
#include <vamp_zero/notation.hpp>
|
|
#include <vamp_zero/performance.hpp>
|
|
#include <vamp_zero/score.hpp>
|
|
#include <vamp_zero/color.hpp>
|
|
|
|
|
|
namespace {
|
|
|
|
auto read_bytes(std::filesystem::path const &path) -> std::string {
|
|
auto input = std::ifstream{path, std::ios::binary};
|
|
BOOST_REQUIRE(input.good());
|
|
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
|
}
|
|
|
|
auto color(luma::color value) -> Color {
|
|
return {value.r, value.g, value.b, value.a};
|
|
}
|
|
|
|
auto rectangle(luma::rect value) -> Rectangle {
|
|
return {value.x, value.y, value.width, value.height};
|
|
}
|
|
|
|
// Uses the same raylib glyph rasterizer and metrics as Luma, without a GPU/window.
|
|
|
|
class image_context final : public luma::draw_context {
|
|
public:
|
|
image_context() = default;
|
|
~image_context() override {
|
|
UnloadImage(image);
|
|
for (auto const &[size, font] : fonts_) {
|
|
UnloadFontData(font.glyphs, font.glyphCount);
|
|
MemFree(font.recs);
|
|
}
|
|
}
|
|
auto fill_rect(luma::rect bounds, luma::color value) -> void override {
|
|
ImageDrawRectangleRec(&image, rectangle(bounds), color(value));
|
|
}
|
|
auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color value) -> void override {
|
|
ImageDrawRectangleLines(&image, rectangle(bounds), static_cast<std::int32_t>(thickness), color(value));
|
|
}
|
|
auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color value) -> void override {
|
|
ImageDrawLineEx(&image, {from.x, from.y}, {to.x, to.y}, static_cast<std::int32_t>(thickness), color(value));
|
|
}
|
|
auto fill_ellipse(luma::rect bounds, luma::color value) -> void override {
|
|
BOOST_TEST(bounds.width == bounds.height);
|
|
ImageDrawCircle(&image, static_cast<std::int32_t>(bounds.x + bounds.width / 2.0F), static_cast<std::int32_t>(bounds.y + bounds.height / 2.0F), static_cast<std::int32_t>(bounds.width / 2.0F), color(value));
|
|
}
|
|
auto stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void override {
|
|
BOOST_FAIL("unexpected ellipse outline");
|
|
}
|
|
auto draw_texture(luma::texture const &, luma::rect, luma::rect, luma::color) -> void override {
|
|
BOOST_FAIL("menu must not load score textures");
|
|
}
|
|
auto draw_text(std::string_view text, luma::point origin, luma::text_style const &style, luma::color value) -> void override {
|
|
BOOST_TEST(style.font == vamp_style::ui_font_path);
|
|
auto measured = measure_text(text, style);
|
|
BOOST_REQUIRE(clip_.has_value());
|
|
BOOST_TEST(measured.width <= clip_->width);
|
|
BOOST_TEST(origin.y >= clip_->y);
|
|
BOOST_TEST(origin.y + measured.height <= clip_->y + clip_->height);
|
|
auto face = font(style.size);
|
|
auto owned = std::string{text};
|
|
auto x = origin.x;
|
|
for (auto i = std::size_t{}; i < owned.size();) {
|
|
auto bytes = std::int32_t{};
|
|
auto codepoint = GetCodepointNext(owned.c_str() + i, &bytes);
|
|
auto index = GetGlyphIndex(face, codepoint);
|
|
auto const &glyph = face.glyphs[index];
|
|
if (codepoint != ' ') {
|
|
ImageDraw(&image, glyph.image, {0.0F, 0.0F, face.recs[index].width, face.recs[index].height}, {x + static_cast<std::float32_t>(glyph.offsetX), origin.y + static_cast<std::float32_t>(glyph.offsetY), face.recs[index].width, face.recs[index].height}, color(value));
|
|
}
|
|
x += (glyph.advanceX == 0 ? face.recs[index].width : static_cast<std::float32_t>(glyph.advanceX)) + 1.0F;
|
|
i += static_cast<std::size_t>(bytes);
|
|
}
|
|
}
|
|
auto push_clip(luma::rect bounds) -> void override {
|
|
BOOST_TEST(bounds.x >= 0.0F);
|
|
BOOST_TEST(bounds.y >= 0.0F);
|
|
BOOST_TEST(bounds.x + bounds.width <= 1024.0F);
|
|
BOOST_TEST(bounds.y + bounds.height <= 600.0F);
|
|
clip_ = bounds;
|
|
}
|
|
auto pop_clip() -> void override {
|
|
clip_.reset();
|
|
}
|
|
auto measure_text(std::string_view text, luma::text_style const &style) const -> luma::size override {
|
|
auto size = MeasureTextEx(font(style.size), std::string{text}.c_str(), style.size, 1.0F);
|
|
return {size.x, size.y};
|
|
}
|
|
Image image{GenImageColor(1024, 600, {vamp_style::background.r, vamp_style::background.g, vamp_style::background.b, 255})};
|
|
private:
|
|
auto font(std::float32_t size) const -> Font {
|
|
auto key = static_cast<std::int32_t>(std::ceil(size));
|
|
auto found = fonts_.find(key);
|
|
if (found != fonts_.end()) {
|
|
return found->second;
|
|
}
|
|
auto bytes = read_bytes(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf");
|
|
auto face = Font{};
|
|
face.baseSize = key;
|
|
face.glyphs = LoadFontData(reinterpret_cast<std::uchar_t const *>(bytes.data()), static_cast<std::int32_t>(bytes.size()), key, nullptr, 95, FONT_DEFAULT, &face.glyphCount);
|
|
BOOST_REQUIRE(face.glyphs != nullptr);
|
|
auto atlas = GenImageFontAtlas(face.glyphs, &face.recs, face.glyphCount, key, 4, 0);
|
|
BOOST_REQUIRE(atlas.data != nullptr);
|
|
for (auto i = std::int32_t{}; i < face.glyphCount; ++i) {
|
|
UnloadImage(face.glyphs[i].image);
|
|
face.glyphs[i].image = ImageFromImage(atlas, face.recs[i]);
|
|
}
|
|
UnloadImage(atlas);
|
|
face.texture.id = 1U;
|
|
fonts_.emplace(key, face);
|
|
return face;
|
|
}
|
|
mutable std::map<std::int32_t, Font> fonts_{};
|
|
std::optional<luma::rect> clip_{};
|
|
};
|
|
|
|
auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pico::link_status link = vamp_pico::link_status::CONNECTED, std::bool_t busy = false) -> void {
|
|
auto directory = std::filesystem::path{VAMP_TEST_UI_DIR};
|
|
std::filesystem::create_directories(directory);
|
|
auto context = image_context{};
|
|
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
|
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
|
auto source = vamp_zero::parse_notation_document(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"), "score.json");
|
|
BOOST_REQUIRE(source);
|
|
auto playback = vamp_zero::automatic(state.audio);
|
|
auto const &pages = playback ? source->playback_pages : source->pages;
|
|
auto const &page = pages.at(state.page_index);
|
|
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / page.path;
|
|
auto sheet = LoadImage(path.c_str());
|
|
BOOST_REQUIRE(sheet.data);
|
|
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
|
|
UnloadImage(sheet);
|
|
if (playback) {
|
|
if (auto highlight = vamp_zero::measure_highlight(page, state.active_measure, {24, 98, 976, 484})) {
|
|
context.stroke_rect(*highlight, 1.5F, vamp_zero::to_luma(vamp_style::accent));
|
|
}
|
|
}
|
|
} else {
|
|
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
|
}
|
|
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero_" + name + ".png")).c_str()));
|
|
auto remote = vamp_pico::remote_display_state{};
|
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
|
auto title = vamp_pico::title_cache{};
|
|
if (remote.title_image[0] != 0) {
|
|
title.select(remote.title_image.data());
|
|
BOOST_REQUIRE(title.accept(remote.title_image.data(), read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "pico/titles" / (std::string{remote.title_image.data()} + ".mask"))));
|
|
}
|
|
auto atlas = read_bytes(directory / "static.p4");
|
|
auto image = GenImageColor(320, 240, WHITE);
|
|
auto row = std::array<std::uint16_t, vamp_pico::display_width>{};
|
|
auto ink = std::size_t{};
|
|
for (auto y = 0U; y < 240U; ++y) {
|
|
vamp_pico::render_scanline(row, y, remote, link, busy, title, atlas);
|
|
for (auto x = 0U; x < 320U; ++x) {
|
|
auto pixel = row[x];
|
|
auto value = Color{static_cast<std::uint8_t>((pixel >> 11U) * 255U / 31U), static_cast<std::uint8_t>(((pixel >> 5U) & 63U) * 255U / 63U), static_cast<std::uint8_t>((pixel & 31U) * 255U / 31U), 255};
|
|
ImageDrawPixel(&image, x, y, value);
|
|
ink += value.r > 128U && value.g > 128U && value.b > 128U ? 1U : 0U;
|
|
}
|
|
}
|
|
BOOST_TEST(ink > 200U);
|
|
BOOST_TEST(ink < 320U * 240U / 2U);
|
|
BOOST_REQUIRE(ExportImage(image, (directory / ("pico_" + name + ".png")).c_str()));
|
|
UnloadImage(image);
|
|
}
|
|
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays) {
|
|
auto state = vamp_zero::app_state{};
|
|
preview(state, "main");
|
|
preview(state, "offline", vamp_pico::link_status::OFFLINE);
|
|
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
|
|
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
|
state.pico_connected = true;
|
|
preview(state, "backing_unavailable");
|
|
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
|
preview(state, "empty");
|
|
auto catalog = std::make_shared<vamp_zero::catalog>();
|
|
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
|
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
|
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves", .playback_pages = score.at("presentation").at("playback_pages").as_array().size()});
|
|
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
|
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
|
|
state.catalog = catalog;
|
|
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
|
state.audio.backing_enabled = true;
|
|
state.audio.metronome_enabled = true;
|
|
preview(state, "settings");
|
|
state.selected_settings_index = vamp_zero::settings_row::solos;
|
|
preview(state, "solos_on");
|
|
state.solos_enabled = false;
|
|
preview(state, "solos_off");
|
|
state.solos_enabled = true;
|
|
state.selected_settings_index = vamp_zero::settings_row::tempo;
|
|
state.audio.tempo_editing = true;
|
|
preview(state, "tempo");
|
|
state.audio.tempo_editing = false;
|
|
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
|
state.selected_song_index = 1U;
|
|
preview(state, "songs");
|
|
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
|
state.selected_part_index = 1U;
|
|
preview(state, "parts");
|
|
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
|
state.active_part_id = "electric";
|
|
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
|
|
state.audio.phase = phase;
|
|
state.audio.backing_enabled = std::string_view{phase} != "Manual";
|
|
state.audio.metronome_enabled = state.audio.backing_enabled;
|
|
state.active_measure = std::string_view{phase} == "Playing" ? std::optional<std::size_t>{1} : std::nullopt;
|
|
preview(state, phase);
|
|
}
|
|
state.audio.phase = "Playing";
|
|
state.page_index = 1;
|
|
state.active_measure = score.at("presentation").at("playback_pages").as_array()[1].at("first_measure").to_number<std::size_t>();
|
|
preview(state, "transition");
|
|
state.page_index = catalog->parts[0].playback_pages - 1;
|
|
state.active_measure = score.at("music").at("staves").as_array()[0].at("measures").as_array().size();
|
|
state.audio.phase = "Sustain";
|
|
preview(state, "final_line");
|
|
state.audio.phase = "Error";
|
|
state.active_measure.reset();
|
|
state.audio.playback_error = "Recording unavailable";
|
|
preview(state, "audio_error");
|
|
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
|
catalog->groups[0].title = std::string(200U, 'M');
|
|
catalog->groups[0].composer = std::string(200U, 'W');
|
|
preview(state, "long_title");
|
|
}
|