@@ -0,0 +1 @@
|
|||||||
|
DisableFormat: true
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CompileFlags:
|
||||||
|
CompilationDatabase: build/build_x86_debug
|
||||||
|
Add:
|
||||||
|
- -Wall
|
||||||
|
- -Wextra
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
ClangTidy:
|
||||||
|
Remove: ['*']
|
||||||
|
---
|
||||||
|
If:
|
||||||
|
PathMatch: (include/vamp_pico/.*|src/vamp_pico/.*)
|
||||||
|
|
||||||
|
CompileFlags:
|
||||||
|
CompilationDatabase: build/build_pico_debug
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CONAN_HOME: build/build_common/conan_cache
|
||||||
|
MODULUS_DIRECT: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
fi
|
||||||
|
if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then
|
||||||
|
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||||
|
sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||||
|
printf '%s\n' \
|
||||||
|
'Types: deb' \
|
||||||
|
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
|
||||||
|
"Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \
|
||||||
|
'Components: main universe restricted multiverse' \
|
||||||
|
'Architectures: arm64' \
|
||||||
|
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
|
||||||
|
'' \
|
||||||
|
'Types: deb' \
|
||||||
|
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
|
||||||
|
"Suites: ${VERSION_CODENAME}-security" \
|
||||||
|
'Components: main universe restricted multiverse' \
|
||||||
|
'Architectures: arm64' \
|
||||||
|
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu-arm64.sources
|
||||||
|
elif [ -f /etc/apt/sources.list ]; then
|
||||||
|
sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list
|
||||||
|
sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list
|
||||||
|
printf '%s\n' \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \
|
||||||
|
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \
|
||||||
|
> /etc/apt/sources.list.d/ubuntu-arm64.list
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
dpkg --add-architecture arm64
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
cmake \
|
||||||
|
curl \
|
||||||
|
g++-12 \
|
||||||
|
g++-12-aarch64-linux-gnu \
|
||||||
|
gcc-12 \
|
||||||
|
gcc-12-aarch64-linux-gnu \
|
||||||
|
gcc-arm-none-eabi \
|
||||||
|
git \
|
||||||
|
libdrm-dev \
|
||||||
|
libdrm-dev:arm64 \
|
||||||
|
libegl-dev \
|
||||||
|
libegl-dev:arm64 \
|
||||||
|
libgbm-dev \
|
||||||
|
libgbm-dev:arm64 \
|
||||||
|
libgles-dev \
|
||||||
|
libgles-dev:arm64 \
|
||||||
|
libglvnd-dev \
|
||||||
|
libglvnd-dev:arm64 \
|
||||||
|
libnewlib-arm-none-eabi \
|
||||||
|
libstdc++-arm-none-eabi-newlib \
|
||||||
|
ninja-build \
|
||||||
|
pkg-config \
|
||||||
|
python3 \
|
||||||
|
python3-venv
|
||||||
|
python3 -m venv build/build_common/ci_python
|
||||||
|
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2
|
||||||
|
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Restore Conan cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: build/build_common/conan_cache
|
||||||
|
key: product-vamp-conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'conan/profiles/*') }}
|
||||||
|
restore-keys: product-vamp-conan-${{ runner.os }}-
|
||||||
|
- name: Configure native release
|
||||||
|
run: python3 modulus.py configure --target x86_64_gcc12_release
|
||||||
|
- name: Build native release
|
||||||
|
run: python3 modulus.py build --target x86_64_gcc12_release
|
||||||
|
- name: Fetch Pico SDK
|
||||||
|
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||||
|
env:
|
||||||
|
VAMP_PICO_SDK_VERSION: "2.3.0"
|
||||||
|
run: |
|
||||||
|
mkdir -p build/build_common
|
||||||
|
git clone \
|
||||||
|
--depth 1 \
|
||||||
|
--branch "$VAMP_PICO_SDK_VERSION" \
|
||||||
|
--recurse-submodules \
|
||||||
|
--shallow-submodules \
|
||||||
|
https://github.com/raspberrypi/pico-sdk.git \
|
||||||
|
build/build_common/pico-sdk
|
||||||
|
- name: Configure Pico LSP database
|
||||||
|
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||||
|
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py lsp --no-build-targets --database pico
|
||||||
|
- name: Create package
|
||||||
|
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||||
|
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py package --version latest --update
|
||||||
|
- name: Upload latest package
|
||||||
|
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||||
|
env:
|
||||||
|
PACKAGE_REGISTRY_USER: ${{ secrets.CONAN_USER }}
|
||||||
|
PACKAGE_REGISTRY_TOKEN: ${{ secrets.CONAN_TOKEN }}
|
||||||
|
run: python3 package/scripts/publish_latest_generic_package.py
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
build/
|
||||||
|
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.log
|
||||||
|
|
||||||
|
package/CMakeCache.txt
|
||||||
|
package/CMakeFiles/
|
||||||
|
package/metadata/
|
||||||
|
package/.ninja_*
|
||||||
|
package/*.json
|
||||||
|
package/*.cmake
|
||||||
|
package/*.ninja
|
||||||
|
package/*.sh
|
||||||
|
package/*-smoke
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.25)
|
||||||
|
|
||||||
|
set(VAMP_PROJECT_VERSION 0.1.0)
|
||||||
|
|
||||||
|
include(cmake/vamp_options.cmake)
|
||||||
|
|
||||||
|
if(VAMP_PICO_FIRMWARE_PROJECT)
|
||||||
|
include(cmake/pico.cmake)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
project(product-vamp VERSION ${VAMP_PROJECT_VERSION} LANGUAGES CXX)
|
||||||
|
|
||||||
|
include(cmake/zero.cmake)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Khodo
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Product Vamp
|
||||||
|
|
||||||
|
Product Vamp is a two-device sheet-music appliance.
|
||||||
|
|
||||||
|
- Raspberry Pi Zero 2 W hosts the control Wi-Fi AP, runs the display/control service in Docker, and drives a Waveshare Zero-DISP-7A 1024x600 touch display.
|
||||||
|
- Raspberry Pi Pico 2 W runs UF2 firmware for a Waveshare 2-inch capacitive touch LCD and sends menu/page commands over the Zero-hosted AP.
|
||||||
|
|
||||||
|
The Zero imports generated score assets into SQLite through Noto at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities.
|
||||||
|
|
||||||
|
Pico controls:
|
||||||
|
|
||||||
|
- Tap: next menu item or next score page.
|
||||||
|
- Double tap: previous menu item or previous score page.
|
||||||
|
- Tap and hold: select the highlighted mode/song/part/practice row/key, or return from score view to the previous menu.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Configure and build the native development target:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py configure
|
||||||
|
python3 modulus.py build
|
||||||
|
```
|
||||||
|
|
||||||
|
Release verification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py configure --target x86_64_gcc12_release
|
||||||
|
python3 modulus.py build --target x86_64_gcc12_release
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure/build can read public packages anonymously from the khodo Conan registry.
|
||||||
|
|
||||||
|
Refresh clangd compile databases, including the Pico firmware database, with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py lsp
|
||||||
|
```
|
||||||
|
|
||||||
|
When running directly outside the generated Modulus Docker image, set `PICO_SDK_PATH` before refreshing the Pico database.
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
Product packaging targets Raspberry Pi OS/Debian trixie on ARM64 and optionally builds Pico firmware. Set `PICO_SDK_PATH` when building the firmware artifact:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --version latest --update
|
||||||
|
```
|
||||||
|
|
||||||
|
The package command creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz
|
||||||
|
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
The tarball contains the Zero Docker archive, Pi install scripts, generated score assets, and Pico UF2 firmware.
|
||||||
|
|
||||||
|
Publishing the rolling generic package requires credentials only for upload:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PACKAGE_REGISTRY_USER=<user> \
|
||||||
|
PACKAGE_REGISTRY_TOKEN=<token> \
|
||||||
|
python3 package/scripts/publish_latest_generic_package.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts are published to the khodo generic package registry.
|
||||||
|
|
||||||
|
## Score Assets
|
||||||
|
|
||||||
|
Source score JSON lives in `data/`. Generated score JSON is not committed. To inspect generated assets without building the full package, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --static-assets-dir package/assets --clean --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
||||||
|
|
||||||
|
Bundled third-party font assets live in `package/assets/fonts/petaluma`. Petaluma is from Steinberg and is distributed under the SIL Open Font License; the package keeps `OFL.txt` next to the font files. If the Pico bitmap font needs to be refreshed after changing the bundled UI font, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/generate_pico_font.py
|
||||||
|
python3 tools/generate_pico_font.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Product Vamp is released under the MIT License. Bundled third-party fonts keep their own licenses next to the font files.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Product Vamp Device Setup
|
||||||
|
|
||||||
|
## Raspberry Pi Zero 2 W
|
||||||
|
|
||||||
|
Flash Raspberry Pi OS Lite 64-bit, enable SSH, and boot the device.
|
||||||
|
|
||||||
|
Install the package tarball from Gitea or build one locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py package
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the tarball to the Zero, verify its `.sha256`, unpack it anywhere, and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo zero/install-zero.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer loads `zero/product-vamp-zero-linux-arm64.docker.tar`, installs `/etc/product-vamp/vamp.json` if missing, configures NetworkManager AP mode, and enables the systemd units.
|
||||||
|
|
||||||
|
On reinstalls, the installer skips apt entirely when the required OS packages are already present. If packages are missing, the Zero needs upstream internet before the installer can finish provisioning Docker, NetworkManager, and SQLite.
|
||||||
|
|
||||||
|
Product Vamp owns `tty1` while the display service is running so the DISP-7A shows the appliance UI instead of the Raspberry Pi OS Lite login console. Use SSH for normal administration. `tty2` is enabled as the local fallback console.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
|
||||||
|
- SSID: `Vamp Control`
|
||||||
|
- Passphrase: `vamp-control`
|
||||||
|
- Zero IP: `192.168.42.1`
|
||||||
|
- API: `http://192.168.42.1:8080`
|
||||||
|
|
||||||
|
For API-only quick checks, set `runtime.headless` to `true` or run `vamp-zero --once` from a development build.
|
||||||
|
|
||||||
|
Score inputs are tracked under `data/`. The build/package step runs the Python generator and packages the generated songs and practice variants into the Zero image automatically; generated JSON under the build directory does not need to be copied by hand.
|
||||||
|
|
||||||
|
## Raspberry Pi Pico 2 W
|
||||||
|
|
||||||
|
Hold BOOTSEL while plugging the Pico into USB, then copy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pico/product-vamp-pico2w.uf2
|
||||||
|
```
|
||||||
|
|
||||||
|
to the mounted `RPI-RP2` drive.
|
||||||
|
|
||||||
|
The default firmware connects to SSID `Vamp Control` with passphrase `vamp-control`, then talks to `192.168.42.1:8080`.
|
||||||
|
|
||||||
|
The Pico screen is landscape. Its top bar is blue while connecting, yellow while retrying or when the Zero API is unavailable, and green when the Zero responds. The area below the bar stays blank until a performance or practice score is active, then it shows the title, selected part or active practice key, and page count.
|
||||||
|
|
||||||
|
Controls:
|
||||||
|
|
||||||
|
- Tap: next menu item or next page.
|
||||||
|
- Double tap: previous menu item or previous page.
|
||||||
|
- Tap and hold: select the highlighted menu row, or return from score view to the previous menu.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Product Vamp Technical Guide
|
||||||
|
|
||||||
|
## Repository Shape
|
||||||
|
|
||||||
|
Product Vamp follows the Modulus product layout used by the khodo repos:
|
||||||
|
|
||||||
|
- `include/vamp_zero` and `src/vamp_zero`: Pi Zero C++23 application.
|
||||||
|
- `include/vamp_pico` and `src/vamp_pico`: Pi Pico 2 W C++17 firmware.
|
||||||
|
- `include/vamp_protocol` and `src/vamp_protocol`: shared route and page-command contracts used by both devices.
|
||||||
|
- `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`.
|
||||||
|
- `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style`: shared color palette, Petaluma font paths, and Pico bitmap font data.
|
||||||
|
- `data`: authored source score JSON for performance songs and practice exercises.
|
||||||
|
- `generator`: build-time Python preprocessing for performance arrangements and practice variants.
|
||||||
|
- `package`: generic bundle inputs, scripts, config, migrations, static font assets, and device install files.
|
||||||
|
|
||||||
|
## Zero Runtime
|
||||||
|
|
||||||
|
The Zero process can run headless for local/API quick checks or with Luma for the Waveshare Zero-DISP-7A display. The API is plain HTTP on the private AP:
|
||||||
|
|
||||||
|
- `GET /api/status`
|
||||||
|
- `GET /api/remote/display`
|
||||||
|
- `POST /api/remote/heartbeat`
|
||||||
|
- `POST /api/remote/next`
|
||||||
|
- `POST /api/remote/previous`
|
||||||
|
- `POST /api/remote/select`
|
||||||
|
|
||||||
|
Legacy `/api/page/next` and `/api/page/previous` routes are still accepted for simple manual checks.
|
||||||
|
|
||||||
|
The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP ciphers so the Pico W can join using `CYW43_AUTH_WPA2_AES_PSK`.
|
||||||
|
|
||||||
|
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
|
||||||
|
|
||||||
|
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, parses generated score JSON, imports both catalogs into SQLite with Noto, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer is intentionally local and compact for now: it draws title/composer/part metadata, systems, measures, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON.
|
||||||
|
Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates.
|
||||||
|
|
||||||
|
## Score Generation
|
||||||
|
|
||||||
|
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON is sanitized so packaged scores do not contain tab positions or fingering fields; guitar playability logic is used only inside the arrangement generator.
|
||||||
|
|
||||||
|
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
|
||||||
|
|
||||||
|
## Pico Runtime
|
||||||
|
|
||||||
|
The Pico firmware uses the Pico SDK C++ stack and the `pico2_w` board definition. It initializes the ST7789T3 display over SPI, reads the CST816D touch controller over I2C, joins the Zero AP, polls `/api/remote/display`, and sends HTTP POST commands for gestures.
|
||||||
|
|
||||||
|
The display is rotated 90 degrees clockwise into a 320x240 landscape UI. The top status bar uses the shared palette:
|
||||||
|
|
||||||
|
- Blue: connecting.
|
||||||
|
- Yellow: retrying or remote request failed.
|
||||||
|
- Green: connected to the Zero API.
|
||||||
|
|
||||||
|
The body stays blank until a performance or practice score is active, then it shows the active title, selected part or active practice key, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select.
|
||||||
|
The Pico text renderer uses an antialiased bitmap atlas generated from `package/assets/fonts/petaluma/PetalumaScript.otf`; regenerate it with `python3 tools/generate_pico_font.py` after font or atlas-size changes. The generated atlas includes ASCII kerning pairs so display text such as `VAMP` uses the same spacing as the source font.
|
||||||
|
|
||||||
|
The root `CMakeLists.txt` is also the Pico firmware entrypoint when configured with `-DVAMP_PICO_FIRMWARE_PROJECT=ON`. That mode returns before the Zero/Linux targets are declared, so the Pico build can use the embedded ARM toolchain cleanly.
|
||||||
|
|
||||||
|
Build defaults can be overridden at package time with CMake cache variables:
|
||||||
|
|
||||||
|
- `VAMP_PICO_WIFI_SSID`
|
||||||
|
- `VAMP_PICO_WIFI_PASSPHRASE`
|
||||||
|
- `VAMP_ZERO_HOST`
|
||||||
|
- `VAMP_ZERO_PORT`
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Modulus packages the CMake install stage. Product Vamp uses `install_prefix` `.` so the registry artifact is a top-level multi-device bundle rather than a root filesystem overlay.
|
||||||
|
|
||||||
|
The ARM64 package target builds:
|
||||||
|
|
||||||
|
- `zero/product-vamp-zero-linux-arm64.docker.tar`
|
||||||
|
- `pico/product-vamp-pico2w.uf2`
|
||||||
|
- `manifest.json`
|
||||||
|
|
||||||
|
Building the full package requires Docker/buildx, the ARM embedded GCC toolchain, and the Pico SDK. CI passes `PICO_SDK_PATH` directly. The generated Modulus Docker image also carries the pinned Pico SDK at `/opt/pico-sdk` for local package builds. The Zero Docker runtime targets Debian trixie to match current Raspberry Pi OS and the ARM64 dependency binaries, and includes `util-linux` for the `setsid --wait --ctty` launch path.
|
||||||
|
|
||||||
|
Generated score assets are created during CMake build/package and copied into the Zero image context. Static font assets remain tracked under `package/assets/fonts`.
|
||||||
|
|
||||||
|
CI builds the generic artifact with `python3 modulus.py package --version latest`, then `package/scripts/publish_latest_generic_package.py` replaces the Gitea generic package version `latest` and deletes older Product Vamp generic package versions.
|
||||||
|
|
||||||
|
## Shared Protocol
|
||||||
|
|
||||||
|
The Pico firmware does not link Zero-side Linux application libraries such as Ligo, Luma, or Noto. Those remain Zero-side dependencies: Ligo serves the HTTP API, Luma renders the Zero display, and Noto owns SQLite persistence. Cross-device code should stay in `vamp_protocol` and `vamp_style`: plain C++17 with no OS, allocator-heavy, or Zero-only dependencies.
|
||||||
|
|
||||||
|
Luma owns the application shell and UI control tree. The score renderer uses Luma primitives for layout, staff lines, and simple vector strokes, and uses a small score-local Raylib font cache for Petaluma SMuFL glyphs until Luma grows first-class codepoint loading.
|
||||||
|
|
||||||
|
## LSP
|
||||||
|
|
||||||
|
`.clangd` points native Zero and shared protocol code at `build/build_x86_debug`, and Pico code at `build/build_pico_debug`. Refresh both compile databases with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 modulus.py lsp
|
||||||
|
```
|
||||||
|
|
||||||
|
The `tooling.lsp` section in `modulus.json` owns the Pico CMake configure. It unsets host compiler variables before configuring the Pico SDK project so clangd sees the embedded ARM toolchain. When running directly outside the generated Modulus Docker image, set `PICO_SDK_PATH` before refreshing the Pico database.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
set(PICO_BOARD pico2_w CACHE STRING "Pico board")
|
||||||
|
|
||||||
|
if(DEFINED ENV{PICO_SDK_PATH} AND NOT PICO_SDK_PATH)
|
||||||
|
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT OFF CACHE BOOL "Fetch Raspberry Pi Pico SDK from git")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_TAG "${VAMP_PICO_SDK_VERSION}" CACHE STRING "Pico SDK git tag to fetch")
|
||||||
|
|
||||||
|
if(NOT PICO_SDK_PATH AND PICO_SDK_FETCH_FROM_GIT)
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
pico_sdk
|
||||||
|
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk.git
|
||||||
|
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||||
|
)
|
||||||
|
FetchContent_Populate(pico_sdk)
|
||||||
|
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT PICO_SDK_PATH)
|
||||||
|
message(FATAL_ERROR "PICO_SDK_PATH is not set and PICO_SDK_FETCH_FROM_GIT is OFF")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
|
||||||
|
if(NOT EXISTS ${PICO_SDK_PATH}/pico_sdk_init.cmake)
|
||||||
|
message(FATAL_ERROR "PICO_SDK_PATH does not contain pico_sdk_init.cmake: ${PICO_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(${PICO_SDK_PATH}/pico_sdk_init.cmake)
|
||||||
|
|
||||||
|
project(product-vamp-pico VERSION ${VAMP_PROJECT_VERSION} LANGUAGES C CXX ASM)
|
||||||
|
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
pico_sdk_init()
|
||||||
|
|
||||||
|
add_executable(product-vamp-pico2w
|
||||||
|
src/vamp_style.cpp
|
||||||
|
src/vamp_style/font.cpp
|
||||||
|
src/vamp_pico/display.cpp
|
||||||
|
src/vamp_pico/http_client.cpp
|
||||||
|
src/vamp_pico/main.cpp
|
||||||
|
src/vamp_pico/touch.cpp
|
||||||
|
src/vamp_protocol/page_control.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(product-vamp-pico2w PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/capi
|
||||||
|
)
|
||||||
|
target_compile_definitions(product-vamp-pico2w PRIVATE
|
||||||
|
VAMP_PICO_WIFI_SSID="${VAMP_PICO_WIFI_SSID}"
|
||||||
|
VAMP_PICO_WIFI_PASSPHRASE="${VAMP_PICO_WIFI_PASSPHRASE}"
|
||||||
|
VAMP_ZERO_HOST="${VAMP_ZERO_HOST}"
|
||||||
|
VAMP_ZERO_PORT=${VAMP_ZERO_PORT}
|
||||||
|
)
|
||||||
|
target_link_libraries(product-vamp-pico2w PRIVATE
|
||||||
|
pico_stdlib
|
||||||
|
hardware_i2c
|
||||||
|
hardware_spi
|
||||||
|
pico_cyw43_arch_lwip_threadsafe_background
|
||||||
|
)
|
||||||
|
|
||||||
|
pico_enable_stdio_usb(product-vamp-pico2w 1)
|
||||||
|
pico_enable_stdio_uart(product-vamp-pico2w 0)
|
||||||
|
pico_add_extra_outputs(product-vamp-pico2w)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
option(VAMP_PICO_FIRMWARE_PROJECT "Configure this CMake invocation as the Pi Pico 2 W firmware project" OFF)
|
||||||
|
option(VAMP_BUILD_PICO_FIRMWARE "Build the Pi Pico 2 W UF2 firmware artifact" OFF)
|
||||||
|
option(VAMP_BUILD_ZERO_IMAGE "Build the Pi Zero 2 W Docker image archive artifact" OFF)
|
||||||
|
option(BUILD_TESTING "Compatibility option accepted by Modulus; Product Vamp does not currently define tests" OFF)
|
||||||
|
|
||||||
|
set(VAMP_PICO_SDK_VERSION "2.3.0" CACHE STRING "Raspberry Pi Pico SDK git tag used when fetching the SDK")
|
||||||
|
set(VAMP_PICO_WIFI_SSID "Vamp Control" CACHE STRING "Default Pico Wi-Fi SSID")
|
||||||
|
set(VAMP_PICO_WIFI_PASSPHRASE "vamp-control" CACHE STRING "Default Pico Wi-Fi passphrase")
|
||||||
|
set(VAMP_ZERO_HOST "192.168.42.1" CACHE STRING "Default Zero control host for Pico firmware")
|
||||||
|
set(VAMP_ZERO_PORT "8080" CACHE STRING "Default Zero control port for Pico firmware")
|
||||||
|
set(VAMP_ZERO_IMAGE_TAG "product-vamp-zero:dev" CACHE STRING "Docker image tag embedded in package metadata")
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
find_package(modulus CONFIG REQUIRED)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
find_package(ligo CONFIG REQUIRED)
|
||||||
|
find_package(noto CONFIG REQUIRED)
|
||||||
|
find_package(luma CONFIG REQUIRED)
|
||||||
|
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||||
|
|
||||||
|
execute_process(
|
||||||
|
COMMAND git rev-parse --short=12 HEAD
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
OUTPUT_VARIABLE VAMP_GIT_SHA
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET
|
||||||
|
)
|
||||||
|
if(NOT VAMP_GIT_SHA)
|
||||||
|
set(VAMP_GIT_SHA dev)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(VAMP_STYLE_SOURCES
|
||||||
|
src/vamp_style.cpp
|
||||||
|
src/vamp_style/font.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_library(vamp-style STATIC ${VAMP_STYLE_SOURCES})
|
||||||
|
target_include_directories(vamp-style PUBLIC
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
)
|
||||||
|
target_compile_features(vamp-style PUBLIC cxx_std_17)
|
||||||
|
|
||||||
|
set(VAMP_PROTOCOL_SOURCES
|
||||||
|
src/vamp_protocol/page_control.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_library(vamp-protocol STATIC ${VAMP_PROTOCOL_SOURCES})
|
||||||
|
target_include_directories(vamp-protocol PUBLIC
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
)
|
||||||
|
target_compile_features(vamp-protocol PUBLIC cxx_std_17)
|
||||||
|
|
||||||
|
set(VAMP_CORE_SOURCES
|
||||||
|
src/vamp_zero/app.cpp
|
||||||
|
src/vamp_zero/color.cpp
|
||||||
|
src/vamp_zero/config.cpp
|
||||||
|
src/vamp_zero/database.cpp
|
||||||
|
src/vamp_zero/errors.cpp
|
||||||
|
src/vamp_zero/library.cpp
|
||||||
|
src/vamp_zero/page_control.cpp
|
||||||
|
src/vamp_zero/portal.cpp
|
||||||
|
src/vamp_zero/score.cpp
|
||||||
|
src/vamp_zero/types.cpp
|
||||||
|
src/vamp_zero/ui.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES})
|
||||||
|
target_include_directories(vamp-zero-core PUBLIC
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
)
|
||||||
|
target_compile_features(vamp-zero-core PUBLIC cxx_std_23)
|
||||||
|
target_compile_definitions(vamp-zero-core PUBLIC
|
||||||
|
VAMP_VERSION="${PROJECT_VERSION}"
|
||||||
|
VAMP_GIT_SHA="${VAMP_GIT_SHA}"
|
||||||
|
)
|
||||||
|
target_link_libraries(vamp-zero-core PUBLIC
|
||||||
|
vamp-style
|
||||||
|
vamp-protocol
|
||||||
|
modulus::modulus
|
||||||
|
modulus::boost_json
|
||||||
|
modulus::openssl
|
||||||
|
Threads::Threads
|
||||||
|
ligo::ligo
|
||||||
|
noto::noto
|
||||||
|
luma::luma
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(vamp-zero src/vamp_zero/main.cpp)
|
||||||
|
target_compile_features(vamp-zero PRIVATE cxx_std_23)
|
||||||
|
target_link_libraries(vamp-zero PRIVATE vamp-zero-core)
|
||||||
|
|
||||||
|
set(VAMP_BUNDLE_DIR ${CMAKE_CURRENT_BINARY_DIR}/bundle)
|
||||||
|
set(VAMP_MANIFEST ${VAMP_BUNDLE_DIR}/manifest.json)
|
||||||
|
configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
|
||||||
|
|
||||||
|
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/data/*.json
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
|
||||||
|
)
|
||||||
|
set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated-assets)
|
||||||
|
set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.stamp)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
|
||||||
|
COMMAND ${Python3_EXECUTABLE}
|
||||||
|
-m generator
|
||||||
|
--data-dir ${CMAKE_CURRENT_SOURCE_DIR}/data
|
||||||
|
--output-dir ${VAMP_GENERATED_ASSETS_DIR}
|
||||||
|
--static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets
|
||||||
|
--clean
|
||||||
|
--check
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
DEPENDS ${VAMP_GENERATOR_INPUTS}
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(vamp-generated-assets ALL DEPENDS ${VAMP_GENERATED_ASSETS_STAMP})
|
||||||
|
|
||||||
|
install(TARGETS vamp-zero
|
||||||
|
RUNTIME DESTINATION zero/bin
|
||||||
|
)
|
||||||
|
install(FILES package/config/vamp.example.json DESTINATION zero/config)
|
||||||
|
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product-vamp)
|
||||||
|
install(DIRECTORY package/migrations/ DESTINATION zero/share/product-vamp/migrations)
|
||||||
|
install(PROGRAMS
|
||||||
|
package/zero/install-zero.sh
|
||||||
|
package/zero/start-ap.sh
|
||||||
|
package/zero/start-zero.sh
|
||||||
|
DESTINATION zero
|
||||||
|
)
|
||||||
|
install(FILES
|
||||||
|
package/zero/product-vamp-ap.service
|
||||||
|
package/zero/product-vamp-zero.service
|
||||||
|
DESTINATION zero/systemd
|
||||||
|
)
|
||||||
|
install(FILES package/pico/wiring.md DESTINATION pico)
|
||||||
|
install(FILES ${VAMP_MANIFEST} DESTINATION .)
|
||||||
|
|
||||||
|
if(VAMP_BUILD_ZERO_IMAGE OR VAMP_BUILD_PICO_FIRMWARE)
|
||||||
|
find_program(BASH_EXECUTABLE bash REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(VAMP_BUILD_ZERO_IMAGE)
|
||||||
|
find_program(DOCKER_EXECUTABLE docker REQUIRED)
|
||||||
|
|
||||||
|
set(VAMP_ZERO_IMAGE_CONTEXT ${CMAKE_CURRENT_BINARY_DIR}/zero-image-context)
|
||||||
|
set(VAMP_ZERO_IMAGE_TAR ${CMAKE_CURRENT_BINARY_DIR}/product-vamp-zero-linux-arm64.docker.tar)
|
||||||
|
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${VAMP_ZERO_IMAGE_TAR}
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E rm -rf ${VAMP_ZERO_IMAGE_CONTEXT}
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:vamp-zero> ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin/vamp-zero
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy package/zero/Dockerfile ${VAMP_ZERO_IMAGE_CONTEXT}/Dockerfile
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory ${VAMP_GENERATED_ASSETS_DIR} ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy package/config/vamp.example.json ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config/vamp.example.json
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory package/migrations ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/migrations
|
||||||
|
COMMAND ${BASH_EXECUTABLE} package/scripts/build_zero_image.sh ${VAMP_ZERO_IMAGE_CONTEXT} ${VAMP_ZERO_IMAGE_TAR} ${VAMP_ZERO_IMAGE_TAG}
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
DEPENDS
|
||||||
|
vamp-zero
|
||||||
|
package/scripts/build_zero_image.sh
|
||||||
|
package/zero/Dockerfile
|
||||||
|
package/config/vamp.example.json
|
||||||
|
package/migrations/001_foundation.sql
|
||||||
|
package/migrations/002_song_import_metadata.sql
|
||||||
|
package/migrations/003_practice_catalog.sql
|
||||||
|
package/migrations/004_practice_hierarchy.sql
|
||||||
|
${VAMP_GENERATED_ASSETS_STAMP}
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(vamp-zero-image ALL DEPENDS ${VAMP_ZERO_IMAGE_TAR})
|
||||||
|
install(FILES ${VAMP_ZERO_IMAGE_TAR} DESTINATION zero)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(VAMP_BUILD_PICO_FIRMWARE)
|
||||||
|
set(VAMP_PICO_BUILD_DIR ${CMAKE_CURRENT_BINARY_DIR}/pico-build)
|
||||||
|
set(VAMP_PICO_UF2 ${CMAKE_CURRENT_BINARY_DIR}/product-vamp-pico2w.uf2)
|
||||||
|
file(GLOB VAMP_PICO_SOURCES CONFIGURE_DEPENDS
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico.cmake
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/cmake/vamp_options.cmake
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_style/*.hpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/*.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/capi/*.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/*.hpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_protocol/*.hpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style/*.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_protocol/*.cpp
|
||||||
|
)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${VAMP_PICO_UF2}
|
||||||
|
COMMAND ${BASH_EXECUTABLE}
|
||||||
|
package/scripts/build_pico_firmware.sh
|
||||||
|
${VAMP_PICO_BUILD_DIR}
|
||||||
|
${VAMP_PICO_UF2}
|
||||||
|
${VAMP_PICO_SDK_VERSION}
|
||||||
|
"${VAMP_PICO_WIFI_SSID}"
|
||||||
|
"${VAMP_PICO_WIFI_PASSPHRASE}"
|
||||||
|
"${VAMP_ZERO_HOST}"
|
||||||
|
"${VAMP_ZERO_PORT}"
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
DEPENDS
|
||||||
|
package/scripts/build_pico_firmware.sh
|
||||||
|
${VAMP_PICO_SOURCES}
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(vamp-pico-firmware ALL DEPENDS ${VAMP_PICO_UF2})
|
||||||
|
install(FILES ${VAMP_PICO_UF2} DESTINATION pico)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include(../../build/build_tools/conan/profiles/arm_64_gcc12_release)
|
||||||
|
|
||||||
|
[conf]
|
||||||
|
tools.build:jobs=2
|
||||||
|
tools.build:compiler_executables={"c": "/usr/bin/aarch64-linux-gnu-gcc-12", "cpp": "/usr/bin/aarch64-linux-gnu-g++-12"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
include(../../build/build_tools/conan/profiles/x86_64_gcc12_debug)
|
||||||
|
|
||||||
|
[conf]
|
||||||
|
tools.build:jobs=2
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
include(../../build/build_tools/conan/profiles/x86_64_gcc12_release)
|
||||||
|
|
||||||
|
[conf]
|
||||||
|
tools.build:jobs=2
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import conan
|
||||||
|
import conan.tools.cmake
|
||||||
|
|
||||||
|
class product_vamp_conan(conan.ConanFile):
|
||||||
|
name = "product-vamp"
|
||||||
|
version = "latest"
|
||||||
|
description = "Two-device sheet-music display and guitar remote"
|
||||||
|
license = "MIT"
|
||||||
|
package_type = "application"
|
||||||
|
settings = "os", "arch", "compiler", "build_type"
|
||||||
|
options = {
|
||||||
|
"with_tests": [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
default_options = {
|
||||||
|
"with_tests": False,
|
||||||
|
}
|
||||||
|
python_requires = "modulus/latest"
|
||||||
|
exports = "modulus.json"
|
||||||
|
exports_sources = (
|
||||||
|
"CMakeLists.txt",
|
||||||
|
"cmake/*.cmake",
|
||||||
|
"data/*.json",
|
||||||
|
"data/**/*.json",
|
||||||
|
"generator/*.py",
|
||||||
|
"generator/**/*.py",
|
||||||
|
"include/*.hpp",
|
||||||
|
"include/vamp_style/*.hpp",
|
||||||
|
"include/vamp_pico/*.h",
|
||||||
|
"include/vamp_pico/capi/*.h",
|
||||||
|
"include/vamp_pico/*.hpp",
|
||||||
|
"include/vamp_protocol/*.hpp",
|
||||||
|
"include/vamp_zero/*.hpp",
|
||||||
|
"package/assets/*",
|
||||||
|
"package/assets/**/*",
|
||||||
|
"package/config/*.json",
|
||||||
|
"package/manifest.json.in",
|
||||||
|
"package/migrations/*.sql",
|
||||||
|
"package/pico/*",
|
||||||
|
"package/scripts/*.py",
|
||||||
|
"package/scripts/*.sh",
|
||||||
|
"package/zero/*",
|
||||||
|
"src/vamp_style.cpp",
|
||||||
|
"src/vamp_style/*.cpp",
|
||||||
|
"src/vamp_pico/*.cpp",
|
||||||
|
"src/vamp_protocol/*.cpp",
|
||||||
|
"src/vamp_zero/*.cpp",
|
||||||
|
)
|
||||||
|
|
||||||
|
def requirements(self):
|
||||||
|
self.requires(
|
||||||
|
str(self.python_requires["modulus"].ref),
|
||||||
|
transitive_headers=True,
|
||||||
|
transitive_libs=True,
|
||||||
|
)
|
||||||
|
for reference in (
|
||||||
|
"ligo/latest",
|
||||||
|
"noto/latest",
|
||||||
|
"luma/latest",
|
||||||
|
):
|
||||||
|
self.requires(
|
||||||
|
reference,
|
||||||
|
transitive_headers=True,
|
||||||
|
transitive_libs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
dependencies = conan.tools.cmake.CMakeDeps(self)
|
||||||
|
dependencies.generate()
|
||||||
|
toolchain = conan.tools.cmake.CMakeToolchain(self)
|
||||||
|
if hasattr(toolchain, "user_presets_path"):
|
||||||
|
toolchain.user_presets_path = False
|
||||||
|
toolchain.generate()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
cmake = conan.tools.cmake.CMake(self)
|
||||||
|
cmake.configure()
|
||||||
|
cmake.build()
|
||||||
|
|
||||||
|
def package_id(self):
|
||||||
|
self.info.options.rm_safe("with_tests")
|
||||||
|
|
||||||
|
def package(self):
|
||||||
|
cmake = conan.tools.cmake.CMake(self)
|
||||||
|
cmake.install()
|
||||||
|
|
||||||
|
def package_info(self):
|
||||||
|
self.cpp_info.bindirs = ["zero/bin"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"title": "01 Cycles",
|
||||||
|
"composer": "",
|
||||||
|
"key_signature": "C",
|
||||||
|
"time_signature": "4/4",
|
||||||
|
"style": "Med. Swing"
|
||||||
|
},
|
||||||
|
"staves": [
|
||||||
|
{
|
||||||
|
"id": "staff-vocal",
|
||||||
|
"name": "Vocal",
|
||||||
|
"clef": "treble",
|
||||||
|
"role": "vocal",
|
||||||
|
"measures": [
|
||||||
|
{
|
||||||
|
"id": "measure-1",
|
||||||
|
"time_signature": "4/4",
|
||||||
|
"key_signature": "C",
|
||||||
|
"voices": [
|
||||||
|
{
|
||||||
|
"id": "voice-2vg7k4p1",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note-ekasof25",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "G7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-1fafh8eh",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-46dyougy",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "C7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-qsxuq33n",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tuplets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "measure-tn8h5n0h",
|
||||||
|
"time_signature": "4/4",
|
||||||
|
"key_signature": "C",
|
||||||
|
"voices": [
|
||||||
|
{
|
||||||
|
"id": "voice-8hwk9b7q",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note-64c6gqr5",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "F7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-hbgzjjm4",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-bpc3jl26",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "Bb7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-tph2glun",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tuplets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "measure-toxu9etb",
|
||||||
|
"time_signature": "4/4",
|
||||||
|
"key_signature": "C",
|
||||||
|
"voices": [
|
||||||
|
{
|
||||||
|
"id": "voice-rz7qmbk5",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note-rfziub9v",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "Eb7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-q6he4t8j",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "e",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-bwh485jp",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "Ab7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-rjm4vz5k",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "a",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "g",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tuplets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "measure-vj7x9zvk",
|
||||||
|
"time_signature": "4/4",
|
||||||
|
"key_signature": "C",
|
||||||
|
"voices": [
|
||||||
|
{
|
||||||
|
"id": "voice-9sgs888d",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note-v1y57u53",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "Dbmaj7",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-qvbgpked",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "c",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-gy3ukk29",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chord_symbol": "Db6",
|
||||||
|
"hidden": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note-0vcz65hi",
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": 4096,
|
||||||
|
"pitches": [
|
||||||
|
{
|
||||||
|
"letter": "d",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "f",
|
||||||
|
"accidental": "n",
|
||||||
|
"octave": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"letter": "b",
|
||||||
|
"accidental": "b",
|
||||||
|
"octave": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tuplets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
"""Build-time score generation for Product Vamp."""
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from generator.arrange.generate import generate_performance_scores
|
||||||
|
from generator.practice.generate import generate_practice_scores
|
||||||
|
from generator.score import count_json_files, validate_generated_tree
|
||||||
|
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.")
|
||||||
|
parser.add_argument("--data-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
|
||||||
|
parser.add_argument("--clean", action="store_true")
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
data_dir = args.data_dir.resolve()
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
static_assets_dir = args.static_assets_dir.resolve()
|
||||||
|
|
||||||
|
if args.clean and output_dir.exists():
|
||||||
|
shutil.rmtree(output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
fonts_src = static_assets_dir / "fonts"
|
||||||
|
if fonts_src.exists():
|
||||||
|
shutil.copytree(fonts_src, output_dir / "fonts", dirs_exist_ok=True)
|
||||||
|
|
||||||
|
performance_count = generate_performance_scores(data_dir / "songs", output_dir / "songs")
|
||||||
|
practice_count = generate_practice_scores(data_dir / "practice", output_dir / "practice")
|
||||||
|
|
||||||
|
validate_generated_tree(output_dir)
|
||||||
|
print(
|
||||||
|
"Generated Product Vamp assets: "
|
||||||
|
f"{performance_count} performance scores, "
|
||||||
|
f"{practice_count} practice score variants, "
|
||||||
|
f"{count_json_files(output_dir)} total JSON files."
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
|
||||||
|
validate_practice_variants(output_dir / "practice")
|
||||||
|
print("Generator check passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Performance arrangement generation."""
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from generator.score import body_start_measure_index
|
||||||
|
|
||||||
|
SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArrangementSection:
|
||||||
|
label: str
|
||||||
|
source_measure_index: int
|
||||||
|
measure_count: int
|
||||||
|
section_index: int
|
||||||
|
role: SectionRole
|
||||||
|
|
||||||
|
|
||||||
|
def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
|
||||||
|
if body_measure_count >= 8:
|
||||||
|
return 8
|
||||||
|
if body_measure_count >= 4:
|
||||||
|
return 4
|
||||||
|
return body_measure_count
|
||||||
|
|
||||||
|
|
||||||
|
def section_role(part_type: str, section_index: int) -> SectionRole:
|
||||||
|
if part_type == "acoustic":
|
||||||
|
return "slash" if section_index == 3 else "rhythm"
|
||||||
|
if part_type == "electric":
|
||||||
|
if section_index == 4:
|
||||||
|
return "slash"
|
||||||
|
if section_index == 2:
|
||||||
|
return "melody"
|
||||||
|
if section_index in {1, 5, 6}:
|
||||||
|
return "accompaniment"
|
||||||
|
return "rest"
|
||||||
|
return "vocal" if section_index in {1, 5, 6} else "rest"
|
||||||
|
|
||||||
|
|
||||||
|
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
|
||||||
|
if not measures:
|
||||||
|
return []
|
||||||
|
body_start = body_start_measure_index(measures)
|
||||||
|
if body_start >= len(measures):
|
||||||
|
return []
|
||||||
|
body_measure_count = len(measures) - body_start
|
||||||
|
edge_measure_count = phrase_aware_edge_measure_count(body_measure_count)
|
||||||
|
edge_source_measure = body_start + body_measure_count - edge_measure_count
|
||||||
|
specs = (
|
||||||
|
("Rhythm Lead-in", edge_source_measure, edge_measure_count, 0),
|
||||||
|
("Vocal Melody", body_start, body_measure_count, 1),
|
||||||
|
("Guitar Melody", body_start, body_measure_count, 2),
|
||||||
|
("Guitar Solo", body_start, body_measure_count, 3),
|
||||||
|
("Guitar Solo", body_start, body_measure_count, 4),
|
||||||
|
("Vocal Melody", body_start, body_measure_count, 5),
|
||||||
|
("Ending", edge_source_measure, edge_measure_count, 6),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index))
|
||||||
|
for label, start, count, index in specs
|
||||||
|
if count > 0
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import product
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from generator.transposition import mod12
|
||||||
|
|
||||||
|
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||||
|
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||||
|
|
||||||
|
|
||||||
|
class TabPosition(NamedTuple):
|
||||||
|
string_number: int
|
||||||
|
fret: int
|
||||||
|
midi: int
|
||||||
|
|
||||||
|
|
||||||
|
ROOTS = {
|
||||||
|
"C": 0,
|
||||||
|
"C#": 1,
|
||||||
|
"Db": 1,
|
||||||
|
"D": 2,
|
||||||
|
"D#": 3,
|
||||||
|
"Eb": 3,
|
||||||
|
"E": 4,
|
||||||
|
"F": 5,
|
||||||
|
"F#": 6,
|
||||||
|
"Gb": 6,
|
||||||
|
"G": 7,
|
||||||
|
"G#": 8,
|
||||||
|
"Ab": 8,
|
||||||
|
"A": 9,
|
||||||
|
"A#": 10,
|
||||||
|
"Bb": 10,
|
||||||
|
"B": 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_root(symbol: str) -> tuple[str, str] | None:
|
||||||
|
if not symbol or symbol[0].upper() not in "ABCDEFG":
|
||||||
|
return None
|
||||||
|
length = 1
|
||||||
|
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
|
||||||
|
length = 2
|
||||||
|
return symbol[:length], symbol[length:]
|
||||||
|
|
||||||
|
|
||||||
|
def chord_intervals(quality: str) -> tuple[int, ...]:
|
||||||
|
value = quality.lower()
|
||||||
|
if "7b5" in value or "ø" in value or "half" in value:
|
||||||
|
return (0, 3, 6, 10)
|
||||||
|
if "dim" in value or "o" in value:
|
||||||
|
return (0, 3, 6, 9)
|
||||||
|
if "aug" in value or "+" in value:
|
||||||
|
return (0, 4, 8, 10)
|
||||||
|
if "sus" in value:
|
||||||
|
return (0, 5, 7, 10)
|
||||||
|
if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")):
|
||||||
|
return (0, 3, 7, 10 if "7" in value else 10)
|
||||||
|
if "maj7" in value or "ma7" in value or "Δ" in value:
|
||||||
|
return (0, 4, 7, 11)
|
||||||
|
if "6" in value and "7" not in value:
|
||||||
|
return (0, 4, 7, 9)
|
||||||
|
if "7" in value:
|
||||||
|
return (0, 4, 7, 10)
|
||||||
|
return (0, 4, 7)
|
||||||
|
|
||||||
|
|
||||||
|
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
|
||||||
|
parsed = parse_root(symbol)
|
||||||
|
if parsed is None:
|
||||||
|
return ()
|
||||||
|
root, quality = parsed
|
||||||
|
root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
|
||||||
|
if root_pc is None:
|
||||||
|
return ()
|
||||||
|
return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality))
|
||||||
|
|
||||||
|
|
||||||
|
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
|
||||||
|
result: list[TabPosition] = []
|
||||||
|
for fret in range(0, 13):
|
||||||
|
midi = open_midi + fret
|
||||||
|
if mod12(midi) in pitch_classes:
|
||||||
|
result.append(TabPosition(string_number, fret, midi))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
|
||||||
|
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||||
|
if not fretted:
|
||||||
|
span = 0
|
||||||
|
low = 0
|
||||||
|
else:
|
||||||
|
span = max(fretted) - min(fretted)
|
||||||
|
low = min(fretted)
|
||||||
|
covered = {mod12(position.midi) for position in candidate}
|
||||||
|
missing = len(set(pitch_classes) - covered)
|
||||||
|
open_penalty = sum(1 for position in candidate if position.fret == 0)
|
||||||
|
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def find_playable_voicing(symbol: str) -> list[TabPosition]:
|
||||||
|
pitch_classes = chord_pitch_classes(symbol)
|
||||||
|
if not pitch_classes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
|
||||||
|
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
|
||||||
|
best: tuple[TabPosition, ...] | None = None
|
||||||
|
best_score = 10**9
|
||||||
|
for string_set in string_sets:
|
||||||
|
options = [by_string[string_number][:8] for string_number in string_set]
|
||||||
|
if any(not item for item in options):
|
||||||
|
continue
|
||||||
|
for candidate in product(*options):
|
||||||
|
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||||
|
if fretted and max(fretted) - min(fretted) > 4:
|
||||||
|
continue
|
||||||
|
covered = {mod12(position.midi) for position in candidate}
|
||||||
|
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
|
||||||
|
continue
|
||||||
|
score = candidate_score(candidate, pitch_classes)
|
||||||
|
if score < best_score:
|
||||||
|
best = candidate
|
||||||
|
best_score = score
|
||||||
|
return sorted(best or [], key=lambda item: item.midi)
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_from_midi(midi: int) -> dict[str, int | str]:
|
||||||
|
names = (
|
||||||
|
("c", "n"),
|
||||||
|
("c", "#"),
|
||||||
|
("d", "n"),
|
||||||
|
("e", "b"),
|
||||||
|
("e", "n"),
|
||||||
|
("f", "n"),
|
||||||
|
("f", "#"),
|
||||||
|
("g", "n"),
|
||||||
|
("a", "b"),
|
||||||
|
("a", "n"),
|
||||||
|
("b", "b"),
|
||||||
|
("b", "n"),
|
||||||
|
)
|
||||||
|
letter, accidental = names[mod12(midi)]
|
||||||
|
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
|
||||||
|
|
||||||
|
|
||||||
|
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
|
||||||
|
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
|
||||||
|
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from generator.arrange.form import ArrangementSection, full_arrangement_sections
|
||||||
|
from generator.arrange.fretboard import pitches_for_voicing
|
||||||
|
from generator.score import (
|
||||||
|
QUARTER_TICKS,
|
||||||
|
apply_source_chord_markers,
|
||||||
|
active_chord_at_offset,
|
||||||
|
chord_markers,
|
||||||
|
chord_symbol_at_offset,
|
||||||
|
copy_source_voice,
|
||||||
|
first_chord_symbol,
|
||||||
|
first_voice,
|
||||||
|
leading_pickup_measure_index,
|
||||||
|
load_json,
|
||||||
|
make_arranged_document,
|
||||||
|
make_measure,
|
||||||
|
measure_capacity_ticks,
|
||||||
|
renderable_duration,
|
||||||
|
renderable_rest_notes,
|
||||||
|
rest_note,
|
||||||
|
sanitized_note,
|
||||||
|
slash_note,
|
||||||
|
slugify,
|
||||||
|
source_staff,
|
||||||
|
source_note_attack_at,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
PART_TYPES = ("acoustic", "electric", "vocal")
|
||||||
|
|
||||||
|
|
||||||
|
def propagated_chords(measures: list[dict]) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for measure in measures:
|
||||||
|
current = first_chord_symbol(measure, current)
|
||||||
|
result.append(current)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
|
||||||
|
note = {
|
||||||
|
"id": note_id,
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": duration_ticks,
|
||||||
|
"pitches": pitches_for_voicing(voicing_chord_symbol),
|
||||||
|
"hidden": False,
|
||||||
|
}
|
||||||
|
if not note["pitches"]:
|
||||||
|
return rest_note(note_id, duration_ticks, visible_chord_symbol)
|
||||||
|
if visible_chord_symbol:
|
||||||
|
note["chord_symbol"] = visible_chord_symbol
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||||
|
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||||
|
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
|
||||||
|
hit_duration = QUARTER_TICKS // 2
|
||||||
|
notes: list[dict] = []
|
||||||
|
cursor = 0
|
||||||
|
for offset in hit_offsets:
|
||||||
|
if offset >= capacity:
|
||||||
|
break
|
||||||
|
if offset > cursor:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
cursor = offset
|
||||||
|
active_chord = active_chord_at_offset(measure, offset, chord_symbol)
|
||||||
|
visible_chord = chord_symbol_at_offset(measure, offset)
|
||||||
|
sequence += 1
|
||||||
|
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
|
||||||
|
cursor = offset + min(hit_duration, capacity - offset)
|
||||||
|
if cursor < capacity:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
return apply_source_chord_markers(notes, measure), sequence
|
||||||
|
|
||||||
|
|
||||||
|
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||||
|
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||||
|
notes: list[dict] = []
|
||||||
|
lead_rest = min(QUARTER_TICKS // 2, capacity)
|
||||||
|
if lead_rest:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0))
|
||||||
|
notes.extend(rests)
|
||||||
|
if lead_rest < capacity:
|
||||||
|
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
|
||||||
|
visible_chord = chord_symbol_at_offset(measure, lead_rest)
|
||||||
|
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol)
|
||||||
|
sequence += 1
|
||||||
|
if source_note_attack_at(measure, lead_rest):
|
||||||
|
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
|
||||||
|
else:
|
||||||
|
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
|
||||||
|
cursor = lead_rest + hit
|
||||||
|
if cursor < capacity:
|
||||||
|
markers = [marker for marker in chord_markers(measure) if marker[0] >= cursor]
|
||||||
|
if markers:
|
||||||
|
segment_cursor = cursor
|
||||||
|
for index, (start, _, chord) in enumerate(markers):
|
||||||
|
if start > segment_cursor:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
||||||
|
notes.extend(rests)
|
||||||
|
segment_cursor = next_start
|
||||||
|
if segment_cursor < capacity:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
else:
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
return apply_source_chord_markers(notes, measure), sequence
|
||||||
|
|
||||||
|
|
||||||
|
def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||||
|
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||||
|
notes: list[dict] = []
|
||||||
|
cursor = 0
|
||||||
|
while cursor < capacity:
|
||||||
|
sequence += 1
|
||||||
|
duration = min(QUARTER_TICKS, capacity - cursor)
|
||||||
|
notes.append(slash_note(f"arr-slash-{sequence}", duration))
|
||||||
|
cursor += duration
|
||||||
|
return notes, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def rest_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||||
|
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||||
|
markers = chord_markers(measure)
|
||||||
|
if not markers:
|
||||||
|
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
|
||||||
|
return notes, sequence
|
||||||
|
|
||||||
|
notes: list[dict] = []
|
||||||
|
cursor = 0
|
||||||
|
for index, (start, _, chord) in enumerate(markers):
|
||||||
|
if start > cursor:
|
||||||
|
if not renderable_duration(start - cursor):
|
||||||
|
return source_rest_skeleton_notes(measure, sequence)
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
||||||
|
if not renderable_duration(next_start - start):
|
||||||
|
return source_rest_skeleton_notes(measure, sequence)
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
||||||
|
notes.extend(rests)
|
||||||
|
cursor = next_start
|
||||||
|
if cursor < capacity:
|
||||||
|
if not renderable_duration(capacity - cursor):
|
||||||
|
return source_rest_skeleton_notes(measure, sequence)
|
||||||
|
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||||
|
notes.extend(rests)
|
||||||
|
return notes, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def source_rest_skeleton_notes(measure: dict, sequence: int) -> tuple[list[dict], int]:
|
||||||
|
notes: list[dict] = []
|
||||||
|
for source_note in first_voice(measure).get("notes", []):
|
||||||
|
if not isinstance(source_note, dict):
|
||||||
|
continue
|
||||||
|
sequence += 1
|
||||||
|
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), str(source_note.get("chord_symbol", "")))
|
||||||
|
if "display_ticks" in source_note:
|
||||||
|
note["display_ticks"] = source_note["display_ticks"]
|
||||||
|
if "tuplet_id" in source_note:
|
||||||
|
note["tuplet_id"] = source_note["tuplet_id"]
|
||||||
|
notes.append(note)
|
||||||
|
if not notes:
|
||||||
|
sequence += 1
|
||||||
|
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
|
||||||
|
return notes, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def melody_voice(measure: dict, *, strip_lyrics: bool) -> dict:
|
||||||
|
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics)
|
||||||
|
voice["id"] = "voice-arranged-source"
|
||||||
|
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
|
||||||
|
return voice
|
||||||
|
|
||||||
|
|
||||||
|
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
|
||||||
|
pickup = leading_pickup_measure_index(source_measures)
|
||||||
|
if pickup is None or section.source_measure_index != pickup + 1 or not output:
|
||||||
|
return
|
||||||
|
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
|
||||||
|
for note in voice["notes"]:
|
||||||
|
if isinstance(note, dict):
|
||||||
|
note.pop("chord_symbol", None)
|
||||||
|
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
|
||||||
|
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
|
||||||
|
output[-1]["voices"][0]["notes"] = voice["notes"]
|
||||||
|
|
||||||
|
|
||||||
|
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int) -> tuple[dict, int]:
|
||||||
|
if section.role == "vocal":
|
||||||
|
return melody_voice(measure, strip_lyrics=False), sequence
|
||||||
|
if section.role == "melody":
|
||||||
|
return melody_voice(measure, strip_lyrics=True), sequence
|
||||||
|
if section.role == "rhythm":
|
||||||
|
notes, sequence = rhythm_notes(measure, chord_symbol, sequence)
|
||||||
|
elif section.role == "accompaniment":
|
||||||
|
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence)
|
||||||
|
elif section.role == "slash":
|
||||||
|
notes, sequence = slash_notes(measure, chord_symbol, sequence)
|
||||||
|
else:
|
||||||
|
notes, sequence = rest_notes(measure, chord_symbol, sequence)
|
||||||
|
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
|
||||||
|
sections = full_arrangement_sections(source_measures, part_type)
|
||||||
|
chords = propagated_chords(source_measures)
|
||||||
|
output: list[dict] = []
|
||||||
|
sequence = 0
|
||||||
|
for section in sections:
|
||||||
|
if part_type == "vocal" and section.role == "vocal":
|
||||||
|
apply_vocal_pickup_overlap(output, source_measures, section)
|
||||||
|
for offset in range(section.measure_count):
|
||||||
|
source_index = section.source_measure_index + offset
|
||||||
|
source_measure = source_measures[source_index]
|
||||||
|
chord_symbol = chords[source_index] if source_index < len(chords) else ""
|
||||||
|
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence)
|
||||||
|
output_measure = make_measure(
|
||||||
|
f"measure-{len(output) + 1}",
|
||||||
|
source_measure,
|
||||||
|
voice.get("notes", []),
|
||||||
|
section_label=section.label if offset == 0 else "",
|
||||||
|
tuplets=voice.get("tuplets", []),
|
||||||
|
)
|
||||||
|
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
||||||
|
output.append(output_measure)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def arrange_document(document: dict, part_type: str) -> dict:
|
||||||
|
staff = source_staff(document)
|
||||||
|
measures = staff.get("measures")
|
||||||
|
if not isinstance(measures, list) or not measures:
|
||||||
|
raise ValueError("performance arrangement requires at least one source measure")
|
||||||
|
output = make_arranged_document(
|
||||||
|
document,
|
||||||
|
staff,
|
||||||
|
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
|
||||||
|
staff_name=part_type.capitalize(),
|
||||||
|
role="vocal" if part_type == "vocal" else "guitar",
|
||||||
|
arrangement_part_type=part_type,
|
||||||
|
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
|
||||||
|
)
|
||||||
|
metadata = output.setdefault("metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
metadata.setdefault("tempo", 124)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise ValueError(f"performance source directory not found: {source_dir}")
|
||||||
|
count = 0
|
||||||
|
for source_path in sorted(source_dir.glob("*.json")):
|
||||||
|
document = load_json(source_path)
|
||||||
|
for part_type in PART_TYPES:
|
||||||
|
output = arrange_document(document, part_type)
|
||||||
|
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
|
||||||
|
write_json(target, output)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Practice score generation."""
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from generator.score import copy_source_voice, load_json, make_arranged_document, make_measure, slugify, source_staff, write_json
|
||||||
|
from generator.transposition import TARGET_KEYS, transpose_document
|
||||||
|
|
||||||
|
|
||||||
|
def practice_document(document: dict) -> dict:
|
||||||
|
staff = source_staff(document)
|
||||||
|
source_measures = staff.get("measures")
|
||||||
|
if not isinstance(source_measures, list):
|
||||||
|
raise ValueError("practice source must contain measures")
|
||||||
|
measures: list[dict] = []
|
||||||
|
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
|
||||||
|
voice = copy_source_voice(measure, strip_lyrics=True)
|
||||||
|
output_measure = make_measure(
|
||||||
|
f"measure-{index + 1}",
|
||||||
|
measure,
|
||||||
|
voice["notes"],
|
||||||
|
section_label=str(measure.get("section_label", "")),
|
||||||
|
tuplets=voice["tuplets"],
|
||||||
|
)
|
||||||
|
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
||||||
|
measures.append(output_measure)
|
||||||
|
return make_arranged_document(
|
||||||
|
document,
|
||||||
|
staff,
|
||||||
|
staff_id="staff-arranged-practice",
|
||||||
|
staff_name="Practice",
|
||||||
|
role="vocal",
|
||||||
|
arrangement_part_type="practice",
|
||||||
|
measures=measures,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def key_file_name(key: str) -> str:
|
||||||
|
return key.lower().replace("#", "sharp").replace("b", "b") + ".json"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||||
|
document = practice_document(load_json(source_path))
|
||||||
|
metadata = document.get("metadata", {})
|
||||||
|
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
|
||||||
|
relative = source_path.relative_to(source_root)
|
||||||
|
lesson_group = relative.parent.name
|
||||||
|
exercise_id = slugify(source_path.stem)
|
||||||
|
write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||||
|
base_document = practice_document(load_json(source_path))
|
||||||
|
relative = source_path.relative_to(source_root)
|
||||||
|
lesson_group = relative.parent.name
|
||||||
|
exercise_id = slugify(source_path.stem)
|
||||||
|
count = 0
|
||||||
|
for key in TARGET_KEYS:
|
||||||
|
write_json(output_dir / "melody" / lesson_group / exercise_id / key_file_name(key), transpose_document(base_document, key))
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
||||||
|
if not source_dir.exists():
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
chord_root = source_dir / "chord"
|
||||||
|
if chord_root.exists():
|
||||||
|
for path in sorted(chord_root.rglob("*.json")):
|
||||||
|
count += generate_chord_practice(path, chord_root, output_dir)
|
||||||
|
melody_root = source_dir / "melody"
|
||||||
|
if melody_root.exists():
|
||||||
|
for path in sorted(melody_root.rglob("*.json")):
|
||||||
|
count += generate_melody_practice(path, melody_root, output_dir)
|
||||||
|
return count
|
||||||
|
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
WHOLE_TICKS = 16384
|
||||||
|
HALF_TICKS = WHOLE_TICKS // 2
|
||||||
|
QUARTER_TICKS = WHOLE_TICKS // 4
|
||||||
|
EIGHTH_TICKS = WHOLE_TICKS // 8
|
||||||
|
SIXTEENTH_TICKS = WHOLE_TICKS // 16
|
||||||
|
THIRTY_SECOND_TICKS = WHOLE_TICKS // 32
|
||||||
|
RENDERABLE_TICKS = (
|
||||||
|
WHOLE_TICKS + HALF_TICKS,
|
||||||
|
WHOLE_TICKS,
|
||||||
|
HALF_TICKS + QUARTER_TICKS,
|
||||||
|
HALF_TICKS,
|
||||||
|
QUARTER_TICKS + EIGHTH_TICKS,
|
||||||
|
QUARTER_TICKS,
|
||||||
|
EIGHTH_TICKS + SIXTEENTH_TICKS,
|
||||||
|
EIGHTH_TICKS,
|
||||||
|
SIXTEENTH_TICKS + THIRTY_SECOND_TICKS,
|
||||||
|
SIXTEENTH_TICKS,
|
||||||
|
THIRTY_SECOND_TICKS,
|
||||||
|
)
|
||||||
|
|
||||||
|
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
|
||||||
|
FORBIDDEN_OUTPUT_FIELDS = {
|
||||||
|
"tab_positions",
|
||||||
|
"tab_frets",
|
||||||
|
"finger",
|
||||||
|
"fingers",
|
||||||
|
"fingering",
|
||||||
|
"left_hand_finger",
|
||||||
|
"right_hand_finger",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
document = json.load(stream)
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise ValueError(f"{path} is not a score document")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, document: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as stream:
|
||||||
|
json.dump(document, stream, indent=2, ensure_ascii=False)
|
||||||
|
stream.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str) -> str:
|
||||||
|
value = value.strip().lower()
|
||||||
|
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||||
|
value = value.strip("-")
|
||||||
|
return value or "score"
|
||||||
|
|
||||||
|
|
||||||
|
def human_label(value: str) -> str:
|
||||||
|
text = value.replace("_", " ").replace("-", " ").strip()
|
||||||
|
return " ".join(part.capitalize() for part in text.split()) or "General"
|
||||||
|
|
||||||
|
|
||||||
|
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
staves = document.get("staves")
|
||||||
|
if not isinstance(staves, list) or not staves:
|
||||||
|
raise ValueError("score document must contain at least one staff")
|
||||||
|
first_staff: dict[str, Any] | None = None
|
||||||
|
for value in staves:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
if first_staff is None:
|
||||||
|
first_staff = value
|
||||||
|
staff_id = str(value.get("id", ""))
|
||||||
|
if not value.get("arranged") and staff_id not in {"staff-arranged-guitar", "staff-arranged-vocal", "staff-arranged-practice"} and value.get("role") != "guitar":
|
||||||
|
return value
|
||||||
|
if first_staff is None:
|
||||||
|
raise ValueError("score document does not contain an object staff")
|
||||||
|
return first_staff
|
||||||
|
|
||||||
|
|
||||||
|
def measure_capacity_ticks(time_signature: str) -> int:
|
||||||
|
try:
|
||||||
|
beats_text, unit_text = time_signature.split("/", 1)
|
||||||
|
beats = int(beats_text)
|
||||||
|
unit = int(unit_text)
|
||||||
|
except ValueError:
|
||||||
|
return WHOLE_TICKS
|
||||||
|
if beats <= 0 or unit <= 0:
|
||||||
|
return WHOLE_TICKS
|
||||||
|
return WHOLE_TICKS * beats // unit
|
||||||
|
|
||||||
|
|
||||||
|
def note_duration(note: dict[str, Any]) -> int:
|
||||||
|
value = note.get("duration_ticks", QUARTER_TICKS)
|
||||||
|
return max(1, int(value)) if isinstance(value, int) else QUARTER_TICKS
|
||||||
|
|
||||||
|
|
||||||
|
def renderable_duration(duration_ticks: int) -> bool:
|
||||||
|
return duration_ticks in RENDERABLE_TICKS
|
||||||
|
|
||||||
|
|
||||||
|
def renderable_duration_parts(duration_ticks: int) -> list[int]:
|
||||||
|
parts: list[int] = []
|
||||||
|
remaining = duration_ticks
|
||||||
|
while remaining > 0:
|
||||||
|
match = next((duration for duration in RENDERABLE_TICKS if duration <= remaining), None)
|
||||||
|
if match is None:
|
||||||
|
return [duration_ticks]
|
||||||
|
parts.append(match)
|
||||||
|
remaining -= match
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
voices = measure.get("voices")
|
||||||
|
if isinstance(voices, list) and voices and isinstance(voices[0], dict):
|
||||||
|
return voices[0]
|
||||||
|
return {"id": "voice-empty", "tuplets": [], "notes": []}
|
||||||
|
|
||||||
|
|
||||||
|
def first_chord_symbol(measure: dict[str, Any], fallback: str = "") -> str:
|
||||||
|
voice = first_voice(measure)
|
||||||
|
notes = voice.get("notes")
|
||||||
|
if not isinstance(notes, list):
|
||||||
|
return fallback
|
||||||
|
for note in notes:
|
||||||
|
if isinstance(note, dict):
|
||||||
|
chord = note.get("chord_symbol")
|
||||||
|
if isinstance(chord, str) and chord:
|
||||||
|
return chord
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def measure_notes(measure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
voice = first_voice(measure)
|
||||||
|
notes = voice.get("notes")
|
||||||
|
return [note for note in notes if isinstance(note, dict)] if isinstance(notes, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
|
||||||
|
cursor = 0
|
||||||
|
result: list[tuple[int, dict[str, Any]]] = []
|
||||||
|
for note in measure_notes(measure):
|
||||||
|
result.append((cursor, note))
|
||||||
|
cursor += note_duration(note)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def chord_markers(measure: dict[str, Any]) -> list[tuple[int, int, str]]:
|
||||||
|
markers: list[tuple[int, int, str]] = []
|
||||||
|
for start_ticks, note in timed_measure_notes(measure):
|
||||||
|
chord = note.get("chord_symbol")
|
||||||
|
if isinstance(chord, str) and chord:
|
||||||
|
markers.append((start_ticks, note_duration(note), chord))
|
||||||
|
return markers
|
||||||
|
|
||||||
|
|
||||||
|
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int) -> str:
|
||||||
|
for start_ticks, _, chord in chord_markers(measure):
|
||||||
|
if start_ticks == offset_ticks:
|
||||||
|
return chord
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "") -> str:
|
||||||
|
current = fallback
|
||||||
|
for start_ticks, _, chord in chord_markers(measure):
|
||||||
|
if start_ticks > offset_ticks:
|
||||||
|
break
|
||||||
|
current = chord
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def source_note_attack_at(measure: dict[str, Any], offset_ticks: int) -> bool:
|
||||||
|
for start_ticks, note in timed_measure_notes(measure):
|
||||||
|
if start_ticks == offset_ticks and note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def first_pitched_note_start(measure: dict[str, Any]) -> int | None:
|
||||||
|
cursor = 0
|
||||||
|
for note in measure_notes(measure):
|
||||||
|
if note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
|
||||||
|
return cursor
|
||||||
|
cursor += note_duration(note)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def leading_pickup_measure_index(measures: list[dict[str, Any]]) -> int | None:
|
||||||
|
if len(measures) < 2 or first_chord_symbol(measures[0]):
|
||||||
|
return None
|
||||||
|
first_pitch = first_pitched_note_start(measures[0])
|
||||||
|
if first_pitch is None or first_pitch <= 0:
|
||||||
|
return None
|
||||||
|
for index, measure in enumerate(measures):
|
||||||
|
if first_chord_symbol(measure):
|
||||||
|
return 0 if index == 1 else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def body_start_measure_index(measures: list[dict[str, Any]]) -> int:
|
||||||
|
pickup = leading_pickup_measure_index(measures)
|
||||||
|
return pickup + 1 if pickup is not None else 0
|
||||||
|
|
||||||
|
|
||||||
|
def rest_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
|
||||||
|
note: dict[str, Any] = {
|
||||||
|
"id": note_id,
|
||||||
|
"kind": "rest",
|
||||||
|
"duration_ticks": duration_ticks,
|
||||||
|
"pitches": [copy.deepcopy(REST_PITCH)],
|
||||||
|
"hidden": False,
|
||||||
|
}
|
||||||
|
if chord_symbol:
|
||||||
|
note["chord_symbol"] = chord_symbol
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: int, chord_symbol: str = "") -> tuple[list[dict[str, Any]], int]:
|
||||||
|
notes: list[dict[str, Any]] = []
|
||||||
|
for index, duration in enumerate(renderable_duration_parts(duration_ticks)):
|
||||||
|
sequence += 1
|
||||||
|
notes.append(rest_note(f"{note_id_prefix}-{sequence}", duration, chord_symbol if index == 0 else ""))
|
||||||
|
return notes, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
markers = [(start, chord) for start, _, chord in chord_markers(measure)]
|
||||||
|
if not markers:
|
||||||
|
result = [copy.deepcopy(note) for note in notes]
|
||||||
|
for note in result:
|
||||||
|
note.pop("chord_symbol", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
unique_markers: list[tuple[int, str]] = []
|
||||||
|
for start, chord in markers:
|
||||||
|
if unique_markers and unique_markers[-1][0] == start:
|
||||||
|
unique_markers[-1] = (start, chord)
|
||||||
|
else:
|
||||||
|
unique_markers.append((start, chord))
|
||||||
|
|
||||||
|
marker_by_start = dict(unique_markers)
|
||||||
|
marker_starts = [start for start, _ in unique_markers]
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
cursor = 0
|
||||||
|
split_sequence = 0
|
||||||
|
for source_note in notes:
|
||||||
|
note = copy.deepcopy(source_note)
|
||||||
|
note.pop("chord_symbol", None)
|
||||||
|
duration = note_duration(note)
|
||||||
|
end = cursor + duration
|
||||||
|
split_points = [cursor] + [start for start in marker_starts if cursor < start < end] + [end]
|
||||||
|
for index in range(len(split_points) - 1):
|
||||||
|
start = split_points[index]
|
||||||
|
stop = split_points[index + 1]
|
||||||
|
part_duration = stop - start
|
||||||
|
if part_duration <= 0:
|
||||||
|
continue
|
||||||
|
part = copy.deepcopy(note)
|
||||||
|
part["duration_ticks"] = part_duration
|
||||||
|
if len(split_points) > 2:
|
||||||
|
split_sequence += 1
|
||||||
|
part["id"] = f"{note.get('id', 'note')}-split-{split_sequence}"
|
||||||
|
if part.get("kind", "note") == "note":
|
||||||
|
part["tie_to_next"] = index + 2 < len(split_points)
|
||||||
|
part["tie_from_previous"] = index > 0
|
||||||
|
if start in marker_by_start:
|
||||||
|
part["chord_symbol"] = marker_by_start[start]
|
||||||
|
result.append(part)
|
||||||
|
cursor = end
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def slash_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
|
||||||
|
note: dict[str, Any] = {
|
||||||
|
"id": note_id,
|
||||||
|
"kind": "note",
|
||||||
|
"duration_ticks": duration_ticks,
|
||||||
|
"pitches": [copy.deepcopy(REST_PITCH)],
|
||||||
|
"notehead": "slash",
|
||||||
|
"hidden": False,
|
||||||
|
}
|
||||||
|
if chord_symbol:
|
||||||
|
note["chord_symbol"] = chord_symbol
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_json(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: sanitize_json(item) for key, item in value.items() if key not in FORBIDDEN_OUTPUT_FIELDS}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [sanitize_json(item) for item in value]
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
|
||||||
|
output = sanitize_json(note)
|
||||||
|
if strip_lyrics:
|
||||||
|
output.pop("lyric", None)
|
||||||
|
output.setdefault("hidden", False)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
|
||||||
|
voice = first_voice(measure)
|
||||||
|
tuplets = sanitize_json(voice.get("tuplets", []))
|
||||||
|
notes = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in measure_notes(measure)]
|
||||||
|
return {
|
||||||
|
"id": str(voice.get("id", "voice-source")),
|
||||||
|
"tuplets": tuplets if isinstance(tuplets, list) else [],
|
||||||
|
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
|
||||||
|
measure: dict[str, Any] = {
|
||||||
|
"id": measure_id,
|
||||||
|
"time_signature": str(source.get("time_signature", "4/4")),
|
||||||
|
"key_signature": str(source.get("key_signature", "C")),
|
||||||
|
"voices": [{
|
||||||
|
"id": f"voice-{measure_id}",
|
||||||
|
"tuplets": tuplets or [],
|
||||||
|
"notes": notes,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
if section_label:
|
||||||
|
measure["section_label"] = section_label
|
||||||
|
return measure
|
||||||
|
|
||||||
|
|
||||||
|
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
metadata = sanitize_json(source_document.get("metadata", {}))
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
output: dict[str, Any] = {
|
||||||
|
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
|
||||||
|
"metadata": metadata,
|
||||||
|
"staves": [{
|
||||||
|
"id": staff_id,
|
||||||
|
"name": staff_name,
|
||||||
|
"clef": str(staff.get("clef", "treble")),
|
||||||
|
"role": role,
|
||||||
|
"arranged": True,
|
||||||
|
"arrangement_part_type": arrangement_part_type,
|
||||||
|
"measures": measures,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return sanitize_json(output)
|
||||||
|
|
||||||
|
|
||||||
|
def count_json_files(root: Path) -> int:
|
||||||
|
return sum(1 for path in root.rglob("*.json") if path.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_tree(root: Path) -> None:
|
||||||
|
for path in root.rglob("*.json"):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for field in FORBIDDEN_OUTPUT_FIELDS:
|
||||||
|
if f'"{field}"' in text:
|
||||||
|
raise ValueError(f"{path} contains forbidden generated field {field}")
|
||||||
|
json.loads(text)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from generator.score import load_json, measure_notes
|
||||||
|
from generator.transposition import TARGET_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
staves = document.get("staves")
|
||||||
|
if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict):
|
||||||
|
raise ValueError("generated score document must contain an arranged staff")
|
||||||
|
return staves[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]:
|
||||||
|
notes = measure_notes(measure)
|
||||||
|
summary: dict[str, Any] = {
|
||||||
|
"measure": measure_number,
|
||||||
|
"section_label": str(measure.get("section_label", "")),
|
||||||
|
"chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")],
|
||||||
|
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
|
||||||
|
}
|
||||||
|
if staff.get("arrangement_part_type") == "vocal":
|
||||||
|
summary["vocal_skeleton"] = [
|
||||||
|
{
|
||||||
|
"kind": str(note.get("kind", "note")),
|
||||||
|
"duration_ticks": int(note.get("duration_ticks", 0)),
|
||||||
|
"display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None,
|
||||||
|
"has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")),
|
||||||
|
}
|
||||||
|
for note in notes
|
||||||
|
]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = document.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
staff = _arranged_staff(document)
|
||||||
|
measures = staff.get("measures")
|
||||||
|
if not isinstance(measures, list):
|
||||||
|
measures = []
|
||||||
|
measure_summaries = [
|
||||||
|
_measure_summary(staff, measure, index + 1)
|
||||||
|
for index, measure in enumerate(measures)
|
||||||
|
if isinstance(measure, dict)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"title": str(metadata.get("title", "")),
|
||||||
|
"composer": str(metadata.get("composer", "")),
|
||||||
|
"key_signature": str(metadata.get("key_signature", "")),
|
||||||
|
"part_type": str(staff.get("arrangement_part_type", "")),
|
||||||
|
"measure_count": len(measure_summaries),
|
||||||
|
"section_labels": [
|
||||||
|
{"measure": item["measure"], "label": item["section_label"]}
|
||||||
|
for item in measure_summaries
|
||||||
|
if item["section_label"]
|
||||||
|
],
|
||||||
|
"measures": measure_summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def performance_tree_summary(root: Path) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
path.name: performance_document_summary(load_json(path))
|
||||||
|
for path in sorted(root.glob("*.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None:
|
||||||
|
if not expected_path.exists():
|
||||||
|
raise ValueError(f"missing generator compatibility summary: {expected_path}")
|
||||||
|
with expected_path.open("r", encoding="utf-8") as stream:
|
||||||
|
expected = json.load(stream)
|
||||||
|
actual = performance_tree_summary(generated_songs_dir)
|
||||||
|
if actual == expected:
|
||||||
|
return
|
||||||
|
|
||||||
|
expected_names = set(expected.keys()) if isinstance(expected, dict) else set()
|
||||||
|
actual_names = set(actual.keys())
|
||||||
|
missing = sorted(expected_names - actual_names)
|
||||||
|
extra = sorted(actual_names - expected_names)
|
||||||
|
if missing or extra:
|
||||||
|
raise ValueError(f"performance generator drift: missing={missing}, extra={extra}")
|
||||||
|
|
||||||
|
for name in sorted(actual_names):
|
||||||
|
if actual[name] != expected[name]:
|
||||||
|
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
|
||||||
|
raise ValueError("performance generator drift")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_practice_variants(generated_practice_dir: Path) -> None:
|
||||||
|
if not generated_practice_dir.exists():
|
||||||
|
return
|
||||||
|
melody_root = generated_practice_dir / "melody"
|
||||||
|
if melody_root.exists():
|
||||||
|
expected_names = {key.lower().replace("#", "sharp").replace("b", "b") + ".json" for key in TARGET_KEYS}
|
||||||
|
for exercise_dir in sorted(path for path in melody_root.rglob("*") if path.is_dir()):
|
||||||
|
json_names = {path.name for path in exercise_dir.glob("*.json")}
|
||||||
|
if not json_names:
|
||||||
|
continue
|
||||||
|
if json_names != expected_names:
|
||||||
|
raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||||
|
|
||||||
|
chord_root = generated_practice_dir / "chord"
|
||||||
|
if chord_root.exists():
|
||||||
|
for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()):
|
||||||
|
json_names = [path.name for path in exercise_dir.glob("*.json")]
|
||||||
|
if not json_names:
|
||||||
|
continue
|
||||||
|
if len(json_names) != 1:
|
||||||
|
raise ValueError(f"chord practice generator drift in {exercise_dir}: expected one authored-key variant, found {json_names}")
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KeyProfile:
|
||||||
|
name: str
|
||||||
|
root: int
|
||||||
|
degrees: tuple[str, str, str, str, str, str, str]
|
||||||
|
prefer_flats: bool
|
||||||
|
|
||||||
|
|
||||||
|
KEY_PROFILES = (
|
||||||
|
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
|
||||||
|
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
|
||||||
|
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
|
||||||
|
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
|
||||||
|
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
|
||||||
|
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
|
||||||
|
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
|
||||||
|
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
|
||||||
|
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
|
||||||
|
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
|
||||||
|
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
|
||||||
|
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
|
||||||
|
)
|
||||||
|
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
|
||||||
|
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
|
||||||
|
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
|
||||||
|
|
||||||
|
|
||||||
|
def mod12(value: int) -> int:
|
||||||
|
return value % 12
|
||||||
|
|
||||||
|
|
||||||
|
def find_key(name: str) -> KeyProfile:
|
||||||
|
normalized = name.lower()
|
||||||
|
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
|
||||||
|
normalized = aliases.get(normalized, normalized)
|
||||||
|
for profile in KEY_PROFILES:
|
||||||
|
if profile.name.lower() == normalized:
|
||||||
|
return profile
|
||||||
|
raise ValueError(f"unsupported key signature: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
||||||
|
interval = mod12(target.root - source.root)
|
||||||
|
return interval - 12 if interval > 6 else interval
|
||||||
|
|
||||||
|
|
||||||
|
def split_spelling(value: str) -> tuple[str, str]:
|
||||||
|
if not value:
|
||||||
|
return "C", ""
|
||||||
|
return value[0], value[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def accidental_offset(accidental: str) -> int:
|
||||||
|
value = accidental.lower()
|
||||||
|
if value in {"#", "sharp"}:
|
||||||
|
return 1
|
||||||
|
if value in {"b", "flat"}:
|
||||||
|
return -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def spelling_pitch_class(spelling: str) -> int:
|
||||||
|
letter, accidental = split_spelling(spelling)
|
||||||
|
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_class(pitch: dict[str, Any]) -> int:
|
||||||
|
letter = str(pitch.get("letter", "c")).lower()
|
||||||
|
accidental = str(pitch.get("accidental", "n"))
|
||||||
|
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_midi(pitch: dict[str, Any]) -> int:
|
||||||
|
octave = int(pitch.get("octave", 4))
|
||||||
|
return octave * 12 + pitch_class(pitch)
|
||||||
|
|
||||||
|
|
||||||
|
def octave_for(midi: int, pitch_class_value: int) -> int:
|
||||||
|
octave = (midi - pitch_class_value) // 12
|
||||||
|
while octave * 12 + pitch_class_value > midi:
|
||||||
|
octave -= 1
|
||||||
|
while (octave + 1) * 12 + pitch_class_value <= midi:
|
||||||
|
octave += 1
|
||||||
|
return octave
|
||||||
|
|
||||||
|
|
||||||
|
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
|
||||||
|
actual = pitch_class(pitch)
|
||||||
|
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
|
||||||
|
for index, spelling in enumerate(source.degrees):
|
||||||
|
letter, _ = split_spelling(spelling)
|
||||||
|
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
|
||||||
|
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
|
||||||
|
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
|
||||||
|
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
|
||||||
|
|
||||||
|
|
||||||
|
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
|
||||||
|
transposed_midi = pitch_midi(pitch) + interval
|
||||||
|
degree = matching_source_degree_for_pitch(pitch, source)
|
||||||
|
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
|
||||||
|
letter, accidental = split_spelling(spelling)
|
||||||
|
pc = spelling_pitch_class(spelling)
|
||||||
|
return {
|
||||||
|
"letter": letter.lower(),
|
||||||
|
"accidental": accidental or "n",
|
||||||
|
"octave": octave_for(transposed_midi, pc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
|
||||||
|
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
|
||||||
|
return None
|
||||||
|
length = 1
|
||||||
|
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
|
||||||
|
length += 1
|
||||||
|
return length, value[offset:offset + length]
|
||||||
|
|
||||||
|
|
||||||
|
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
|
||||||
|
root_letter, _ = split_spelling(root)
|
||||||
|
actual = spelling_pitch_class(root)
|
||||||
|
for index, spelling in enumerate(source.degrees):
|
||||||
|
letter, _ = split_spelling(spelling)
|
||||||
|
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
||||||
|
degree = matching_source_degree_for_root(root, source)
|
||||||
|
if degree is not None:
|
||||||
|
return target.degrees[degree]
|
||||||
|
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
|
||||||
|
|
||||||
|
|
||||||
|
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
||||||
|
root = parse_chord_root(symbol, 0)
|
||||||
|
if root is None:
|
||||||
|
return symbol
|
||||||
|
root_length, root_text = root
|
||||||
|
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
|
||||||
|
slash = result.find("/")
|
||||||
|
if slash < 0:
|
||||||
|
return result
|
||||||
|
bass = parse_chord_root(result, slash + 1)
|
||||||
|
if bass is None:
|
||||||
|
return result
|
||||||
|
bass_length, bass_text = bass
|
||||||
|
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
|
||||||
|
|
||||||
|
|
||||||
|
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
|
||||||
|
output = copy.deepcopy(document)
|
||||||
|
metadata = output.setdefault("metadata", {})
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("score metadata must be an object")
|
||||||
|
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
|
||||||
|
target_key = find_key(target_key_name)
|
||||||
|
interval = signed_interval(source_key, target_key)
|
||||||
|
metadata["key_signature"] = target_key.name
|
||||||
|
for staff in output.get("staves", []):
|
||||||
|
if not isinstance(staff, dict):
|
||||||
|
continue
|
||||||
|
for measure in staff.get("measures", []):
|
||||||
|
if not isinstance(measure, dict):
|
||||||
|
continue
|
||||||
|
measure["key_signature"] = target_key.name
|
||||||
|
for voice in measure.get("voices", []):
|
||||||
|
if not isinstance(voice, dict):
|
||||||
|
continue
|
||||||
|
for note in voice.get("notes", []):
|
||||||
|
if not isinstance(note, dict):
|
||||||
|
continue
|
||||||
|
chord = note.get("chord_symbol")
|
||||||
|
if isinstance(chord, str) and chord:
|
||||||
|
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
|
||||||
|
pitches = note.get("pitches")
|
||||||
|
if isinstance(pitches, list):
|
||||||
|
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
|
||||||
|
return output
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#if defined(__has_include)
|
||||||
|
#if __has_include(<stdfloat>)
|
||||||
|
#include <stdfloat>
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace std {
|
||||||
|
|
||||||
|
using bool_t = decltype(true);
|
||||||
|
using char_t = decltype('x');
|
||||||
|
using uchar_t = std::uint8_t;
|
||||||
|
|
||||||
|
#if !defined(__STDCPP_FLOAT32_T__)
|
||||||
|
using float32_t = decltype(0.0F);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !defined(__STDCPP_FLOAT64_T__)
|
||||||
|
using float64_t = decltype(0.0);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifndef NO_SYS
|
||||||
|
#define NO_SYS 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef LWIP_SOCKET
|
||||||
|
#define LWIP_SOCKET 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define LWIP_NETCONN 0
|
||||||
|
#define MEM_LIBC_MALLOC 0
|
||||||
|
#define MEM_ALIGNMENT 4
|
||||||
|
#define MEM_SIZE 16000
|
||||||
|
#define MEMP_NUM_TCP_SEG 32
|
||||||
|
#define MEMP_NUM_ARP_QUEUE 10
|
||||||
|
#define PBUF_POOL_SIZE 24
|
||||||
|
#define LWIP_ARP 1
|
||||||
|
#define LWIP_ETHERNET 1
|
||||||
|
#define LWIP_ICMP 1
|
||||||
|
#define LWIP_RAW 1
|
||||||
|
#define LWIP_DHCP 1
|
||||||
|
#define LWIP_DNS 1
|
||||||
|
#define LWIP_IPV4 1
|
||||||
|
#define LWIP_TCP 1
|
||||||
|
#define LWIP_UDP 1
|
||||||
|
#define LWIP_TCP_KEEPALIVE 1
|
||||||
|
#define LWIP_NETIF_STATUS_CALLBACK 1
|
||||||
|
#define LWIP_NETIF_LINK_CALLBACK 1
|
||||||
|
#define LWIP_NETIF_HOSTNAME 1
|
||||||
|
#define LWIP_NETIF_TX_SINGLE_PBUF 1
|
||||||
|
#define LWIP_CHKSUM_ALGORITHM 3
|
||||||
|
#define DHCP_DOES_ARP_CHECK 0
|
||||||
|
#define LWIP_DHCP_DOES_ACD_CHECK 0
|
||||||
|
|
||||||
|
#define TCP_MSS 1460
|
||||||
|
#define TCP_WND (8 * TCP_MSS)
|
||||||
|
#define TCP_SND_BUF (8 * TCP_MSS)
|
||||||
|
#define TCP_SND_QUEUELEN ((4 * TCP_SND_BUF + TCP_MSS - 1) / TCP_MSS)
|
||||||
|
|
||||||
|
#define MEM_STATS 0
|
||||||
|
#define SYS_STATS 0
|
||||||
|
#define MEMP_STATS 0
|
||||||
|
#define LINK_STATS 0
|
||||||
|
|
||||||
|
#define ETHARP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define NETIF_DEBUG LWIP_DBG_OFF
|
||||||
|
#define PBUF_DEBUG LWIP_DBG_OFF
|
||||||
|
#define API_LIB_DEBUG LWIP_DBG_OFF
|
||||||
|
#define API_MSG_DEBUG LWIP_DBG_OFF
|
||||||
|
#define SOCKETS_DEBUG LWIP_DBG_OFF
|
||||||
|
#define ICMP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define INET_DEBUG LWIP_DBG_OFF
|
||||||
|
#define IP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define IP_REASS_DEBUG LWIP_DBG_OFF
|
||||||
|
#define RAW_DEBUG LWIP_DBG_OFF
|
||||||
|
#define MEM_DEBUG LWIP_DBG_OFF
|
||||||
|
#define MEMP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define SYS_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_INPUT_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_OUTPUT_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_RTO_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_CWND_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_WND_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_FR_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_QLEN_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCP_RST_DEBUG LWIP_DBG_OFF
|
||||||
|
#define UDP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define TCPIP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define PPP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define SLIP_DEBUG LWIP_DBG_OFF
|
||||||
|
#define DHCP_DEBUG LWIP_DBG_OFF
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
#include <vamp_style.hpp>
|
||||||
|
|
||||||
|
namespace vamp_pico {
|
||||||
|
|
||||||
|
constexpr auto display_width = std::int32_t{320};
|
||||||
|
constexpr auto display_height = std::int32_t{240};
|
||||||
|
|
||||||
|
constexpr auto color_black = std::uint16_t{0x0000};
|
||||||
|
constexpr auto color_white = std::uint16_t{0xffff};
|
||||||
|
constexpr auto color_gray = std::uint16_t{0x8410};
|
||||||
|
|
||||||
|
class display {
|
||||||
|
public:
|
||||||
|
auto init() -> void;
|
||||||
|
auto fill(std::uint16_t color) -> void;
|
||||||
|
auto fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void;
|
||||||
|
auto draw_text(std::int32_t x, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t scale = 2) -> void;
|
||||||
|
auto show_boot(std::char_t const* line1, std::char_t const* line2, std::uint16_t accent) -> void;
|
||||||
|
auto show_remote(vamp_style::color status, std::char_t const* title, std::char_t const* part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void;
|
||||||
|
private:
|
||||||
|
std::bool_t remote_valid_{};
|
||||||
|
vamp_style::color last_status_{};
|
||||||
|
std::bool_t last_active_{};
|
||||||
|
std::array<std::char_t, 96> last_title_{};
|
||||||
|
std::array<std::char_t, 48> last_part_{};
|
||||||
|
std::uint32_t last_page_{};
|
||||||
|
std::uint32_t last_pages_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vamp_protocol/page_control.hpp>
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_pico {
|
||||||
|
|
||||||
|
struct remote_display_state {
|
||||||
|
std::bool_t active{};
|
||||||
|
std::char_t title[96]{};
|
||||||
|
std::char_t part[48]{};
|
||||||
|
std::char_t mode[24]{};
|
||||||
|
std::uint32_t page{};
|
||||||
|
std::uint32_t pages{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t;
|
||||||
|
auto heartbeat() -> std::bool_t;
|
||||||
|
auto fetch_remote_display(vamp_pico::remote_display_state& state) -> std::bool_t;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_pico {
|
||||||
|
|
||||||
|
struct touch_point {
|
||||||
|
std::int32_t x{};
|
||||||
|
std::int32_t y{};
|
||||||
|
};
|
||||||
|
|
||||||
|
class touch_controller {
|
||||||
|
public:
|
||||||
|
auto init() -> void;
|
||||||
|
auto read(touch_point& point) -> std::bool_t;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_protocol {
|
||||||
|
|
||||||
|
constexpr std::char_t root_path[] = "/";
|
||||||
|
constexpr std::char_t status_path[] = "/api/status";
|
||||||
|
constexpr std::char_t next_page_path[] = "/api/page/next";
|
||||||
|
constexpr std::char_t previous_page_path[] = "/api/page/previous";
|
||||||
|
constexpr std::char_t remote_next_path[] = "/api/remote/next";
|
||||||
|
constexpr std::char_t remote_previous_path[] = "/api/remote/previous";
|
||||||
|
constexpr std::char_t remote_select_path[] = "/api/remote/select";
|
||||||
|
constexpr std::char_t remote_heartbeat_path[] = "/api/remote/heartbeat";
|
||||||
|
constexpr std::char_t remote_display_path[] = "/api/remote/display";
|
||||||
|
|
||||||
|
enum class page_command {
|
||||||
|
PREVIOUS,
|
||||||
|
NEXT,
|
||||||
|
SELECT,
|
||||||
|
};
|
||||||
|
|
||||||
|
auto page_command_path(vamp_protocol::page_command command) -> std::char_t const*;
|
||||||
|
auto page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const*;
|
||||||
|
auto page_command_name(vamp_protocol::page_command command) -> std::char_t const*;
|
||||||
|
auto page_command_display_label(vamp_protocol::page_command command) -> std::char_t const*;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_style {
|
||||||
|
|
||||||
|
struct color {
|
||||||
|
std::uint8_t r{};
|
||||||
|
std::uint8_t g{};
|
||||||
|
std::uint8_t b{};
|
||||||
|
std::uint8_t a{255};
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr auto background = vamp_style::color{9, 12, 14, 255};
|
||||||
|
constexpr auto panel = vamp_style::color{21, 27, 30, 255};
|
||||||
|
constexpr auto ink = vamp_style::color{238, 244, 241, 255};
|
||||||
|
constexpr auto muted = vamp_style::color{137, 151, 149, 255};
|
||||||
|
constexpr auto rule = vamp_style::color{66, 78, 78, 255};
|
||||||
|
constexpr auto paper = vamp_style::color{2, 3, 3, 255};
|
||||||
|
constexpr auto paper_ink = vamp_style::color{246, 248, 244, 255};
|
||||||
|
constexpr auto accent = vamp_style::color{232, 190, 70, 255};
|
||||||
|
constexpr auto selection = vamp_style::color{41, 100, 111, 255};
|
||||||
|
constexpr auto connecting = vamp_style::color{45, 111, 185, 255};
|
||||||
|
constexpr auto retrying = vamp_style::color{229, 171, 37, 255};
|
||||||
|
constexpr auto connected = vamp_style::color{40, 170, 88, 255};
|
||||||
|
constexpr auto danger = vamp_style::color{190, 63, 58, 255};
|
||||||
|
|
||||||
|
auto rgb565(vamp_style::color value) -> std::uint16_t;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_style {
|
||||||
|
|
||||||
|
constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"};
|
||||||
|
constexpr auto score_text_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"};
|
||||||
|
constexpr auto score_music_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/Petaluma.otf"};
|
||||||
|
constexpr auto ui_font_height = std::int32_t{36};
|
||||||
|
constexpr auto ui_font_max_width = std::int32_t{28};
|
||||||
|
|
||||||
|
struct font_glyph {
|
||||||
|
std::array<std::uint8_t, ui_font_height * ui_font_max_width> alpha{};
|
||||||
|
std::uint8_t width{};
|
||||||
|
std::uint8_t advance{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto ui_font_glyph(std::char_t value) -> vamp_style::font_glyph;
|
||||||
|
auto ui_font_pair_adjust(std::char_t left, std::char_t right) -> std::int32_t;
|
||||||
|
auto ui_text_width(std::string_view text, std::int32_t scale) -> std::int32_t;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
struct command_line {
|
||||||
|
std::string config_path{"/etc/product-vamp/vamp.json"};
|
||||||
|
std::bool_t check_config{};
|
||||||
|
std::bool_t health_check{};
|
||||||
|
std::bool_t print_version{};
|
||||||
|
std::bool_t exit_after_startup{};
|
||||||
|
std::bool_t migrate{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected<vamp_zero::command_line, modulus::error>;
|
||||||
|
auto run(vamp_zero::command_line command) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <luma.hpp>
|
||||||
|
|
||||||
|
#include <vamp_style.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
auto to_luma(vamp_style::color value) -> luma::color;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <boost/json/value.hpp>
|
||||||
|
|
||||||
|
#include <ligo.hpp>
|
||||||
|
#include <luma.hpp>
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
struct runtime_config {
|
||||||
|
vamp_zero::hardware_mode hardware{vamp_zero::hardware_mode::REAL};
|
||||||
|
std::bool_t headless{false};
|
||||||
|
std::bool_t exit_after_startup{false};
|
||||||
|
std::string assets_path{"/usr/share/product-vamp"};
|
||||||
|
std::string heartbeat_path{"/run/product-vamp/heartbeat.json"};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct database_config {
|
||||||
|
std::string path{"/var/lib/product-vamp/vamp.db"};
|
||||||
|
std::string migrations_path{"/usr/share/product-vamp/migrations"};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct control_config {
|
||||||
|
ligo::endpoint_config endpoint{
|
||||||
|
.name = "product-vamp-control",
|
||||||
|
.protocol = ligo::protocol::HTTP,
|
||||||
|
.bind = "0.0.0.0:8080",
|
||||||
|
.timeout = std::chrono::milliseconds{5000},
|
||||||
|
.http = {
|
||||||
|
.max_body_size = 64U * 1024U,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
std::string ap_ssid{"Vamp Control"};
|
||||||
|
std::string ap_passphrase{"vamp-control"};
|
||||||
|
std::string ap_interface{"wlan0"};
|
||||||
|
std::string zero_ip{"192.168.42.1"};
|
||||||
|
std::string zero_cidr{"192.168.42.1/24"};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct config {
|
||||||
|
modulus::config modulus{};
|
||||||
|
vamp_zero::runtime_config runtime{};
|
||||||
|
vamp_zero::database_config database{};
|
||||||
|
vamp_zero::control_config control{};
|
||||||
|
luma::application_config display{.title = "Product Vamp"};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto parse_config(boost::json::value const& value) -> std::expected<vamp_zero::config, modulus::error>;
|
||||||
|
auto load_config(std::string const& path) -> std::expected<vamp_zero::config, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <boost/asio/any_io_executor.hpp>
|
||||||
|
#include <boost/asio/awaitable.hpp>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <vamp_zero/config.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
auto async_migrate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||||
|
auto async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||||
|
auto async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>>;
|
||||||
|
auto async_load_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 split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
enum class error_code : std::int32_t {
|
||||||
|
OK = 0,
|
||||||
|
INVALID_ARGUMENT = 1,
|
||||||
|
NOT_FOUND = 2,
|
||||||
|
UNAVAILABLE = 3,
|
||||||
|
PERSISTENCE = 4,
|
||||||
|
CONFLICT = 5,
|
||||||
|
INTERNAL = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
auto make_error(vamp_zero::error_code code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error;
|
||||||
|
auto fail_void(vamp_zero::error_code code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto fail(vamp_zero::error_code code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<VALUE, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename VALUE>
|
||||||
|
auto vamp_zero::fail(vamp_zero::error_code code, std::string message, modulus::source_location location) -> std::expected<VALUE, modulus::error> {
|
||||||
|
return modulus::fail<VALUE>(vamp_zero::make_error(code, std::move(message), location));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
auto parse_score_document(std::string_view json, std::string source_path = {}) -> std::expected<vamp_zero::score_document, modulus::error>;
|
||||||
|
auto load_performance_library(std::string const& assets_path) -> std::expected<vamp_zero::library_state, modulus::error>;
|
||||||
|
auto load_practice_library(std::string const& assets_path) -> std::expected<vamp_zero::practice_library, modulus::error>;
|
||||||
|
auto group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state;
|
||||||
|
auto group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> vamp_zero::practice_library;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <boost/json/value.hpp>
|
||||||
|
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <vamp_protocol/page_control.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
auto apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state;
|
||||||
|
auto app_status_json(vamp_zero::app_state const& state) -> boost::json::value;
|
||||||
|
auto remote_display_text(vamp_zero::app_state const& state) -> std::string;
|
||||||
|
auto validate_app_state(vamp_zero::app_state const& state) -> std::expected<void, modulus::error>;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <boost/json/value.hpp>
|
||||||
|
|
||||||
|
#include <ligo.hpp>
|
||||||
|
|
||||||
|
#include <vamp_protocol/page_control.hpp>
|
||||||
|
#include <vamp_zero/config.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
class control_state {
|
||||||
|
public:
|
||||||
|
using observer = std::function<void(vamp_zero::app_state)>;
|
||||||
|
|
||||||
|
control_state(vamp_zero::config config, vamp_zero::app_state state);
|
||||||
|
auto config() const -> vamp_zero::config;
|
||||||
|
auto snapshot() const -> vamp_zero::app_state;
|
||||||
|
auto apply(vamp_protocol::page_command command) -> vamp_zero::app_state;
|
||||||
|
auto heartbeat() -> vamp_zero::app_state;
|
||||||
|
auto refresh_connection() -> std::optional<vamp_zero::app_state>;
|
||||||
|
auto set_observer(vamp_zero::control_state::observer observer) -> void;
|
||||||
|
private:
|
||||||
|
auto pico_connected_locked() const -> std::bool_t;
|
||||||
|
auto snapshot_locked() const -> vamp_zero::app_state;
|
||||||
|
mutable std::mutex mutex_{};
|
||||||
|
vamp_zero::config config_{};
|
||||||
|
vamp_zero::app_state state_{};
|
||||||
|
std::chrono::steady_clock::time_point last_heartbeat_{};
|
||||||
|
vamp_zero::control_state::observer observer_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto make_router(vamp_zero::control_state& state) -> ligo::service_router;
|
||||||
|
auto portal_status_json(vamp_zero::config const& config, vamp_zero::app_state const& state) -> boost::json::value;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include <luma.hpp>
|
||||||
|
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
constexpr auto score_measures_per_system = std::size_t{4};
|
||||||
|
constexpr auto score_systems_per_page = std::size_t{3};
|
||||||
|
constexpr auto score_measures_per_page = score_measures_per_system * score_systems_per_page;
|
||||||
|
|
||||||
|
auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
enum class hardware_mode {
|
||||||
|
FAKE,
|
||||||
|
REAL,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_pitch {
|
||||||
|
std::char_t letter{'c'};
|
||||||
|
std::int32_t octave{};
|
||||||
|
std::string accidental{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_note {
|
||||||
|
std::string id{};
|
||||||
|
std::string kind{};
|
||||||
|
std::bool_t hidden{};
|
||||||
|
std::int64_t duration_ticks{};
|
||||||
|
std::int64_t display_ticks{};
|
||||||
|
std::string notehead{};
|
||||||
|
std::string chord_symbol{};
|
||||||
|
std::string lyric{};
|
||||||
|
std::bool_t tie_to_next{};
|
||||||
|
std::bool_t tie_from_previous{};
|
||||||
|
std::vector<vamp_zero::score_pitch> pitches{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_measure {
|
||||||
|
std::string id{};
|
||||||
|
std::string section_label{};
|
||||||
|
std::string time_signature{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::vector<vamp_zero::score_note> notes{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_document {
|
||||||
|
std::string id{};
|
||||||
|
std::string title{};
|
||||||
|
std::string composer{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::string part{};
|
||||||
|
std::string arrangement_part_type{};
|
||||||
|
std::string source_path{};
|
||||||
|
std::string source_json{};
|
||||||
|
std::vector<vamp_zero::score_measure> measures{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct library_part {
|
||||||
|
std::string id{};
|
||||||
|
std::string title{};
|
||||||
|
std::string composer{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::string part{};
|
||||||
|
std::string source_path{};
|
||||||
|
std::string source_json{};
|
||||||
|
std::size_t measure_count{};
|
||||||
|
std::size_t total_pages{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct song_group {
|
||||||
|
std::string title{};
|
||||||
|
std::string composer{};
|
||||||
|
std::vector<vamp_zero::library_part> parts{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct library_state {
|
||||||
|
std::vector<vamp_zero::library_part> parts{};
|
||||||
|
std::vector<vamp_zero::song_group> groups{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct practice_exercise {
|
||||||
|
struct score_variant {
|
||||||
|
std::string id{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::string source_path{};
|
||||||
|
std::string source_json{};
|
||||||
|
std::size_t measure_count{};
|
||||||
|
std::size_t total_pages{};
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string id{};
|
||||||
|
std::string practice_type_id{};
|
||||||
|
std::string practice_type_title{};
|
||||||
|
std::string lesson_group_id{};
|
||||||
|
std::string lesson_group_title{};
|
||||||
|
std::string title{};
|
||||||
|
std::string composer{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::string source_path{};
|
||||||
|
std::string source_json{};
|
||||||
|
std::size_t measure_count{};
|
||||||
|
std::size_t total_pages{};
|
||||||
|
std::vector<vamp_zero::practice_exercise::score_variant> variants{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct practice_lesson_group {
|
||||||
|
std::string id{};
|
||||||
|
std::string title{};
|
||||||
|
std::vector<vamp_zero::practice_exercise> exercises{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct practice_type_group {
|
||||||
|
std::string id{};
|
||||||
|
std::string title{};
|
||||||
|
std::vector<vamp_zero::practice_lesson_group> lesson_groups{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct practice_library {
|
||||||
|
std::vector<vamp_zero::practice_exercise> exercises{};
|
||||||
|
std::vector<vamp_zero::practice_type_group> types{};
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class app_mode {
|
||||||
|
MAIN_MENU,
|
||||||
|
PERFORMANCE_SONG_MENU,
|
||||||
|
PERFORMANCE_PART_MENU,
|
||||||
|
PERFORMANCE_SCORE,
|
||||||
|
PRACTICE_TYPE_MENU,
|
||||||
|
PRACTICE_LESSON_MENU,
|
||||||
|
PRACTICE_EXERCISE_MENU,
|
||||||
|
PRACTICE_KEY_MENU,
|
||||||
|
PRACTICE_SCORE,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct app_state {
|
||||||
|
vamp_zero::library_state performance{};
|
||||||
|
vamp_zero::practice_library practice{};
|
||||||
|
vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU};
|
||||||
|
std::size_t selected_main_menu_index{};
|
||||||
|
std::size_t selected_song_index{};
|
||||||
|
std::size_t selected_part_index{};
|
||||||
|
std::size_t selected_practice_type_index{};
|
||||||
|
std::size_t selected_practice_lesson_index{};
|
||||||
|
std::size_t selected_practice_exercise_index{};
|
||||||
|
std::size_t selected_practice_key_index{};
|
||||||
|
std::string active_part_id{};
|
||||||
|
std::string active_practice_id{};
|
||||||
|
std::size_t page_index{};
|
||||||
|
std::uint64_t next_count{};
|
||||||
|
std::uint64_t previous_count{};
|
||||||
|
std::uint64_t select_count{};
|
||||||
|
std::uint64_t heartbeat_count{};
|
||||||
|
std::string last_command{"startup"};
|
||||||
|
std::bool_t pico_connected{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto to_string(vamp_zero::hardware_mode value) -> std::string;
|
||||||
|
auto to_string(vamp_zero::app_mode value) -> std::string;
|
||||||
|
auto practice_key_names() -> std::vector<std::string_view> const&;
|
||||||
|
auto practice_type_supports_key_selection(std::string_view practice_type_id) -> std::bool_t;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <boost/asio/any_io_executor.hpp>
|
||||||
|
|
||||||
|
#include <luma.hpp>
|
||||||
|
#include <modulus.hpp>
|
||||||
|
|
||||||
|
#include <vamp_zero/config.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
struct ui_state {
|
||||||
|
vamp_zero::app_state app{};
|
||||||
|
std::string ap_ssid{};
|
||||||
|
std::string zero_ip{};
|
||||||
|
std::uint16_t port{8080};
|
||||||
|
};
|
||||||
|
|
||||||
|
class zero_ui {
|
||||||
|
public:
|
||||||
|
zero_ui(boost::asio::any_io_executor executor, luma::application_config config);
|
||||||
|
auto run() -> std::expected<void, modulus::error>;
|
||||||
|
auto stop() -> void;
|
||||||
|
auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>;
|
||||||
|
auto snapshot() const -> vamp_zero::ui_state;
|
||||||
|
private:
|
||||||
|
auto make_root(vamp_zero::ui_state const& state) -> std::expected<std::unique_ptr<luma::control>, modulus::error>;
|
||||||
|
boost::asio::any_io_executor executor_;
|
||||||
|
luma::application application_;
|
||||||
|
mutable std::mutex mutex_{};
|
||||||
|
vamp_zero::ui_state state_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
auto make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state;
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"package": {
|
||||||
|
"name": "product-vamp",
|
||||||
|
"artifact": {
|
||||||
|
"enabled": true,
|
||||||
|
"targets": [
|
||||||
|
"arm_64_gcc12_release"
|
||||||
|
],
|
||||||
|
"install_prefix": ".",
|
||||||
|
"cmake_definitions": {
|
||||||
|
"VAMP_BUILD_PICO_FIRMWARE": true,
|
||||||
|
"VAMP_BUILD_ZERO_IMAGE": true
|
||||||
|
},
|
||||||
|
"registry_url": "https://git.thyme-labs.com/api/packages/khodo/generic",
|
||||||
|
"upload_env": "PRODUCT_VAMP_PACKAGE_UPLOAD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"enabled": true,
|
||||||
|
"name": "product-vamp"
|
||||||
|
},
|
||||||
|
"tooling": {
|
||||||
|
"default_target": "x86_64_gcc12_debug",
|
||||||
|
"docker": {
|
||||||
|
"image": "product-vamp-modulus-build:dev"
|
||||||
|
},
|
||||||
|
"remote": {
|
||||||
|
"name": "khodo",
|
||||||
|
"url": "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
|
},
|
||||||
|
"lsp": {
|
||||||
|
"enabled": true,
|
||||||
|
"compile_databases": [
|
||||||
|
{
|
||||||
|
"name": "pico",
|
||||||
|
"build_dir": "build/build_pico_debug",
|
||||||
|
"cmake_definitions": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
|
"VAMP_PICO_FIRMWARE_PROJECT": true,
|
||||||
|
"PICO_BOARD": "pico2_w",
|
||||||
|
"PICO_GCC_TRIPLE": "arm-none-eabi",
|
||||||
|
"PICO_SDK_FETCH_FROM_GIT": false
|
||||||
|
},
|
||||||
|
"unset_env": [
|
||||||
|
"CC",
|
||||||
|
"CXX",
|
||||||
|
"CPP",
|
||||||
|
"AS",
|
||||||
|
"AR",
|
||||||
|
"LD",
|
||||||
|
"RANLIB",
|
||||||
|
"STRIP",
|
||||||
|
"CFLAGS",
|
||||||
|
"CXXFLAGS",
|
||||||
|
"CPPFLAGS",
|
||||||
|
"LDFLAGS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"enabled": true,
|
||||||
|
"target": "x86_64_gcc12_debug",
|
||||||
|
"executable": "vamp-zero",
|
||||||
|
"args": [
|
||||||
|
"--config",
|
||||||
|
"package/config/vamp.example.json",
|
||||||
|
"--once"
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"127.0.0.1:8080:8080/tcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"x86_64_gcc12_debug": {
|
||||||
|
"profile": "conan/profiles/x86_64_gcc12_debug",
|
||||||
|
"build_dir": "build/build_x86_debug",
|
||||||
|
"can_run": true,
|
||||||
|
"build_type": "Debug"
|
||||||
|
},
|
||||||
|
"x86_64_gcc12_release": {
|
||||||
|
"profile": "conan/profiles/x86_64_gcc12_release",
|
||||||
|
"build_dir": "build/build_x86_release",
|
||||||
|
"can_run": true,
|
||||||
|
"build_type": "Release"
|
||||||
|
},
|
||||||
|
"arm_64_gcc12_release": {
|
||||||
|
"profile": "conan/profiles/arm_64_gcc12_release",
|
||||||
|
"build_dir": "build/build_arm_release",
|
||||||
|
"can_run": false,
|
||||||
|
"build_type": "Release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+519
@@ -0,0 +1,519 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
PACKAGE_REF = "modulus/latest"
|
||||||
|
CONAN_VERSION = "2.26.2"
|
||||||
|
FETCH_IMAGE = "modulus-fetch:dev"
|
||||||
|
DEFAULT_BUILD_BASE_IMAGE = "debian:trixie-slim"
|
||||||
|
PICO_SDK_VERSION = "2.3.0"
|
||||||
|
COMMANDS = (
|
||||||
|
"configure",
|
||||||
|
"build",
|
||||||
|
"test",
|
||||||
|
"package",
|
||||||
|
"lsp",
|
||||||
|
"run",
|
||||||
|
)
|
||||||
|
COMMON_DIR = pathlib.Path("build") / "build_common"
|
||||||
|
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
||||||
|
RUNTIME_DIR = COMMON_DIR / "modulus"
|
||||||
|
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
||||||
|
DEFAULT_REMOTE_NAME = "khodo"
|
||||||
|
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||||
|
|
||||||
|
def repo_root():
|
||||||
|
root = pathlib.Path(__file__).resolve().parent
|
||||||
|
if not (root / "modulus.json").is_file():
|
||||||
|
raise SystemExit("modulus.py must be run from a Modulus-managed repository root")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise SystemExit(f"failed to read {path}: {error}") from error
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} must contain a JSON object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def remote_config(root):
|
||||||
|
config = read_json(root / "modulus.json")
|
||||||
|
remote = config.get(
|
||||||
|
"tooling",
|
||||||
|
{},
|
||||||
|
).get("remote", {})
|
||||||
|
if not isinstance(remote, dict):
|
||||||
|
remote = {}
|
||||||
|
return (
|
||||||
|
str(remote.get("name") or DEFAULT_REMOTE_NAME),
|
||||||
|
str(remote.get("url") or DEFAULT_REMOTE_URL),
|
||||||
|
)
|
||||||
|
|
||||||
|
def runtime_ready(root):
|
||||||
|
return (
|
||||||
|
(root / TOOLS_DIR / "configure.py").is_file()
|
||||||
|
and (root / TOOLS_DIR / "lsp.py").is_file()
|
||||||
|
and (root / TOOLS_DIR / "run.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file()
|
||||||
|
and (root / RUNTIME_DIR / "modulus" / "export.py").is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
def optional_credentials():
|
||||||
|
user = os.environ.get("CONAN_USER")
|
||||||
|
token = os.environ.get("CONAN_TOKEN")
|
||||||
|
if bool(user) != bool(token):
|
||||||
|
raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset")
|
||||||
|
return user, token
|
||||||
|
|
||||||
|
def env_flag(name):
|
||||||
|
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
def direct_mode(root):
|
||||||
|
return env_flag("MODULUS_DIRECT") or project_docker_marker(root).exists()
|
||||||
|
|
||||||
|
def run(command, input_text=None, capture=False, display_command=None, env=None, check=True):
|
||||||
|
visible = display_command if display_command is not None else command
|
||||||
|
print("+", " ".join(shlex.quote(str(part)) for part in visible))
|
||||||
|
completed = subprocess.run(
|
||||||
|
[str(part) for part in command],
|
||||||
|
input=input_text,
|
||||||
|
stdout=subprocess.PIPE if capture else None,
|
||||||
|
stderr=subprocess.PIPE if capture else None,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if check and completed.returncode != 0:
|
||||||
|
if capture and completed.stdout:
|
||||||
|
print(completed.stdout, end="")
|
||||||
|
if capture and completed.stderr:
|
||||||
|
print(completed.stderr, end="", file=sys.stderr)
|
||||||
|
raise SystemExit(completed.returncode)
|
||||||
|
return completed
|
||||||
|
|
||||||
|
def run_with_retries(command, attempts=3, input_text=None, display_command=None, env=None):
|
||||||
|
completed = None
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
completed = run(
|
||||||
|
command,
|
||||||
|
input_text=input_text,
|
||||||
|
display_command=display_command,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode == 0:
|
||||||
|
return completed
|
||||||
|
if attempt != attempts:
|
||||||
|
delay = attempt * 20
|
||||||
|
print(f"command failed with exit code {completed.returncode}; retrying in {delay}s")
|
||||||
|
time.sleep(delay)
|
||||||
|
raise SystemExit(completed.returncode if completed is not None else 1)
|
||||||
|
|
||||||
|
def fetch_dockerfile():
|
||||||
|
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update \\
|
||||||
|
&& apt-get install -y --no-install-recommends \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-venv \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/modulus-python
|
||||||
|
|
||||||
|
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
"""
|
||||||
|
|
||||||
|
def product_build_dockerfile():
|
||||||
|
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN dpkg --add-architecture arm64
|
||||||
|
|
||||||
|
RUN apt-get update \\
|
||||||
|
&& apt-get install -y --no-install-recommends \\
|
||||||
|
bash \\
|
||||||
|
build-essential \\
|
||||||
|
ca-certificates \\
|
||||||
|
clangd-19 \\
|
||||||
|
cmake \\
|
||||||
|
curl \\
|
||||||
|
docker-buildx \\
|
||||||
|
docker-cli \\
|
||||||
|
docker.io \\
|
||||||
|
g++-12 \\
|
||||||
|
g++-12-aarch64-linux-gnu \\
|
||||||
|
gcc-12 \\
|
||||||
|
gcc-12-aarch64-linux-gnu \\
|
||||||
|
gcc-arm-none-eabi \\
|
||||||
|
git \\
|
||||||
|
libdrm-dev \\
|
||||||
|
libdrm-dev:arm64 \\
|
||||||
|
libegl-dev \\
|
||||||
|
libegl-dev:arm64 \\
|
||||||
|
libgbm-dev \\
|
||||||
|
libgbm-dev:arm64 \\
|
||||||
|
libgles-dev \\
|
||||||
|
libgles-dev:arm64 \\
|
||||||
|
libglvnd-dev \\
|
||||||
|
libglvnd-dev:arm64 \\
|
||||||
|
libnewlib-arm-none-eabi \\
|
||||||
|
libstdc++-arm-none-eabi-newlib \\
|
||||||
|
ninja-build \\
|
||||||
|
pkg-config \\
|
||||||
|
python3 \\
|
||||||
|
python3-venv \\
|
||||||
|
&& ln -sf /usr/bin/clangd-19 /usr/local/bin/clangd \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV CC=/usr/bin/gcc-12
|
||||||
|
ENV CXX=/usr/bin/g++-12
|
||||||
|
ENV VAMP_PICO_SDK_VERSION={PICO_SDK_VERSION}
|
||||||
|
ENV PICO_SDK_PATH=/opt/pico-sdk
|
||||||
|
|
||||||
|
RUN git clone \\
|
||||||
|
--depth 1 \\
|
||||||
|
--branch "$VAMP_PICO_SDK_VERSION" \\
|
||||||
|
--recurse-submodules \\
|
||||||
|
--shallow-submodules \\
|
||||||
|
https://github.com/raspberrypi/pico-sdk.git \\
|
||||||
|
"$PICO_SDK_PATH"
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/modulus-python
|
||||||
|
|
||||||
|
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir \\
|
||||||
|
conan=={CONAN_VERSION}
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
RUN mkdir -p /opt && touch /opt/modulus-build-image
|
||||||
|
"""
|
||||||
|
|
||||||
|
def sync_product_build_dockerfile(root):
|
||||||
|
dockerfile = root / TOOLS_DIR / "docker" / "Dockerfile"
|
||||||
|
if not dockerfile.parent.is_dir():
|
||||||
|
return
|
||||||
|
desired = product_build_dockerfile()
|
||||||
|
try:
|
||||||
|
current = dockerfile.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
current = None
|
||||||
|
if current != desired:
|
||||||
|
dockerfile.write_text(desired, encoding="utf-8")
|
||||||
|
|
||||||
|
def ensure_fetch_image():
|
||||||
|
if not docker_platform_args():
|
||||||
|
inspected = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"image",
|
||||||
|
"inspect",
|
||||||
|
FETCH_IMAGE,
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if inspected.returncode == 0:
|
||||||
|
return
|
||||||
|
run_with_retries(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
*docker_platform_args(),
|
||||||
|
"--tag",
|
||||||
|
FETCH_IMAGE,
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
input_text=fetch_dockerfile(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_container():
|
||||||
|
return pathlib.Path("/.dockerenv").exists()
|
||||||
|
|
||||||
|
def docker_workspace_args(root):
|
||||||
|
if in_container():
|
||||||
|
container = os.environ.get("HOSTNAME", "").strip()
|
||||||
|
if not container:
|
||||||
|
raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container")
|
||||||
|
return [
|
||||||
|
"--volumes-from",
|
||||||
|
container,
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
"--volume",
|
||||||
|
f"{root}:{root}",
|
||||||
|
]
|
||||||
|
|
||||||
|
def docker_socket_args():
|
||||||
|
socket = pathlib.Path("/var/run/docker.sock")
|
||||||
|
if not socket.is_socket():
|
||||||
|
return []
|
||||||
|
args = [
|
||||||
|
"--volume",
|
||||||
|
"/var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
args.extend(["--group-add", str(socket.stat().st_gid)])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return args
|
||||||
|
|
||||||
|
def docker_platform_args():
|
||||||
|
platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip()
|
||||||
|
if not platform:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"--platform",
|
||||||
|
platform,
|
||||||
|
]
|
||||||
|
|
||||||
|
def fetch_env(root):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CONAN_HOME"] = str(root / CONAN_HOME_DIR)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def run_fetch(root, command, capture=False, display_command=None, check=True):
|
||||||
|
if direct_mode(root):
|
||||||
|
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||||
|
ensure_fetch_image()
|
||||||
|
docker_command = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--interactive",
|
||||||
|
"--user",
|
||||||
|
f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--env",
|
||||||
|
"HOME=/tmp",
|
||||||
|
"--env",
|
||||||
|
"CONAN_HOME",
|
||||||
|
"--env",
|
||||||
|
"CONAN_USER",
|
||||||
|
"--env",
|
||||||
|
"CONAN_TOKEN",
|
||||||
|
"--env",
|
||||||
|
"DOCKER_HOST",
|
||||||
|
*docker_workspace_args(root),
|
||||||
|
*docker_socket_args(),
|
||||||
|
"--workdir",
|
||||||
|
str(root),
|
||||||
|
FETCH_IMAGE,
|
||||||
|
*command,
|
||||||
|
]
|
||||||
|
return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||||
|
|
||||||
|
def configure_remote(root, remote_name, remote_url):
|
||||||
|
user, token = optional_credentials()
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"-f",
|
||||||
|
remote_name,
|
||||||
|
remote_url,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if not user and not token:
|
||||||
|
return
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"login",
|
||||||
|
remote_name,
|
||||||
|
user,
|
||||||
|
"-p",
|
||||||
|
token,
|
||||||
|
],
|
||||||
|
display_command=[
|
||||||
|
"conan",
|
||||||
|
"remote",
|
||||||
|
"login",
|
||||||
|
remote_name,
|
||||||
|
user,
|
||||||
|
"-p",
|
||||||
|
"<hidden>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def package_refs(data):
|
||||||
|
refs = []
|
||||||
|
for location_data in data.values():
|
||||||
|
if not isinstance(location_data, dict):
|
||||||
|
continue
|
||||||
|
for ref, ref_data in location_data.items():
|
||||||
|
if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict):
|
||||||
|
continue
|
||||||
|
revisions = ref_data.get(
|
||||||
|
"revisions",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if not isinstance(revisions, dict):
|
||||||
|
continue
|
||||||
|
for recipe_revision, revision_data in revisions.items():
|
||||||
|
if not isinstance(revision_data, dict):
|
||||||
|
continue
|
||||||
|
packages = revision_data.get(
|
||||||
|
"packages",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
continue
|
||||||
|
for package_id, package_data in packages.items():
|
||||||
|
package_revisions = package_data.get(
|
||||||
|
"revisions",
|
||||||
|
{},
|
||||||
|
)if isinstance(package_data, dict) else {}
|
||||||
|
if isinstance(package_revisions, dict) and package_revisions:
|
||||||
|
for package_revision in package_revisions:
|
||||||
|
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}")
|
||||||
|
else:
|
||||||
|
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
|
||||||
|
return sorted(refs)
|
||||||
|
|
||||||
|
def remove_cached_modulus(root):
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"remove",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-c",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_modulus(root, remote_name):
|
||||||
|
remove_cached_modulus(root)
|
||||||
|
run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"download",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-r",
|
||||||
|
remote_name,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
completed = run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"list",
|
||||||
|
PACKAGE_REF + ":*",
|
||||||
|
"-c",
|
||||||
|
"-f",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
refs = package_refs(json.loads(completed.stdout))
|
||||||
|
if not refs:
|
||||||
|
raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache")
|
||||||
|
package_ref = refs[-1]
|
||||||
|
completed = run_fetch(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"conan",
|
||||||
|
"cache",
|
||||||
|
"path",
|
||||||
|
package_ref,
|
||||||
|
],
|
||||||
|
capture=True,
|
||||||
|
)
|
||||||
|
package_root = pathlib.Path(completed.stdout.strip())
|
||||||
|
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
|
||||||
|
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
|
||||||
|
return package_root
|
||||||
|
|
||||||
|
def materialize_runtime(root):
|
||||||
|
remote_name, remote_url = remote_config(root)
|
||||||
|
configure_remote(root, remote_name, remote_url)
|
||||||
|
package_root = download_modulus(root, remote_name)
|
||||||
|
sys.path.insert(0, str(package_root))
|
||||||
|
import modulus.export
|
||||||
|
modulus.export.sync_project(root, package_root)
|
||||||
|
|
||||||
|
def ensure_runtime(root):
|
||||||
|
if not runtime_ready(root):
|
||||||
|
materialize_runtime(root)
|
||||||
|
sync_product_build_dockerfile(root)
|
||||||
|
|
||||||
|
def project_docker_marker(root):
|
||||||
|
config = read_json(root / "modulus.json")
|
||||||
|
tooling = config.get("tooling", {})
|
||||||
|
if not isinstance(tooling, dict):
|
||||||
|
tooling = {}
|
||||||
|
docker = tooling.get("docker", {})
|
||||||
|
if not isinstance(docker, dict):
|
||||||
|
docker = {}
|
||||||
|
return pathlib.Path(str(docker.get("marker") or "/opt/modulus-build-image"))
|
||||||
|
|
||||||
|
def run_configure(argv):
|
||||||
|
return delegate("configure", argv)
|
||||||
|
|
||||||
|
def run_package(argv):
|
||||||
|
return delegate("package", argv)
|
||||||
|
|
||||||
|
def delegate(command, argv):
|
||||||
|
root = repo_root()
|
||||||
|
ensure_runtime(root)
|
||||||
|
sys.path.insert(0, str(root / RUNTIME_DIR))
|
||||||
|
import modulus.cli
|
||||||
|
original_docker_run = modulus.cli.docker_run
|
||||||
|
|
||||||
|
def docker_run_with_host_socket(context, command, docker_args=None, env_overrides=None):
|
||||||
|
merged_docker_args = [
|
||||||
|
*docker_socket_args(),
|
||||||
|
*(docker_args or []),
|
||||||
|
]
|
||||||
|
return original_docker_run(context, command, docker_args=merged_docker_args, env_overrides=env_overrides)
|
||||||
|
|
||||||
|
modulus.cli.docker_run = docker_run_with_host_socket
|
||||||
|
previous_argv = sys.argv
|
||||||
|
previous_cwd = pathlib.Path.cwd()
|
||||||
|
sys.argv = [
|
||||||
|
str(root / "modulus.py"),
|
||||||
|
*argv,
|
||||||
|
]
|
||||||
|
os.chdir(root)
|
||||||
|
try:
|
||||||
|
return modulus.cli.main(command)
|
||||||
|
finally:
|
||||||
|
os.chdir(previous_cwd)
|
||||||
|
sys.argv = previous_argv
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if not argv or argv[0] in ("-h", "--help"):
|
||||||
|
print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...")
|
||||||
|
return 0 if argv else 2
|
||||||
|
command = argv[0]
|
||||||
|
if command not in COMMANDS:
|
||||||
|
raise SystemExit(f"unknown Modulus command: {command}")
|
||||||
|
if command == "configure":
|
||||||
|
return run_configure(argv[1:])
|
||||||
|
if command == "package":
|
||||||
|
return run_package(argv[1:])
|
||||||
|
return delegate(command, argv[1:])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright © 2018, Steinberg Media Technologies GmbH (http://www.steinberg.net/),
|
||||||
|
with Reserved Font Name "Petaluma".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"modulus": {
|
||||||
|
"logging": {
|
||||||
|
"level": "info"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"include_category": true,
|
||||||
|
"include_code": true,
|
||||||
|
"include_message": true,
|
||||||
|
"include_location": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"hardware": "real",
|
||||||
|
"headless": false,
|
||||||
|
"exit_after_startup": false,
|
||||||
|
"assets_path": "/usr/share/product-vamp",
|
||||||
|
"heartbeat_path": "/run/product-vamp/heartbeat.json"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"path": "/var/lib/product-vamp/vamp.db",
|
||||||
|
"migrations_path": "/usr/share/product-vamp/migrations"
|
||||||
|
},
|
||||||
|
"control": {
|
||||||
|
"endpoint": {
|
||||||
|
"name": "product-vamp-control",
|
||||||
|
"protocol": "http",
|
||||||
|
"bind": "0.0.0.0:8080",
|
||||||
|
"timeout_ms": 5000,
|
||||||
|
"http": {
|
||||||
|
"max_body_size": 65536
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ap_ssid": "Vamp Control",
|
||||||
|
"ap_passphrase": "vamp-control",
|
||||||
|
"ap_interface": "wlan0",
|
||||||
|
"zero_ip": "192.168.42.1",
|
||||||
|
"zero_cidr": "192.168.42.1/24"
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"title": "Product Vamp",
|
||||||
|
"display": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 600,
|
||||||
|
"target_fps": 60,
|
||||||
|
"idle_fps": 15,
|
||||||
|
"fullscreen": true
|
||||||
|
},
|
||||||
|
"touch": {
|
||||||
|
"max_points": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"package": "product-vamp",
|
||||||
|
"version": "@PROJECT_VERSION@",
|
||||||
|
"git_sha": "@VAMP_GIT_SHA@",
|
||||||
|
"zero": {
|
||||||
|
"device": "raspberry-pi-zero-2w",
|
||||||
|
"display": "waveshare-zero-disp-7a",
|
||||||
|
"docker_archive": "zero/product-vamp-zero-linux-arm64.docker.tar",
|
||||||
|
"image_tag": "@VAMP_ZERO_IMAGE_TAG@",
|
||||||
|
"default_config": "zero/config/vamp.example.json",
|
||||||
|
"control_endpoint": "http://192.168.42.1:8080"
|
||||||
|
},
|
||||||
|
"pico": {
|
||||||
|
"device": "raspberry-pi-pico-2w",
|
||||||
|
"display": "waveshare-2inch-capacitive-touch-lcd",
|
||||||
|
"firmware": "pico/product-vamp-pico2w.uf2",
|
||||||
|
"default_wifi_ssid": "@VAMP_PICO_WIFI_SSID@",
|
||||||
|
"default_zero_host": "@VAMP_ZERO_HOST@",
|
||||||
|
"default_zero_port": @VAMP_ZERO_PORT@
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE songs (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||||
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||||
|
composer TEXT NOT NULL DEFAULT '',
|
||||||
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||||
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||||
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||||
|
) STRICT;
|
||||||
|
-- vamp-statement
|
||||||
|
CREATE TABLE page_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||||
|
song_id TEXT NOT NULL CHECK(length(trim(song_id)) BETWEEN 1 AND 120),
|
||||||
|
page_index INTEGER NOT NULL CHECK(page_index >= 0),
|
||||||
|
next_count INTEGER NOT NULL DEFAULT 0 CHECK(next_count >= 0),
|
||||||
|
previous_count INTEGER NOT NULL DEFAULT 0 CHECK(previous_count >= 0),
|
||||||
|
last_command TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= 0)
|
||||||
|
) STRICT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT '';
|
||||||
|
-- vamp-statement
|
||||||
|
ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT '';
|
||||||
|
-- vamp-statement
|
||||||
|
CREATE INDEX idx_songs_title_part ON songs(title, part);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT '';
|
||||||
|
-- vamp-statement
|
||||||
|
CREATE TABLE practice_exercises (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||||
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||||
|
composer TEXT NOT NULL DEFAULT '',
|
||||||
|
key_signature TEXT NOT NULL DEFAULT 'C',
|
||||||
|
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||||
|
source_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||||
|
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||||
|
) STRICT;
|
||||||
|
-- vamp-statement
|
||||||
|
CREATE INDEX idx_practice_exercises_title ON practice_exercises(title);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general';
|
||||||
|
-- vamp-statement
|
||||||
|
ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice';
|
||||||
|
-- vamp-statement
|
||||||
|
ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general';
|
||||||
|
-- vamp-statement
|
||||||
|
ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General';
|
||||||
|
-- vamp-statement
|
||||||
|
CREATE INDEX idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id, lesson_group_id, source_path, title);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Product Vamp Pico 2 W Wiring
|
||||||
|
|
||||||
|
The Pico firmware targets the Waveshare 2inch Capacitive Touch LCD module using the vendor default Pico pin map.
|
||||||
|
|
||||||
|
| LCD Pin | Pico 2 W Pin |
|
||||||
|
| --- | --- |
|
||||||
|
| 3V3 | 3V3 |
|
||||||
|
| GND | GND |
|
||||||
|
| MISO | GP12 |
|
||||||
|
| MOSI | GP11 |
|
||||||
|
| SCLK | GP10 |
|
||||||
|
| SD_CS | NC |
|
||||||
|
| LCD_CS | GP9 |
|
||||||
|
| LCD_DC | GP14 |
|
||||||
|
| LCD_RST | GP13 |
|
||||||
|
| LCD_BL | GP15 |
|
||||||
|
| TP_SDA | GP6 |
|
||||||
|
| TP_SCL | GP7 |
|
||||||
|
| TP_INT | GP8 |
|
||||||
|
| TP_RST | GP5 |
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 7 ]; then
|
||||||
|
echo "usage: build_pico_firmware.sh BUILD_DIR OUTPUT_UF2 SDK_VERSION WIFI_SSID WIFI_PASSPHRASE ZERO_HOST ZERO_PORT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
build_dir=$1
|
||||||
|
output_uf2=$2
|
||||||
|
sdk_version=$3
|
||||||
|
wifi_ssid=$4
|
||||||
|
wifi_passphrase=$5
|
||||||
|
zero_host=$6
|
||||||
|
zero_port=$7
|
||||||
|
|
||||||
|
pico_sdk_path=${VAMP_PICO_SDK_PATH:-${PICO_SDK_PATH:-}}
|
||||||
|
sdk_args=(
|
||||||
|
-DPICO_SDK_FETCH_FROM_GIT=ON
|
||||||
|
"-DPICO_SDK_FETCH_FROM_GIT_TAG=${sdk_version}"
|
||||||
|
)
|
||||||
|
if [ -n "$pico_sdk_path" ]; then
|
||||||
|
pico_sdk_path=$(cd "$pico_sdk_path" && pwd)
|
||||||
|
sdk_args=(
|
||||||
|
"-DPICO_SDK_PATH=${pico_sdk_path}"
|
||||||
|
-DPICO_SDK_FETCH_FROM_GIT=OFF
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$output_uf2")"
|
||||||
|
|
||||||
|
unset CC CXX CPP AS AR LD RANLIB STRIP CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
|
||||||
|
|
||||||
|
for tool in cmake ninja; do
|
||||||
|
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
echo "missing required tool: $tool" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "${PICO_TOOLCHAIN_PATH:-}" ]; then
|
||||||
|
for tool in arm-none-eabi-gcc arm-none-eabi-g++; do
|
||||||
|
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
echo "missing required tool: $tool, or set PICO_TOOLCHAIN_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmake -E rm -f "$build_dir/CMakeCache.txt"
|
||||||
|
cmake -E rm -rf "$build_dir/CMakeFiles"
|
||||||
|
|
||||||
|
cmake \
|
||||||
|
-S "$source_dir" \
|
||||||
|
-B "$build_dir" \
|
||||||
|
-G Ninja \
|
||||||
|
-DVAMP_PICO_FIRMWARE_PROJECT=ON \
|
||||||
|
-DPICO_BOARD=pico2_w \
|
||||||
|
-DPICO_GCC_TRIPLE=arm-none-eabi \
|
||||||
|
"${sdk_args[@]}" \
|
||||||
|
"-DVAMP_PICO_WIFI_SSID=${wifi_ssid}" \
|
||||||
|
"-DVAMP_PICO_WIFI_PASSPHRASE=${wifi_passphrase}" \
|
||||||
|
"-DVAMP_ZERO_HOST=${zero_host}" \
|
||||||
|
"-DVAMP_ZERO_PORT=${zero_port}"
|
||||||
|
|
||||||
|
cmake --build "$build_dir" --target product-vamp-pico2w --parallel "${VAMP_BUILD_JOBS:-2}"
|
||||||
|
cmake -E copy "$build_dir/product-vamp-pico2w.uf2" "$output_uf2"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 3 ]; then
|
||||||
|
echo "usage: build_zero_image.sh CONTEXT_DIR OUTPUT_TAR IMAGE_TAG" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
context_dir=$1
|
||||||
|
output_tar=$2
|
||||||
|
image_tag=$3
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$output_tar")"
|
||||||
|
rm -f "$output_tar"
|
||||||
|
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag "$image_tag" \
|
||||||
|
--load \
|
||||||
|
"$context_dir"
|
||||||
|
else
|
||||||
|
docker build --platform linux/arm64 --tag "$image_tag" "$context_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm --platform linux/arm64 "$image_tag" /usr/bin/vamp-zero --version
|
||||||
|
docker save "$image_tag" --output "$output_tar"
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DEFAULT_REGISTRY_URL = "https://git.thyme-labs.com/api/packages/khodo/generic"
|
||||||
|
DEFAULT_VERSION = "latest"
|
||||||
|
PAGE_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(path):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise SystemExit(f"failed to read {path}: {error}") from error
|
||||||
|
package = data.get("package", {})
|
||||||
|
if not isinstance(package, dict):
|
||||||
|
raise SystemExit(f"{path}: package must be an object")
|
||||||
|
name = package.get("name")
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
raise SystemExit(f"{path}: package.name must be a non-empty string")
|
||||||
|
artifact = package.get("artifact", {})
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
artifact = {}
|
||||||
|
registry_url = artifact.get("registry_url")
|
||||||
|
if not isinstance(registry_url, str) or not registry_url:
|
||||||
|
registry_url = DEFAULT_REGISTRY_URL
|
||||||
|
return name, registry_url
|
||||||
|
|
||||||
|
|
||||||
|
def parse_registry_url(registry_url):
|
||||||
|
parsed = urllib.parse.urlparse(registry_url.rstrip("/"))
|
||||||
|
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
||||||
|
if len(parts) < 4 or parts[-4] != "api" or parts[-3] != "packages" or parts[-1] != "generic":
|
||||||
|
raise SystemExit("package artifact registry_url must end with /api/packages/{owner}/generic")
|
||||||
|
owner = parts[-2]
|
||||||
|
prefix = "/" + "/".join(urllib.parse.quote(part, safe="") for part in parts[:-4]) if parts[:-4] else ""
|
||||||
|
return parsed, prefix, owner
|
||||||
|
|
||||||
|
|
||||||
|
def build_url(parsed, path, query=""):
|
||||||
|
return urllib.parse.urlunparse((
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
path,
|
||||||
|
"",
|
||||||
|
query,
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def quote_path(*parts):
|
||||||
|
return "/".join(urllib.parse.quote(part, safe="") for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def version_list_url(parsed, prefix, owner, package_name, page):
|
||||||
|
path = f"{prefix}/api/v1/packages/{quote_path(owner, 'generic', package_name)}"
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"page": page,
|
||||||
|
"limit": PAGE_LIMIT,
|
||||||
|
})
|
||||||
|
return build_url(parsed, path, query)
|
||||||
|
|
||||||
|
|
||||||
|
def version_delete_url(parsed, prefix, owner, package_name, version):
|
||||||
|
path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}"
|
||||||
|
return build_url(parsed, path)
|
||||||
|
|
||||||
|
|
||||||
|
def file_upload_url(registry_url, package_name, version, path):
|
||||||
|
return "/".join((
|
||||||
|
registry_url.rstrip("/"),
|
||||||
|
urllib.parse.quote(package_name, safe=""),
|
||||||
|
urllib.parse.quote(version, safe=""),
|
||||||
|
urllib.parse.quote(path.name, safe=""),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def authorization(user, token):
|
||||||
|
encoded = base64.b64encode(f"{user}:{token}".encode("utf-8")).decode("ascii")
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, url, auth, data=None, headers=None, missing_ok=False):
|
||||||
|
request_headers = {
|
||||||
|
"Authorization": auth,
|
||||||
|
**(headers or {}),
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
|
||||||
|
try:
|
||||||
|
return urllib.request.urlopen(request)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if missing_ok and error.code == 404:
|
||||||
|
return None
|
||||||
|
body = error.read().decode("utf-8", errors="replace")
|
||||||
|
message = f"{method} {url} failed with HTTP {error.code}"
|
||||||
|
if body:
|
||||||
|
message += f": {body}"
|
||||||
|
raise SystemExit(message) from error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
raise SystemExit(f"{method} {url} failed: {error.reason}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def list_versions(parsed, prefix, owner, package_name, auth):
|
||||||
|
versions = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
response = request("GET", version_list_url(parsed, prefix, owner, package_name, page), auth, missing_ok=True)
|
||||||
|
if response is None:
|
||||||
|
return []
|
||||||
|
with response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise SystemExit("Gitea package version list response was not an array")
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("version"), str):
|
||||||
|
versions.append(item["version"])
|
||||||
|
if len(data) < PAGE_LIMIT:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return sorted(set(versions))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_version(parsed, prefix, owner, package_name, version, auth):
|
||||||
|
url = version_delete_url(parsed, prefix, owner, package_name, version)
|
||||||
|
response = request("DELETE", url, auth, missing_ok=True)
|
||||||
|
if response is None:
|
||||||
|
print(f"Package version already absent: {package_name}/{version}")
|
||||||
|
return
|
||||||
|
with response:
|
||||||
|
if response.status != 204:
|
||||||
|
raise SystemExit(f"DELETE {url} returned HTTP {response.status}")
|
||||||
|
print(f"Deleted package version: {package_name}/{version}")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_artifacts(dist_dir, package_name, version):
|
||||||
|
archives = sorted(dist_dir.glob(f"{package_name}-{version}-*.tar.gz"))
|
||||||
|
artifacts = []
|
||||||
|
for archive in archives:
|
||||||
|
artifacts.append(archive)
|
||||||
|
checksum = archive.with_name(archive.name + ".sha256")
|
||||||
|
if checksum.is_file():
|
||||||
|
artifacts.append(checksum)
|
||||||
|
if not artifacts:
|
||||||
|
raise SystemExit(f"no artifacts found matching {dist_dir}/{package_name}-{version}-*.tar.gz")
|
||||||
|
missing = [path for path in artifacts if not path.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing))
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(registry_url, package_name, version, path, auth):
|
||||||
|
url = file_upload_url(registry_url, package_name, version, path)
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(path.stat().st_size),
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
}
|
||||||
|
with path.open("rb") as input_file:
|
||||||
|
response = request("PUT", url, auth, data=input_file, headers=headers)
|
||||||
|
with response:
|
||||||
|
if response.status != 201:
|
||||||
|
raise SystemExit(f"PUT {url} returned HTTP {response.status}")
|
||||||
|
print(f"Uploaded package file: {package_name}/{version}/{path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = argparse.ArgumentParser(description="Replace Product Vamp's rolling Gitea generic package.")
|
||||||
|
parser.add_argument("--config", type=pathlib.Path, default=pathlib.Path("modulus.json"))
|
||||||
|
parser.add_argument("--dist-dir", type=pathlib.Path, default=pathlib.Path("build/packages"))
|
||||||
|
parser.add_argument("--version", default=DEFAULT_VERSION)
|
||||||
|
parser.add_argument("--user", default=None)
|
||||||
|
parser.add_argument("--token", default=None)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
args = parse_args(argv)
|
||||||
|
package_name, registry_url = read_config(args.config)
|
||||||
|
user = args.user or os_environ("PACKAGE_REGISTRY_USER")
|
||||||
|
token = args.token or os_environ("PACKAGE_REGISTRY_TOKEN")
|
||||||
|
auth = authorization(user, token)
|
||||||
|
parsed, prefix, owner = parse_registry_url(registry_url)
|
||||||
|
artifacts = discover_artifacts(args.dist_dir, package_name, args.version)
|
||||||
|
|
||||||
|
existing_versions = list_versions(parsed, prefix, owner, package_name, auth)
|
||||||
|
|
||||||
|
# Gitea rejects duplicate file names within a generic package version, so
|
||||||
|
# replacing the rolling release requires deleting the old version first.
|
||||||
|
if args.version in existing_versions:
|
||||||
|
delete_version(parsed, prefix, owner, package_name, args.version, auth)
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
upload_file(registry_url, package_name, args.version, artifact, auth)
|
||||||
|
|
||||||
|
for version in existing_versions:
|
||||||
|
if version != args.version:
|
||||||
|
delete_version(parsed, prefix, owner, package_name, version, auth)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def os_environ(name):
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(f"{name} is required")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libdrm2 \
|
||||||
|
libegl1 \
|
||||||
|
libgbm1 \
|
||||||
|
libgl1 \
|
||||||
|
libgles2 \
|
||||||
|
libglvnd0 \
|
||||||
|
libstdc++6 \
|
||||||
|
libudev1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxcb1 \
|
||||||
|
sqlite3 \
|
||||||
|
tini \
|
||||||
|
util-linux \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY usr/ /usr/
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["/usr/bin/vamp-zero", "--config", "/etc/product-vamp/vamp.json"]
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
bundle_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
zero_dir="$bundle_dir/zero"
|
||||||
|
image_tar="$zero_dir/product-vamp-zero-linux-arm64.docker.tar"
|
||||||
|
config_dir=/etc/product-vamp
|
||||||
|
state_dir=/var/lib/product-vamp
|
||||||
|
run_dir=/run/product-vamp
|
||||||
|
share_dir=/usr/share/product-vamp
|
||||||
|
required_packages=(
|
||||||
|
docker.io
|
||||||
|
docker-cli
|
||||||
|
network-manager
|
||||||
|
sqlite3
|
||||||
|
ca-certificates
|
||||||
|
python3
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "install-zero.sh must be run as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$image_tar" ]; then
|
||||||
|
echo "missing Docker archive: $image_tar" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
missing_packages=()
|
||||||
|
for package in "${required_packages[@]}"; do
|
||||||
|
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -qx 'install ok installed'; then
|
||||||
|
missing_packages+=("$package")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#missing_packages[@]}" -eq 0 ]; then
|
||||||
|
echo "Required OS packages already installed; skipping apt."
|
||||||
|
else
|
||||||
|
echo "Installing missing OS packages: ${missing_packages[*]}"
|
||||||
|
if ! apt-get update -o APT::Update::Error-Mode=any; then
|
||||||
|
echo "Failed to refresh apt package indexes." >&2
|
||||||
|
echo "Connect the Zero to upstream internet, then rerun sudo zero/install-zero.sh." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! apt-get install -y --no-install-recommends "${missing_packages[@]}"; then
|
||||||
|
echo "Failed to install required OS packages: ${missing_packages[*]}" >&2
|
||||||
|
echo "Connect the Zero to upstream internet, then rerun sudo zero/install-zero.sh." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
systemctl enable --now docker NetworkManager
|
||||||
|
|
||||||
|
install -d -m 0755 "$config_dir" "$state_dir" "$run_dir" "$share_dir"
|
||||||
|
cp -R "$zero_dir/share/product-vamp/." "$share_dir/"
|
||||||
|
|
||||||
|
if [ ! -f "$config_dir/vamp.json" ]; then
|
||||||
|
cp "$zero_dir/config/vamp.example.json" "$config_dir/vamp.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker load -i "$image_tar"
|
||||||
|
|
||||||
|
install -m 0755 "$zero_dir/start-ap.sh" /usr/local/bin/product-vamp-start-ap
|
||||||
|
install -m 0755 "$zero_dir/start-zero.sh" /usr/local/bin/product-vamp-start-zero
|
||||||
|
install -m 0644 "$zero_dir/systemd/product-vamp-ap.service" /etc/systemd/system/product-vamp-ap.service
|
||||||
|
install -m 0644 "$zero_dir/systemd/product-vamp-zero.service" /etc/systemd/system/product-vamp-zero.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
|
||||||
|
systemctl enable --now getty@tty2.service >/dev/null 2>&1 || true
|
||||||
|
systemctl enable --now product-vamp-ap.service
|
||||||
|
systemctl enable product-vamp-zero.service
|
||||||
|
systemctl restart product-vamp-zero.service
|
||||||
|
|
||||||
|
echo "Product Vamp Zero installed. Control AP defaults to SSID 'Vamp Control' at http://192.168.42.1:8080."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Product Vamp Wi-Fi control access point
|
||||||
|
After=NetworkManager.service
|
||||||
|
Requires=NetworkManager.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/bin/product-vamp-start-ap
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Product Vamp Zero display and control service
|
||||||
|
After=docker.service product-vamp-ap.service getty@tty1.service
|
||||||
|
Requires=docker.service product-vamp-ap.service
|
||||||
|
Conflicts=getty@tty1.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
TTYPath=/dev/tty1
|
||||||
|
TTYReset=yes
|
||||||
|
TTYVHangup=yes
|
||||||
|
TTYVTDisallocate=yes
|
||||||
|
StandardInput=tty-force
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
ExecStartPre=/usr/bin/install -d -m 0755 /run/product-vamp
|
||||||
|
ExecStart=/usr/local/bin/product-vamp-start-zero
|
||||||
|
ExecStop=/bin/sh -c '/usr/bin/docker stop product-vamp-zero >/dev/null 2>&1 || true'
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
config=${PRODUCT_VAMP_CONFIG:-/etc/product-vamp/vamp.json}
|
||||||
|
ssid=${PRODUCT_VAMP_AP_SSID:-Vamp Control}
|
||||||
|
passphrase=${PRODUCT_VAMP_AP_PASSPHRASE:-vamp-control}
|
||||||
|
interface=${PRODUCT_VAMP_AP_INTERFACE:-wlan0}
|
||||||
|
cidr=${PRODUCT_VAMP_ZERO_CIDR:-192.168.42.1/24}
|
||||||
|
connection=${PRODUCT_VAMP_AP_CONNECTION:-product-vamp-control}
|
||||||
|
|
||||||
|
configure_connection() {
|
||||||
|
nmcli connection modify "$connection" \
|
||||||
|
connection.interface-name "$interface" \
|
||||||
|
802-11-wireless.mode ap \
|
||||||
|
802-11-wireless.band bg \
|
||||||
|
802-11-wireless.channel 6 \
|
||||||
|
802-11-wireless.ssid "$ssid" \
|
||||||
|
802-11-wireless-security.key-mgmt wpa-psk \
|
||||||
|
802-11-wireless-security.proto rsn \
|
||||||
|
802-11-wireless-security.pairwise ccmp \
|
||||||
|
802-11-wireless-security.group ccmp \
|
||||||
|
802-11-wireless-security.pmf 1 \
|
||||||
|
802-11-wireless-security.psk "$passphrase" \
|
||||||
|
ipv4.method shared \
|
||||||
|
ipv4.addresses "$cidr" \
|
||||||
|
ipv6.method ignore \
|
||||||
|
connection.autoconnect yes
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -r "$config" ]; then
|
||||||
|
ssid=$(python3 -c 'import json,sys; data=json.load(open(sys.argv[1])); print(data.get("control",{}).get("ap_ssid","Vamp Control"))' "$config")
|
||||||
|
passphrase=$(python3 -c 'import json,sys; data=json.load(open(sys.argv[1])); print(data.get("control",{}).get("ap_passphrase","vamp-control"))' "$config")
|
||||||
|
interface=$(python3 -c 'import json,sys; data=json.load(open(sys.argv[1])); print(data.get("control",{}).get("ap_interface","wlan0"))' "$config")
|
||||||
|
cidr=$(python3 -c 'import json,sys; data=json.load(open(sys.argv[1])); print(data.get("control",{}).get("zero_cidr","192.168.42.1/24"))' "$config")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if nmcli -t -f NAME connection show | grep -Fxq "$connection"; then
|
||||||
|
configure_connection
|
||||||
|
else
|
||||||
|
nmcli connection add type wifi ifname "$interface" con-name "$connection" autoconnect yes ssid "$ssid"
|
||||||
|
configure_connection
|
||||||
|
fi
|
||||||
|
|
||||||
|
nmcli connection up "$connection"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
container_name=${PRODUCT_VAMP_ZERO_CONTAINER:-product-vamp-zero}
|
||||||
|
image_tag=${PRODUCT_VAMP_ZERO_IMAGE:-product-vamp-zero:dev}
|
||||||
|
config_dir=${PRODUCT_VAMP_CONFIG_DIR:-/etc/product-vamp}
|
||||||
|
state_dir=${PRODUCT_VAMP_STATE_DIR:-/var/lib/product-vamp}
|
||||||
|
run_dir=${PRODUCT_VAMP_RUN_DIR:-/run/product-vamp}
|
||||||
|
display_tty=${PRODUCT_VAMP_DISPLAY_TTY:-/dev/tty1}
|
||||||
|
|
||||||
|
install -d -m 0755 "$run_dir"
|
||||||
|
|
||||||
|
if command -v chvt >/dev/null 2>&1 && [ -c "$display_tty" ]; then
|
||||||
|
tty_number=${display_tty#/dev/tty}
|
||||||
|
case "$tty_number" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
chvt "$tty_number" || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -c "$display_tty" ]; then
|
||||||
|
if command -v setterm >/dev/null 2>&1; then
|
||||||
|
setterm --cursor off > "$display_tty" || printf '\033[?25l' > "$display_tty" || true
|
||||||
|
else
|
||||||
|
printf '\033[?25l' > "$display_tty" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "Starting Product Vamp Zero container '$container_name' from '$image_tag' on '$display_tty'"
|
||||||
|
|
||||||
|
exec docker run --rm --name "$container_name" --network host --privileged \
|
||||||
|
--env PRODUCT_VAMP_DISPLAY_TTY="$display_tty" \
|
||||||
|
-v "$config_dir:/etc/product-vamp:ro" \
|
||||||
|
-v "$state_dir:/var/lib/product-vamp" \
|
||||||
|
-v "$run_dir:/run/product-vamp" \
|
||||||
|
"$image_tag" \
|
||||||
|
sh -lc 'display_tty=${PRODUCT_VAMP_DISPLAY_TTY:-/dev/tty1}; exec setsid --wait --ctty /usr/bin/vamp-zero --config /etc/product-vamp/vamp.json < "$display_tty"'
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"modulus.py",
|
||||||
|
"package/conanfile.py",
|
||||||
|
"build/build_tools"
|
||||||
|
],
|
||||||
|
"extraPaths": [
|
||||||
|
"build/build_common/modulus",
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"typeCheckingMode": "off",
|
||||||
|
"reportMissingImports": false,
|
||||||
|
"reportMissingModuleSource": false
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import difflib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
FONT_PATH = REPO / "package/assets/fonts/petaluma/PetalumaScript.otf"
|
||||||
|
OUTPUT_PATH = REPO / "src/vamp_style/font.cpp"
|
||||||
|
FONT_SIZE = 32
|
||||||
|
FONT_HEIGHT = 36
|
||||||
|
FONT_MAX_WIDTH = 28
|
||||||
|
FIRST_CODEPOINT = 32
|
||||||
|
LAST_CODEPOINT = 126
|
||||||
|
|
||||||
|
|
||||||
|
def cpp_char_literal(value: str) -> str:
|
||||||
|
if value == "'":
|
||||||
|
return "'\\''"
|
||||||
|
if value == "\\":
|
||||||
|
return "'\\\\'"
|
||||||
|
return f"'{value}'"
|
||||||
|
|
||||||
|
|
||||||
|
def render_glyph(font: ImageFont.FreeTypeFont, value: str, y_offset: int) -> tuple[list[int], int, int]:
|
||||||
|
image = Image.new("L", (FONT_MAX_WIDTH, FONT_HEIGHT), 0)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
bbox = font.getbbox(value)
|
||||||
|
x = max(0, -bbox[0])
|
||||||
|
draw.text((x, y_offset), value, font=font, fill=255)
|
||||||
|
|
||||||
|
pixels = [min(15, max(0, round(pixel / 17))) for pixel in image.get_flattened_data()]
|
||||||
|
visible = image.getbbox()
|
||||||
|
width = 0 if visible is None else min(FONT_MAX_WIDTH, visible[2])
|
||||||
|
advance = min(255, max(0, round(font.getlength(value))))
|
||||||
|
return pixels, width, advance
|
||||||
|
|
||||||
|
|
||||||
|
def render_alpha_rows(values: list[int]) -> list[str]:
|
||||||
|
lines = []
|
||||||
|
for row in range(FONT_HEIGHT):
|
||||||
|
start = row * FONT_MAX_WIDTH
|
||||||
|
row_values = values[start : start + FONT_MAX_WIDTH]
|
||||||
|
lines.append(" " + ", ".join(f"0x{value:02x}" for value in row_values) + ",")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def pair_adjustments(font: ImageFont.FreeTypeFont) -> list[tuple[int, int]]:
|
||||||
|
chars = [chr(value) for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)]
|
||||||
|
adjustments = []
|
||||||
|
for left in chars:
|
||||||
|
left_width = font.getlength(left)
|
||||||
|
for right in chars:
|
||||||
|
adjustment = round(font.getlength(left + right) - left_width - font.getlength(right))
|
||||||
|
if adjustment == 0:
|
||||||
|
continue
|
||||||
|
key = (ord(left) << 8) | ord(right)
|
||||||
|
adjustments.append((key, int(adjustment)))
|
||||||
|
return sorted(adjustments)
|
||||||
|
|
||||||
|
|
||||||
|
def generate() -> str:
|
||||||
|
font = ImageFont.truetype(str(FONT_PATH), FONT_SIZE)
|
||||||
|
min_top = min(font.getbbox(chr(value))[1] for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1))
|
||||||
|
y_offset = max(0, -min_top)
|
||||||
|
adjustments = pair_adjustments(font)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"#include <vamp_style/font.hpp>",
|
||||||
|
"",
|
||||||
|
"#include <algorithm>",
|
||||||
|
"#include <array>",
|
||||||
|
"#include <cstdint>",
|
||||||
|
"#include <string_view>",
|
||||||
|
"",
|
||||||
|
"namespace {",
|
||||||
|
"",
|
||||||
|
"struct pair_adjustment {",
|
||||||
|
" std::uint16_t key{};",
|
||||||
|
" std::int8_t adjustment{};",
|
||||||
|
"};",
|
||||||
|
"",
|
||||||
|
f"constexpr auto pair_adjustments = std::array<pair_adjustment, {len(adjustments)}>{{{{",
|
||||||
|
]
|
||||||
|
for key, adjustment in adjustments:
|
||||||
|
lines.append(f" {{0x{key:04x}, {adjustment}}},")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"}};",
|
||||||
|
"",
|
||||||
|
"auto pair_key(std::char_t left, std::char_t right) -> std::uint16_t {",
|
||||||
|
" auto l = static_cast<std::uint16_t>(static_cast<std::uint8_t>(left));",
|
||||||
|
" auto r = static_cast<std::uint16_t>(static_cast<std::uint8_t>(right));",
|
||||||
|
" return static_cast<std::uint16_t>((l << 8U) | r);",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"auto empty_glyph() -> vamp_style::font_glyph {",
|
||||||
|
" return {",
|
||||||
|
" .alpha = {},",
|
||||||
|
" .width = 0,",
|
||||||
|
" .advance = 8,",
|
||||||
|
" };",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"auto vamp_style::ui_font_glyph(std::char_t value) -> vamp_style::font_glyph {",
|
||||||
|
" switch (value) {",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for codepoint in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1):
|
||||||
|
value = chr(codepoint)
|
||||||
|
alpha, width, advance = render_glyph(font, value, y_offset)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f" case {cpp_char_literal(value)}:",
|
||||||
|
" return {",
|
||||||
|
" .alpha = {",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(render_alpha_rows(alpha))
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
" },",
|
||||||
|
f" .width = {width},",
|
||||||
|
f" .advance = {advance},",
|
||||||
|
" };",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
" default:",
|
||||||
|
" return empty_glyph();",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"auto vamp_style::ui_font_pair_adjust(std::char_t left, std::char_t right) -> std::int32_t {",
|
||||||
|
" auto key = pair_key(left, right);",
|
||||||
|
" auto found = std::lower_bound(pair_adjustments.begin(), pair_adjustments.end(), key, [](pair_adjustment const& item, std::uint16_t value) {",
|
||||||
|
" return item.key < value;",
|
||||||
|
" });",
|
||||||
|
" if (found == pair_adjustments.end() || found->key != key) {",
|
||||||
|
" return 0;",
|
||||||
|
" }",
|
||||||
|
" return found->adjustment;",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"auto vamp_style::ui_text_width(std::string_view text, std::int32_t scale) -> std::int32_t {",
|
||||||
|
" if (text.empty() || scale <= 0) {",
|
||||||
|
" return 0;",
|
||||||
|
" }",
|
||||||
|
" auto width = std::int32_t{};",
|
||||||
|
" auto previous = std::char_t{};",
|
||||||
|
" for (auto ch : text) {",
|
||||||
|
" if (previous != std::char_t{}) {",
|
||||||
|
" width += vamp_style::ui_font_pair_adjust(previous, ch) * scale;",
|
||||||
|
" }",
|
||||||
|
" width += static_cast<std::int32_t>(vamp_style::ui_font_glyph(ch).advance) * scale;",
|
||||||
|
" previous = ch;",
|
||||||
|
" }",
|
||||||
|
" return std::max(std::int32_t{}, width);",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
generated = generate()
|
||||||
|
if args.check:
|
||||||
|
current = OUTPUT_PATH.read_text()
|
||||||
|
if current == generated:
|
||||||
|
print("pico font is up to date")
|
||||||
|
return 0
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
current.splitlines(),
|
||||||
|
generated.splitlines(),
|
||||||
|
fromfile=str(OUTPUT_PATH),
|
||||||
|
tofile=f"{OUTPUT_PATH} (generated)",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
print("\n".join(diff))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
OUTPUT_PATH.write_text(generated)
|
||||||
|
print(f"generated {OUTPUT_PATH}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user