Update
ci / checks (push) Successful in 1h15m13s

This commit is contained in:
khodowany
2026-09-24 06:20:37 -05:00
parent da5640291a
commit 1be98e14aa
35 changed files with 191 additions and 184 deletions
+8 -6
View File
@@ -5,9 +5,9 @@ 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 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. - 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.
Python builds the complete SQLite catalogue with the score assets. The Zero opens that packaged catalogue read-only through Noto, without creating a database, migrating schemas, or importing JSON at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities. Python builds the complete SQLite catalog with the score assets. The Zero opens that packaged catalog read-only through Noto, without creating a database, migrating schemas, or importing JSON at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities.
The catalogue stores shared, immutable menu metadata. Complete score bundles stay in SQLite and are loaded individually when selected; snapshots and Pico heartbeats do not copy the catalogue's musical JSON. The catalog stores shared, immutable menu metadata. Complete score bundles stay in SQLite and are loaded individually when selected; snapshots and Pico heartbeats do not copy the catalog's musical JSON.
Pico controls: Pico controls:
@@ -31,7 +31,7 @@ python3 modulus.py configure --target x86_64_gcc12_release
python3 modulus.py build --target x86_64_gcc12_release python3 modulus.py build --target x86_64_gcc12_release
``` ```
`python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp-zero --once` checks the packaged database and referenced page/audio assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. Missing or incompatible catalogues require rebuilding or reinstalling; they are never repaired at runtime. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files. `python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp-zero --once` checks the packaged database and referenced page/audio assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. Missing or incompatible catalogs require rebuilding or reinstalling; they are never repaired at runtime. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files.
Configure/build can read public packages anonymously from the khodo Conan registry. Configure/build can read public packages anonymously from the khodo Conan registry.
@@ -64,7 +64,7 @@ build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256 build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256
``` ```
The tarball contains the Zero Docker archive, Pi install scripts, generated catalogue database, pages, audio, fonts, and Pico UF2 firmware. Complete score JSON is stored inside `catalogue.db`; individual JSON files remain build intermediates and are not deployed. The tarball contains the Zero Docker archive, Pi install scripts, generated catalog database, pages, audio, fonts, and Pico UF2 firmware. Complete score JSON is stored inside `catalog.db`; individual JSON files remain build intermediates and are not deployed.
Run `sudo zero/install-zero.sh` from an extracted bundle on the Zero. Every installation resets all Product Vamp data and settings, including the control Wi-Fi profile, to the new bundle defaults. No previous database rows or configuration are migrated. Unrelated OS data and networks are untouched. Installing through the control Wi-Fi can disconnect your session; use a local console or an independent connection. Run `sudo zero/install-zero.sh` from an extracted bundle on the Zero. Every installation resets all Product Vamp data and settings, including the control Wi-Fi profile, to the new bundle defaults. No previous database rows or configuration are migrated. Unrelated OS data and networks are untouched. Installing through the control Wi-Fi can disconnect your session; use a local console or an independent connection.
@@ -91,9 +91,9 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --
The `--check` path compares generation invariants against `data/expected/performance_summary.json`. The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
The pinned build-only dependencies are in `generator/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment. The pinned build-only dependencies are in `generator/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment.
CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving-cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Music, playback, and catalogue validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving-cache --progress`. CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving-cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Music, playback, and catalog validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving-cache --progress`.
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> part generation -> schema-v3 JSON, engraved PNG pages, generated-tone audio, and a read-only catalogue database. Sources in `data/songs/` retain the seven-section performance form. Numbered sources in `data/practice/` (`000.xml` through `366.xml`, as authored) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises. The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> part generation -> schema-v3 JSON, engraved PNG pages, generated-tone audio, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Numbered sources in `data/practice/` (`000.xml` through `366.xml`, as authored) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises.
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. They render standard notation plus generated TAB. Practice sources provide a monophonic melody and optional chord symbols; the vocal part adds deterministic scat syllables without changing rhythm or written register. Acoustic, electric, and vocal parts all sound one octave below their notation, for both songs and practice. Explicit MusicXML instrument transposition is honored once; an unplayable passage fails rather than being shifted to fit. Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. They render standard notation plus generated TAB. Practice sources provide a monophonic melody and optional chord symbols; the vocal part adds deterministic scat syllables without changing rhythm or written register. Acoustic, electric, and vocal parts all sound one octave below their notation, for both songs and practice. Explicit MusicXML instrument transposition is honored once; an unplayable passage fails rather than being shifted to fit.
@@ -103,6 +103,8 @@ The main menu's **Playback** toggle plays the selected part once at the menu BPM
Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
Use American English in project-owned identifiers, filenames, messages, and documentation, such as `catalog`, `color`, and `canceled`. Preserve authored music and external API names.
See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced. See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced.
Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r generator/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets. Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r generator/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
+1 -1
View File
@@ -31,7 +31,7 @@ Defaults:
For API-only quick checks, set `runtime.headless` to `true` or run `vamp-zero --once` from a development build. 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/songs/` and `data/practice/`. The build/package step generates acoustic, electric, and vocal parts, engraved pages, audio, and the read-only catalogue database. Installation replaces these assets together; no database migration or runtime score import is needed. Score inputs are tracked under `data/songs/` and `data/practice/`. The build/package step generates acoustic, electric, and vocal parts, engraved pages, audio, and the read-only catalog database. Installation replaces these assets together; no database migration or runtime score import is needed.
## Raspberry Pi Pico 2 W ## Raspberry Pi Pico 2 W
+15 -15
View File
@@ -10,7 +10,7 @@ Product Vamp follows the Modulus product layout used by the khodo repos:
- `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`. - `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.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory. - `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory.
- `data`: authored MusicXML for performance songs and practice exercises, plus the generator regression summary. - `data`: authored MusicXML for performance songs and practice exercises, plus the generator regression summary.
- `generator`: build-time Python part generation, guitar placement, scat lyrics, engraving, audio compilation, and catalogue construction. - `generator`: build-time Python part generation, guitar placement, scat lyrics, engraving, audio compilation, and catalog construction.
- `package`: generic bundle inputs, scripts, config, static font assets, and device install files. - `package`: generic bundle inputs, scripts, config, static font assets, and device install files.
## Zero Runtime ## Zero Runtime
@@ -35,17 +35,17 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip
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. 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 validates the packaged `catalogue.db` application identifier and schema version, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. Performance navigation is Song -> Part -> Score; practice is Exercise -> Part -> Score. Both offer Acoustic, Electric, and Vocal, without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures. At startup the Zero validates the packaged `catalog.db` application identifier and schema version, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. Performance navigation is Song -> Part -> Score; practice is Exercise -> Part -> Score. Both offer Acoustic, Electric, and Vocal, without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index; practice is a flat, numerically ordered exercise list. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database before deployment; metadata and embedded JSON are published together. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures. Runtime catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index; practice is a flat, numerically ordered exercise list. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database before deployment; metadata and embedded JSON are published together. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
Practice exercise IDs are the three-digit source stems, including `000` when present. Part IDs append the part name, such as `001-electric`. Duplicate titles and identical placeholder music remain independent exercises. Catalogue schema 3 stores exercise identity and part metadata without practice categories, lesson groups, or key variants. Every exercise must have all three parts. Older catalogues must be rebuilt or reinstalled, not migrated. Practice exercise IDs are the three-digit source stems, including `000` when present. Part IDs append the part name, such as `001-electric`. Duplicate titles and identical placeholder music remain independent exercises. Catalog schema 3 stores exercise identity and part metadata without practice categories, lesson groups, or key variants. Every exercise must have all three parts. Older catalogs must be rebuilt or reinstalled, not migrated.
`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it checks database integrity and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only. `--once` (also `runtime.exit_after_startup`) is a headless readiness check: it checks database integrity and validates the catalog and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only.
Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again. Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again.
Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards cancelled or obsolete timer callbacks. Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
## Score Generation ## Score Generation
@@ -53,7 +53,7 @@ The build runs `python3 -m generator` against `data/` and writes build-only asse
Practice sources live directly in `data/practice/` with three-digit filenames, currently `001.xml` through `366.xml`; `000.xml` is also valid. For example, `data/practice/001.xml` generates `practice/001/{acoustic,electric,vocal}.json` and corresponding page directories. Songs stay in `data/songs/`. The filename number determines exercise identity and ordering; MusicXML titles determine labels, falling back to the number. Root-level XML, other practice filenames, and nested practice directories are rejected. Adding, renaming, or removing a source requires no registry edit. The current 366 exercises produce 1,098 practice parts, plus six performance parts. Practice sources live directly in `data/practice/` with three-digit filenames, currently `001.xml` through `366.xml`; `000.xml` is also valid. For example, `data/practice/001.xml` generates `practice/001/{acoustic,electric,vocal}.json` and corresponding page directories. Songs stay in `data/songs/`. The filename number determines exercise identity and ordering; MusicXML titles determine labels, falling back to the number. Root-level XML, other practice filenames, and nested practice directories are rejected. Adding, renaming, or removing a source requires no registry edit. The current 366 exercises produce 1,098 practice parts, plus six performance parts.
The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> part generation -> semantic JSON, audio, and engraved pages -> packaged SQLite catalogue. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. The musical model retains independent voices and per-tone durations/ties; automatic practice generation additionally requires a single monophonic melody and optional structured harmonies. Packaged music describes each generated part, not an archival copy of the XML. The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> part generation -> semantic JSON, audio, and engraved pages -> packaged SQLite catalog. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. The musical model retains independent voices and per-tone durations/ties; automatic practice generation additionally requires a single monophonic melody and optional structured harmonies. Packaged music describes each generated part, not an archival copy of the XML.
### MusicXML Contract ### MusicXML Contract
@@ -120,7 +120,7 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the shared accompaniment rhythm. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the exact melody, spelling, rhythm, ties, and tuplets; vocal retains those same authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the shared accompaniment rhythm. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the exact melody, spelling, rhythm, ties, and tuplets; vocal retains those same authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections.
Accompaniment attacks at each bar start and authored harmony onset, sustaining until the next harmony onset or bar end. Harmony carries into subsequent bars until changed or explicitly cancelled by N.C.; melody attacks never suppress accompaniment. This same rule applies to electric accompaniment sections in songs. Song acoustic rhythm and solo-slash patterns are unchanged. Accompaniment attacks at each bar start and authored harmony onset, sustaining until the next harmony onset or bar end. Harmony carries into subsequent bars until changed or explicitly canceled by N.C.; melody attacks never suppress accompaniment. This same rule applies to electric accompaniment sections in songs. Song acoustic rhythm and solo-slash patterns are unchanged.
Each source is parsed, validated, and normalized once for all three parts. Part builders treat the prepared source as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist. Each source is parsed, validated, and normalized once for all three parts. Part builders treat the prepared source as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist.
@@ -150,7 +150,7 @@ At runtime the shared on-demand score loader serves both display and audio. A ba
`audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; menu BPM is constant throughout a session. Selecting a practice or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding part menu with its selection preserved. Leaving the score cancels playback; stale results cannot close a newer score. Errors remain visible rather than being mistaken for completion. `audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; menu BPM is constant throughout a session. Selecting a practice or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding part menu with its selection preserved. Leaving the score cancels playback; stale results cannot close a newer score. Errors remain visible rather than being mistaken for completion.
`--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalogue; the source XML and Python dependencies remain build-only. `--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalog; the source XML and Python dependencies remain build-only.
### Engraving Boundary ### Engraving Boundary
@@ -166,19 +166,19 @@ Generated guitar tones carry `tablature: {string, fret}`; their staff is marked
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite. `audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. The musical bundle version is separate from the catalogue database version; runtime accepts only the supported packaged catalogue schema. Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. The musical bundle version is separate from the catalog database version; runtime accepts only the supported packaged catalog schema.
Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics. Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
### Validation And Migration ### Validation And Migration
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, or audio. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain `catalogue.db` with embedded semantic JSON, PNG pages, generated PCM WAVs, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score. Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, or audio. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalog. Runtime packages contain `catalog.db` with embedded semantic JSON, PNG pages, generated PCM WAVs, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalog score.
CI page-cache snapshot keys include every generator Python module and XML source, while retaining a renderer/font-compatible restore prefix. Individual cached pages remain validated by their semantic-content and renderer fingerprints. CI page-cache snapshot keys include every generator Python module and XML source, while retaining a renderer/font-compatible restore prefix. Individual cached pages remain validated by their semantic-content and renderer fingerprints.
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`. Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
The six compact source files were replaced, not retained as a second authoring format. Installation replaces the entire catalogue, rather than retaining or migrating rows from older releases. Legacy data must be authored as supported MusicXML and regenerated; the runtime never upgrades old databases. The six compact source files were replaced, not retained as a second authoring format. Installation replaces the entire catalog, rather than retaining or migrating rows from older releases. Legacy data must be authored as supported MusicXML and regenerated; the runtime never upgrades old databases.
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html). References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
@@ -220,13 +220,13 @@ Building the full package requires Docker/buildx, the ARM embedded GCC toolchain
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`. 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`.
`generator/catalogue.py` uses Python's standard-library SQLite module to construct `catalogue.db` after musical, playback, and engraving validation. The database carries application ID `0x56414D50` and catalogue schema version 2, with complete score bundles and menu metadata. Sorted insertion and the absence of wall-clock timestamps make repeated builds reproducible with the same toolchain. It is closed and integrity-checked before publication, without journal/WAL sidecars. Individual JSON files remain available in the build tree for inspection, but both CMake installation and Docker image assembly exclude them. `generator/catalog.py` uses Python's standard-library SQLite module to construct `catalog.db` after musical, playback, and engraving validation. The database carries application ID `0x56414D50` and catalog schema version 3, with complete score bundles and menu metadata. Sorted insertion and the absence of wall-clock timestamps make repeated builds reproducible with the same toolchain. It is closed and integrity-checked before publication, without journal/WAL sidecars. Individual JSON files remain available in the build tree for inspection, but both CMake installation and Docker image assembly exclude them.
The default `database.path` is `/usr/share/product-vamp/catalogue.db`; development configuration points at the native generated-assets database. Stored source paths are logical, asset-relative locations, not build-host filenames. Runtime resolves them against `runtime.assets_path` and rejects traversal and escaping symlinks. The JSON file itself need not exist: its parent identifies the score's page directory. Normal startup checks catalogue compatibility; `--once` additionally runs SQLite's integrity probe and checks all page/audio files. Missing, corrupt, or incompatible catalogues fail rather than creating an empty library. `--ensure-database` is no longer a supported command. The default `database.path` is `/usr/share/product-vamp/catalog.db`; development configuration points at the native generated-assets database. Stored source paths are logical, asset-relative locations, not build-host filenames. Runtime resolves them against `runtime.assets_path` and rejects traversal and escaping symlinks. The JSON file itself need not exist: its parent identifies the score's page directory. Normal startup checks catalog compatibility; `--once` additionally runs SQLite's integrity probe and checks all page/audio files. Missing, corrupt, or incompatible catalogs fail rather than creating an empty library. `--ensure-database` is no longer a supported command.
Every run of `zero/install-zero.sh` is a clean installation. It stages configuration/scripts, verifies the manifest's Docker tag against the incoming archive's image digest, and probes that image before stopping Product Vamp. It then clears the owned `/etc/product-vamp`, `/var/lib/product-vamp`, `/run/product-vamp`, and obsolete host `/usr/share/product-vamp` trees, including old SQLite sidecars. The installed container uses assets from its image and no longer mounts a writable state database. Startup reads the verified image ID from `/etc/product-vamp/image-id`, rather than depending on a mutable Docker tag. The installer replaces its scripts and systemd units, removes their old overrides, recreates only the `product-vamp-control` NetworkManager profile, and requires a successful service health check before announcing success. Every run of `zero/install-zero.sh` is a clean installation. It stages configuration/scripts, verifies the manifest's Docker tag against the incoming archive's image digest, and probes that image before stopping Product Vamp. It then clears the owned `/etc/product-vamp`, `/var/lib/product-vamp`, `/run/product-vamp`, and obsolete host `/usr/share/product-vamp` trees, including old SQLite sidecars. The installed container uses assets from its image and no longer mounts a writable state database. Startup reads the verified image ID from `/etc/product-vamp/image-id`, rather than depending on a mutable Docker tag. The installer replaces its scripts and systemd units, removes their old overrides, recreates only the `product-vamp-control` NetworkManager profile, and requires a successful service health check before announcing success.
Configuration, AP credentials, and all prior catalogue data are intentionally reset to bundle defaults. There is no automatic backup or migration. Installation through the control AP can disconnect the session; use the local console or independent networking. Preflight failure leaves existing application files untouched and restores the previous image tag; replacement failure stops Product Vamp and reports the error. Rerunning the installer is the recovery path. Ordinary boots/restarts do not run cleanup. Unrelated networks, OS data, containers, and system logs remain untouched. Cleanup rejects symlink/mount targets, never follows paths from old configuration, and removes only identified obsolete Product Vamp images not used by another container. Configuration, AP credentials, and all prior catalog data are intentionally reset to bundle defaults. There is no automatic backup or migration. Installation through the control AP can disconnect the session; use the local console or independent networking. Preflight failure leaves existing application files untouched and restores the previous image tag; replacement failure stops Product Vamp and reports the error. Rerunning the installer is the recovery path. Ordinary boots/restarts do not run cleanup. Unrelated networks, OS data, containers, and system logs remain untouched. Cleanup rejects symlink/mount targets, never follows paths from old configuration, and removes only identified obsolete Product Vamp images not used by another container.
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. 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.
+2 -2
View File
@@ -1,4 +1,4 @@
if(NOT EXISTS "${SOURCE}/catalogue.db") if(NOT EXISTS "${SOURCE}/catalog.db")
message(FATAL_ERROR "Missing generated catalogue: ${SOURCE}/catalogue.db") message(FATAL_ERROR "Missing generated catalog: ${SOURCE}/catalog.db")
endif() endif()
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE) file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE)
+1 -1
View File
@@ -132,7 +132,7 @@ file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.inputs
CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY) CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY)
add_custom_command( add_custom_command(
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP} OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalogue.db BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalog.db
COMMAND ${Python3_EXECUTABLE} COMMAND ${Python3_EXECUTABLE}
-u -u
-m generator -m generator
+3 -3
View File
@@ -13,7 +13,7 @@ from generator.summary import validate_expected_performance_summary, validate_pr
from generator.parts import source_paths from generator.parts import source_paths
from generator.engraving import engrave_tree from generator.engraving import engrave_tree
from generator.playback import compile_tree from generator.playback import compile_tree
from generator.catalogue import build_catalogue from generator.catalog import build_catalog
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -74,8 +74,8 @@ def generate_assets(args: argparse.Namespace) -> None:
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress) engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
report('validating score, page, and audio assets') report('validating score, page, and audio assets')
validate_generated_tree(staged) validate_generated_tree(staged)
report('building catalogue database') report('building catalog database')
build_catalogue(staged) build_catalog(staged)
backup = Path(temporary) / 'previous' backup = Path(temporary) / 'previous'
if output_dir.exists(): if output_dir.exists():
output_dir.rename(backup) output_dir.rename(backup)
@@ -81,15 +81,15 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
raise ValueError(f'practice exercise requires all three parts: {identity}') raise ValueError(f'practice exercise requires all three parts: {identity}')
connection.commit() connection.commit()
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalogue integrity check failed') raise ValueError('catalog integrity check failed')
def build_catalogue(root: Path, destination: Path | None = None) -> Path: def build_catalog(root: Path, destination: Path | None = None) -> Path:
if not root.is_dir(): if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found') raise ValueError(f'{root}: score asset directory not found')
destination = destination or root / 'catalogue.db' destination = destination or root / 'catalog.db'
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-catalogue-', dir=destination.parent) as temporary: with tempfile.TemporaryDirectory(prefix='.vamp-catalog-', dir=destination.parent) as temporary:
staged = Path(temporary) / 'catalogue.db' staged = Path(temporary) / 'catalog.db'
connection = sqlite3.connect(staged) connection = sqlite3.connect(staged)
try: try:
populate(connection, root) populate(connection, root)
@@ -99,8 +99,8 @@ def build_catalogue(root: Path, destination: Path | None = None) -> Path:
return destination return destination
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalogue.') parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalog.')
parser.add_argument('assets', type=Path) parser.add_argument('assets', type=Path)
parser.add_argument('destination', type=Path, nargs='?') parser.add_argument('destination', type=Path, nargs='?')
arguments = parser.parse_args() arguments = parser.parse_args()
build_catalogue(arguments.assets, arguments.destination) build_catalog(arguments.assets, arguments.destination)
+3 -3
View File
@@ -41,10 +41,10 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
result['custom'] = True result['custom'] = True
if 'cancel' in key: if 'cancel' in key:
# A cancellation references the old signature, not a count of naturals. # A cancellation references the old signature, not a count of naturals.
cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones) canceled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
if cancelled.get('custom'): if canceled.get('custom'):
raise ValueError('transposed key cancellation cannot be represented by fifths') raise ValueError('transposed key cancellation cannot be represented by fifths')
result['cancel'] = cancelled['fifths'] result['cancel'] = canceled['fifths']
return result return result
def concert_document(document: dict, *, octave_transposing: bool = False) -> dict: def concert_document(document: dict, *, octave_transposing: bool = False) -> dict:
+1 -1
View File
@@ -39,7 +39,7 @@ private:
std::future<std::expected<sonus::prepared_sequence, modulus::error>> preparation_{}; std::future<std::expected<sonus::prepared_sequence, modulus::error>> preparation_{};
std::uint64_t preparing_session_{}; std::uint64_t preparing_session_{};
std::optional<std::uint64_t> attempted_session_{}; std::optional<std::uint64_t> attempted_session_{};
std::shared_ptr<vamp_zero::catalogue const> catalog_{}; std::shared_ptr<vamp_zero::catalog const> catalog_{};
sonus::playback_session playback_{}; sonus::playback_session playback_{};
std::bool_t reported_{true}; std::bool_t reported_{true};
}; };
+1 -1
View File
@@ -24,7 +24,7 @@ struct runtime_config {
}; };
struct database_config { struct database_config {
std::string path{"/usr/share/product-vamp/catalogue.db"}; std::string path{"/usr/share/product-vamp/catalog.db"};
}; };
struct control_config { struct control_config {
+1 -1
View File
@@ -24,7 +24,7 @@ public:
auto heartbeat() -> vamp_zero::app_state; auto heartbeat() -> vamp_zero::app_state;
auto refresh_connection() -> std::optional<vamp_zero::app_state>; auto refresh_connection() -> std::optional<vamp_zero::app_state>;
auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void; auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void;
auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalogue const> catalog = {}) -> void; auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void;
private: private:
auto pico_connected_locked() const -> std::bool_t; auto pico_connected_locked() const -> std::bool_t;
auto snapshot_locked() const -> vamp_zero::app_state; auto snapshot_locked() const -> vamp_zero::app_state;
+1 -1
View File
@@ -28,7 +28,7 @@ public:
~score_loader(); ~score_loader();
score_loader(score_loader const &) = delete; score_loader(score_loader const &) = delete;
auto operator=(score_loader const &) -> score_loader & = delete; auto operator=(score_loader const &) -> score_loader & = delete;
auto select(std::shared_ptr<vamp_zero::catalogue const> catalog, std::optional<vamp_zero::score_reference> reference) -> void; auto select(std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<vamp_zero::score_reference> reference) -> void;
auto snapshot() const -> vamp_zero::score_load_state; auto snapshot() const -> vamp_zero::score_load_state;
auto stop() -> void; auto stop() -> void;
private: private:
+3 -3
View File
@@ -64,12 +64,12 @@ struct practice_library {
std::vector<vamp_zero::practice_exercise> exercises{}; std::vector<vamp_zero::practice_exercise> exercises{};
}; };
struct catalogue { struct catalog {
vamp_zero::library_state performance{}; vamp_zero::library_state performance{};
vamp_zero::practice_library practice{}; vamp_zero::practice_library practice{};
}; };
auto empty_catalogue() -> std::shared_ptr<vamp_zero::catalogue const> const &; auto empty_catalog() -> std::shared_ptr<vamp_zero::catalog const> const &;
enum class score_category { enum class score_category {
PERFORMANCE, PERFORMANCE,
@@ -104,7 +104,7 @@ struct audio_state {
}; };
struct app_state { struct app_state {
std::shared_ptr<vamp_zero::catalogue const> catalog{vamp_zero::empty_catalogue()}; std::shared_ptr<vamp_zero::catalog const> catalog{vamp_zero::empty_catalog()};
vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU}; vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU};
vamp_zero::audio_state audio{}; vamp_zero::audio_state audio{};
std::size_t selected_main_menu_index{}; std::size_t selected_main_menu_index{};
+1 -1
View File
@@ -5,7 +5,7 @@
"heartbeat_path": "build/build_x86_debug/runtime/heartbeat.json" "heartbeat_path": "build/build_x86_debug/runtime/heartbeat.json"
}, },
"database": { "database": {
"path": "build/build_x86_debug/generated-assets/catalogue.db" "path": "build/build_x86_debug/generated-assets/catalog.db"
}, },
"control": { "control": {
"endpoint": { "endpoint": {
+1 -1
View File
@@ -17,7 +17,7 @@
"heartbeat_path": "/run/product-vamp/heartbeat.json" "heartbeat_path": "/run/product-vamp/heartbeat.json"
}, },
"database": { "database": {
"path": "/usr/share/product-vamp/catalogue.db" "path": "/usr/share/product-vamp/catalog.db"
}, },
"control": { "control": {
"endpoint": { "endpoint": {
+4 -4
View File
@@ -166,7 +166,7 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
return std::unexpected(practice.error()); return std::unexpected(practice.error());
} }
auto state = vamp_zero::app_state{ auto state = vamp_zero::app_state{
.catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{ .catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::catalog{
.performance = std::move(*performance), .performance = std::move(*performance),
.practice = std::move(*practice), .practice = std::move(*practice),
}), }),
@@ -204,7 +204,7 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
co_return std::expected<void, modulus::error>{}; co_return std::expected<void, modulus::error>{};
} }
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> { auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (auto const &part : catalog.performance.parts) { for (auto const &part : catalog.performance.parts) {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id}; auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference)); auto valid = co_await validate_score_pages(executor, config, std::move(reference));
@@ -261,9 +261,9 @@ auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<v
return {}; return {};
} }
auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> std::expected<void, modulus::error> { auto check_readiness(vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config, catalog), boost::asio::use_future); auto future = boost::asio::co_spawn(io, validate_catalog(io.get_executor(), config, catalog), boost::asio::use_future);
io.run(); io.run();
auto valid = future.get(); auto valid = future.get();
if (!valid) { if (!valid) {
+13 -13
View File
@@ -25,7 +25,7 @@
namespace { namespace {
constexpr auto application_id = std::int64_t{0x56414D50}; constexpr auto application_id = std::int64_t{0x56414D50};
constexpr auto catalogue_schema_version = std::int64_t{3}; constexpr auto catalog_schema_version = std::int64_t{3};
auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config { auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config {
auto result = noto::file_connection_config(config.path, "product-vamp"); auto result = noto::file_connection_config(config.path, "product-vamp");
@@ -40,17 +40,17 @@ auto text_value(noto::sqlite_value const &value) -> std::string {
return text == nullptr ? std::string{} : *text; return text == nullptr ? std::string{} : *text;
} }
auto close_catalogue_query(noto::connection &connection, noto::statement &statement, std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> { auto close_catalog_query(noto::connection &connection, noto::statement &statement, std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto finalized = co_await statement.async_finalize(); auto finalized = co_await statement.async_finalize();
if (!finalized) { if (!finalized) {
modulus::log_error("catalogue query finalization failed: " + finalized.error().describe()); modulus::log_error("catalog query finalization failed: " + finalized.error().describe());
if (status) { if (status) {
status = std::unexpected(finalized.error()); status = std::unexpected(finalized.error());
} }
} }
auto closed = co_await connection.async_close(); auto closed = co_await connection.async_close();
if (!closed) { if (!closed) {
modulus::log_error("catalogue connection close failed: " + closed.error().describe()); modulus::log_error("catalog connection close failed: " + closed.error().describe());
if (status) { if (status) {
status = std::unexpected(closed.error()); status = std::unexpected(closed.error());
} }
@@ -60,7 +60,7 @@ auto close_catalogue_query(noto::connection &connection, noto::statement &statem
auto resolve_source_path(std::string const &assets_path, std::string const &source) -> std::expected<std::string, modulus::error> { auto resolve_source_path(std::string const &assets_path, std::string const &source) -> std::expected<std::string, modulus::error> {
auto fail = [] { auto fail = [] {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "unsafe catalogue asset path; rebuild or reinstall Product Vamp")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "unsafe catalog asset path; rebuild or reinstall Product Vamp"));
}; };
auto relative = std::filesystem::path{source}; auto relative = std::filesystem::path{source};
if (source.empty() || source.find('\0') != std::string::npos || source.find('\\') != std::string::npos || if (source.empty() || source.find('\0') != std::string::npos || source.find('\\') != std::string::npos ||
@@ -94,23 +94,23 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::bool_t integrity) -> boost::asio::awaitable<std::expected<void, modulus::error>> { auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::bool_t integrity) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto error = std::error_code{}; auto error = std::error_code{};
if (!std::filesystem::is_regular_file(config.path, error) || error) { if (!std::filesystem::is_regular_file(config.path, error) || error) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "catalogue is missing or unreadable: " + config.path + "; rebuild or reinstall Product Vamp")); co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "catalog is missing or unreadable: " + config.path + "; rebuild or reinstall Product Vamp"));
} }
auto connection = noto::connection{executor, connection_config(config)}; auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect(); auto connected = co_await connection.async_connect();
if (!connected) { if (!connected) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalogue: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe())); co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalog: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe()));
} }
auto application = co_await connection.async_application_id(); auto application = co_await connection.async_application_id();
auto version = co_await connection.async_user_version(); auto version = co_await connection.async_user_version();
auto status = std::expected<void, modulus::error>{}; auto status = std::expected<void, modulus::error>{};
if (!application || !version || *application != application_id || *version != catalogue_schema_version) { if (!application || !version || *application != application_id || *version != catalog_schema_version) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid or incompatible catalogue: " + config.path + "; rebuild or reinstall Product Vamp")); status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid or incompatible catalog: " + config.path + "; rebuild or reinstall Product Vamp"));
} }
if (status && integrity) { if (status && integrity) {
auto checked = co_await connection.async_execute("PRAGMA quick_check(1)"); auto checked = co_await connection.async_execute("PRAGMA quick_check(1)");
if (!checked || checked->rows.size() != 1U || checked->rows[0].size() != 1U || text_value(checked->rows[0][0]) != "ok") { if (!checked || checked->rows.size() != 1U || checked->rows[0].size() != 1U || text_value(checked->rows[0][0]) != "ok") {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "corrupt catalogue: " + config.path + "; rebuild or reinstall Product Vamp")); status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "corrupt catalog: " + config.path + "; rebuild or reinstall Product Vamp"));
} }
} }
auto closed = co_await connection.async_close(); auto closed = co_await connection.async_close();
@@ -169,7 +169,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
.total_pages = count_value(row[7]), .total_pages = count_value(row[7]),
}); });
} }
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
if (!closed) { if (!closed) {
co_return std::unexpected(closed.error()); co_return std::unexpected(closed.error());
} }
@@ -230,7 +230,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
.parts = {std::move(practice_part)}, .parts = {std::move(practice_part)},
}); });
} }
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
if (!closed) { if (!closed) {
co_return std::unexpected(closed.error()); co_return std::unexpected(closed.error());
} }
@@ -272,7 +272,7 @@ auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zer
if (!result) { if (!result) {
status = std::unexpected(result.error()); status = std::unexpected(result.error());
} }
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status)); auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
if (!closed) { if (!closed) {
co_return std::unexpected(closed.error()); co_return std::unexpected(closed.error());
} }
+2 -2
View File
@@ -67,7 +67,7 @@ auto decrease_bpm(vamp_zero::audio_state &audio) -> void {
auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
if (!state.catalog) { if (!state.catalog) {
state.catalog = vamp_zero::empty_catalogue(); state.catalog = vamp_zero::empty_catalog();
} }
state.audio = clamp_audio_state(state.audio); state.audio = clamp_audio_state(state.audio);
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U); state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
@@ -469,7 +469,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::expected<void, modulus::error> { auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::expected<void, modulus::error> {
if (!state.catalog) { if (!state.catalog) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "catalogue is missing")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "catalog is missing"));
} }
if (state.selected_main_menu_index >= main_menu_count) { if (state.selected_main_menu_index >= main_menu_count) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range"));
+1 -1
View File
@@ -124,7 +124,7 @@ auto vamp_zero::control_state::set_observer(std::function<void(vamp_zero::app_st
observer_ = std::move(observer); observer_ = std::move(observer);
} }
auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr<vamp_zero::catalogue const> catalog) -> void { auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr<vamp_zero::catalog const> catalog) -> void {
auto observer = std::function<void(vamp_zero::app_state)>{}; auto observer = std::function<void(vamp_zero::app_state)>{};
auto updated = vamp_zero::app_state{}; auto updated = vamp_zero::app_state{};
{ {
+2 -2
View File
@@ -75,7 +75,7 @@ struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load; std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load;
std::function<void()> completed; std::function<void()> completed;
std::mutex mutex{}; std::mutex mutex{};
std::shared_ptr<vamp_zero::catalogue const> catalog{}; std::shared_ptr<vamp_zero::catalog const> catalog{};
std::optional<vamp_zero::score_reference> reference{}; std::optional<vamp_zero::score_reference> reference{};
vamp_zero::score_load_state value{}; vamp_zero::score_load_state value{};
std::uint64_t generation{}; std::uint64_t generation{};
@@ -90,7 +90,7 @@ vamp_zero::score_loader::~score_loader() {
stop(); stop();
} }
auto vamp_zero::score_loader::select(std::shared_ptr<vamp_zero::catalogue const> catalog, std::optional<vamp_zero::score_reference> reference) -> void { auto vamp_zero::score_loader::select(std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<vamp_zero::score_reference> reference) -> void {
auto lock = std::scoped_lock{state_->mutex}; auto lock = std::scoped_lock{state_->mutex};
if (!catalog) { if (!catalog) {
reference.reset(); reference.reset();
+2 -2
View File
@@ -4,8 +4,8 @@
#include <vector> #include <vector>
auto vamp_zero::empty_catalogue() -> std::shared_ptr<vamp_zero::catalogue const> const & { auto vamp_zero::empty_catalog() -> std::shared_ptr<vamp_zero::catalog const> const & {
static auto const empty = std::make_shared<vamp_zero::catalogue const>(); static auto const empty = std::make_shared<vamp_zero::catalog const>();
return empty; return empty;
} }
+1 -1
View File
@@ -402,7 +402,7 @@ auto vamp_zero::zero_ui::publish(vamp_zero::ui_state state) -> std::expected<voi
{ {
auto lock = std::scoped_lock{runtime_->mutex_}; auto lock = std::scoped_lock{runtime_->mutex_};
if (!state.app.catalog) { if (!state.app.catalog) {
state.app.catalog = vamp_zero::empty_catalogue(); state.app.catalog = vamp_zero::empty_catalog();
} }
score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app)); score_loader_->select(state.app.catalog, vamp_zero::selected_score_reference(state.app));
runtime_->state_ = std::move(state); runtime_->state_ = std::move(state);
+1 -1
View File
@@ -3,7 +3,7 @@ add_executable(product-vamp-test
../src/vamp_pico/input.cpp ../src/vamp_pico/input.cpp
../src/vamp_pico/remote_display.cpp ../src/vamp_pico/remote_display.cpp
test.cpp test.cpp
test_catalogue_runtime.cpp test_catalog_runtime.cpp
test_health.cpp test_health.cpp
test_pico.cpp test_pico.cpp
test_playback.cpp test_playback.cpp
@@ -21,13 +21,13 @@ inline auto part_filename(std::string_view part) -> std::string {
return result + ".json"; return result + ".json";
} }
inline auto build_catalogue(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t { inline auto build_catalog(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
auto source = assets.string(); auto source = assets.string();
auto destination = database.string(); auto destination = database.string();
auto child = ::fork(); auto child = ::fork();
BOOST_REQUIRE(child >= 0); BOOST_REQUIRE(child >= 0);
if (child == 0) { if (child == 0) {
::execl(VAMP_TEST_PYTHON, VAMP_TEST_PYTHON, VAMP_TEST_SOURCE_DIR "/generator/catalogue.py", source.c_str(), destination.c_str(), nullptr); ::execl(VAMP_TEST_PYTHON, VAMP_TEST_PYTHON, VAMP_TEST_SOURCE_DIR "/generator/catalog.py", source.c_str(), destination.c_str(), nullptr);
::_exit(127); ::_exit(127);
} }
auto status = std::int32_t{}; auto status = std::int32_t{};
+12 -11
View File
@@ -41,7 +41,7 @@
#include <vamp_zero/score.hpp> #include <vamp_zero/score.hpp>
#include <vamp_zero/types.hpp> #include <vamp_zero/types.hpp>
#include "catalogue_fixture.hpp" #include "catalog_fixture.hpp"
namespace { namespace {
@@ -137,7 +137,7 @@ auto navigation_state() -> vamp_zero::app_state {
auto practice = vamp_zero::group_practice_exercises({exercise}); auto practice = vamp_zero::group_practice_exercises({exercise});
BOOST_REQUIRE(practice); BOOST_REQUIRE(practice);
return vamp_zero::app_state{ return vamp_zero::app_state{
.catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{ .catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::catalog{
.performance = vamp_zero::group_library_parts({part}), .performance = vamp_zero::group_library_parts({part}),
.practice = std::move(*practice), .practice = std::move(*practice),
}), }),
@@ -157,6 +157,7 @@ BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
BOOST_REQUIRE(parsed); BOOST_REQUIRE(parsed);
BOOST_TEST(parsed->runtime.headless); BOOST_TEST(parsed->runtime.headless);
BOOST_TEST(!parsed->audio.enabled); BOOST_TEST(!parsed->audio.enabled);
BOOST_TEST(parsed->database.path == "/usr/share/product-vamp/catalog.db");
auto invalid = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":"yes"}})json")); auto invalid = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":"yes"}})json"));
BOOST_TEST(!invalid); BOOST_TEST(!invalid);
} }
@@ -274,7 +275,7 @@ BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
BOOST_TEST(!state.audio.tempo_editing); BOOST_TEST(!state.audio.tempo_editing);
} }
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalog) {
auto count = std::size_t{}; auto count = std::size_t{};
for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) { for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
if (entry.path().extension() != ".json") { if (entry.path().extension() != ".json") {
@@ -294,7 +295,7 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
BOOST_AUTO_TEST_CASE(practice_part_navigation_and_remote_display) { BOOST_AUTO_TEST_CASE(practice_part_navigation_and_remote_display) {
auto state = navigation_state(); auto state = navigation_state();
auto catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog); auto catalog = std::make_shared<vamp_zero::catalog>(*state.catalog);
auto &practice_part = catalog->practice.exercises.front().parts[2]; auto &practice_part = catalog->practice.exercises.front().parts[2];
practice_part.source_path = "exercise-vocal.json"; practice_part.source_path = "exercise-vocal.json";
practice_part.total_pages = 3U; practice_part.total_pages = 3U;
@@ -350,9 +351,9 @@ BOOST_AUTO_TEST_CASE(practice_part_navigation_and_remote_display) {
BOOST_REQUIRE(vamp_zero::validate_app_state(state)); BOOST_REQUIRE(vamp_zero::validate_app_state(state));
} }
BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) { BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalog_returns_to_main) {
auto state = navigation_state(); auto state = navigation_state();
auto catalog = std::make_shared<vamp_zero::catalogue>(*state.catalog); auto catalog = std::make_shared<vamp_zero::catalog>(*state.catalog);
auto first = catalog->practice.exercises.front(); auto first = catalog->practice.exercises.front();
for (auto index = std::uint32_t{2}; index <= 366U; ++index) { for (auto index = std::uint32_t{2}; index <= 366U; ++index) {
auto exercise = first; auto exercise = first;
@@ -369,7 +370,7 @@ BOOST_AUTO_TEST_CASE(flat_practice_wraps_and_empty_catalogue_returns_to_main) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_PART_MENU);
state.catalog = vamp_zero::empty_catalogue(); state.catalog = vamp_zero::empty_catalog();
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU);
BOOST_TEST(state.selected_practice_exercise_index == 0U); BOOST_TEST(state.selected_practice_exercise_index == 0U);
@@ -389,7 +390,7 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_parts) {
} }
BOOST_REQUIRE(expected_exercises > 0U); BOOST_REQUIRE(expected_exercises > 0U);
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) { for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
config.path = (std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalogue.db").string(); config.path = (std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalog.db").string();
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future); auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
io.run(); io.run();
@@ -434,7 +435,7 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
write_text(assets / "practice" / name / vamp_test::part_filename(part_name), score_asset_json("Shared Title", "C", std::string{part_name})); write_text(assets / "practice" / name / vamp_test::part_filename(part_name), score_asset_json("Shared Title", "C", std::string{part_name}));
} }
} }
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path)); BOOST_REQUIRE(vamp_test::build_catalog(assets, config.path));
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, assets.string()), boost::asio::use_future); auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, assets.string()), boost::asio::use_future);
io.run(); io.run();
@@ -448,9 +449,9 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
for (auto const &exercise : library->exercises) { for (auto const &exercise : library->exercises) {
BOOST_TEST(exercise.title == "Shared Title"); BOOST_TEST(exercise.title == "Shared Title");
} }
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path)); BOOST_REQUIRE(vamp_test::build_catalog(assets, config.path));
std::filesystem::rename(assets / "practice/002", assets / "practice/099"); std::filesystem::rename(assets / "practice/002", assets / "practice/099");
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path)); BOOST_REQUIRE(vamp_test::build_catalog(assets, config.path));
auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id"); auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id");
BOOST_REQUIRE(rows); BOOST_REQUIRE(rows);
BOOST_REQUIRE_EQUAL(rows->rows.size(), 12U); BOOST_REQUIRE_EQUAL(rows->rows.size(), 12U);
@@ -11,7 +11,7 @@ from generator.parts import PART_TYPES
from unittest.mock import patch from unittest.mock import patch
from tests.test_musicxml import xml_note, xml_score from tests.test_musicxml import xml_note, xml_score
class CatalogueTests(unittest.TestCase): class CatalogTests(unittest.TestCase):
def test_every_numbered_source_is_wired_to_three_part_paths(self): def test_every_numbered_source_is_wired_to_three_part_paths(self):
source = Path('data/practice') source = Path('data/practice')
sources = sorted(source.glob('*.xml')) sources = sorted(source.glob('*.xml'))
@@ -5,7 +5,7 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from generator.catalogue import APPLICATION_ID, SCHEMA_VERSION, build_catalogue from generator.catalog import APPLICATION_ID, SCHEMA_VERSION, build_catalog
def bundle(title='Fixture', key='C', part='Lead'): def bundle(title='Fixture', key='C', part='Lead'):
return { return {
@@ -21,14 +21,14 @@ def write_score(root, relative, document=None):
path.write_text(json.dumps(document or bundle()), encoding='utf-8') path.write_text(json.dumps(document or bundle()), encoding='utf-8')
return path return path
class CatalogueTests(unittest.TestCase): class CatalogTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup) self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) self.root = Path(self.temporary.name)
(self.root / 'songs').mkdir() (self.root / 'songs').mkdir()
def rows(self, table): def rows(self, table):
with closing(sqlite3.connect(self.root / 'catalogue.db')) as connection: with closing(sqlite3.connect(self.root / 'catalog.db')) as connection:
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')] return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')]
def test_metadata_relative_paths_and_complete_documents(self): def test_metadata_relative_paths_and_complete_documents(self):
@@ -36,7 +36,8 @@ class CatalogueTests(unittest.TestCase):
write_score(self.root, 'songs/tune.json', document) write_score(self.root, 'songs/tune.json', document)
for part in ('Acoustic', 'Electric', 'Vocal'): for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/001/{part.lower()}.json', bundle('Exercise', 'Eb', part)) write_score(self.root, f'practice/001/{part.lower()}.json', bundle('Exercise', 'Eb', part))
database = build_catalogue(self.root) database = build_catalog(self.root)
self.assertEqual(database, self.root / 'catalog.db')
song = self.rows('songs')[0] song = self.rows('songs')[0]
self.assertEqual(json.loads(song['source_json']), document) self.assertEqual(json.loads(song['source_json']), document)
self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json')) self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json'))
@@ -57,62 +58,65 @@ class CatalogueTests(unittest.TestCase):
self.assertFalse(Path(str(database) + '-journal').exists()) self.assertFalse(Path(str(database) + '-journal').exists())
def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self): def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self):
score = write_score(self.root, 'songs/tune.json') score = write_score(self.root, 'songs/tune.json')
database = build_catalogue(self.root) database = build_catalog(self.root)
previous = database.read_bytes() previous = database.read_bytes()
build_catalogue(self.root) build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous) self.assertEqual(database.read_bytes(), previous)
with tempfile.TemporaryDirectory() as other: with tempfile.TemporaryDirectory() as other:
root = Path(other) root = Path(other)
write_score(root, 'songs/tune.json') write_score(root, 'songs/tune.json')
self.assertEqual(build_catalogue(root).read_bytes(), previous) self.assertEqual(build_catalog(root).read_bytes(), previous)
score.rename(score.with_name('renamed.json')) score.rename(score.with_name('renamed.json'))
build_catalogue(self.root) build_catalog(self.root)
self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed']) self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed'])
score.with_name('renamed.json').unlink() score.with_name('renamed.json').unlink()
build_catalogue(self.root) build_catalog(self.root)
self.assertEqual(self.rows('songs'), []) self.assertEqual(self.rows('songs'), [])
def test_invalid_input_preserves_existing_database(self): def test_invalid_input_preserves_existing_database(self):
score = write_score(self.root, 'songs/tune.json') score = write_score(self.root, 'songs/tune.json')
database = build_catalogue(self.root) database = build_catalog(self.root)
previous = database.read_bytes() previous = database.read_bytes()
for document in ({}, bundle(title=''), bundle(key=None)): for document in ({}, bundle(title=''), bundle(key=None)):
score.write_text(json.dumps(document)) score.write_text(json.dumps(document))
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
build_catalogue(self.root) build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous) self.assertEqual(database.read_bytes(), previous)
self.assertFalse(list(self.root.glob('.vamp-catalogue-*'))) self.assertFalse(list(self.root.glob('.vamp-catalog-*')))
def test_missing_parts_and_metadata_disagreement_fail(self): def test_missing_parts_and_metadata_disagreement_fail(self):
for part in ('Acoustic', 'Electric'): for part in ('Acoustic', 'Electric'):
write_score(self.root, f'practice/001/{part.lower()}.json', bundle(part=part)) write_score(self.root, f'practice/001/{part.lower()}.json', bundle(part=part))
with self.assertRaisesRegex(ValueError, 'all three parts'): with self.assertRaisesRegex(ValueError, 'all three parts'):
build_catalogue(self.root) build_catalog(self.root)
write_score(self.root, 'practice/001/vocal.json', bundle(key='F', part='Vocal')) write_score(self.root, 'practice/001/vocal.json', bundle(key='F', part='Vocal'))
with self.assertRaisesRegex(ValueError, 'disagree'): with self.assertRaisesRegex(ValueError, 'disagree'):
build_catalogue(self.root) build_catalog(self.root)
def test_keyless_parts_preserve_their_original_key(self): def test_keyless_parts_preserve_their_original_key(self):
for part in ('Acoustic', 'Electric', 'Vocal'): for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/001/{part.lower()}.json', bundle(key='No key', part=part)) write_score(self.root, f'practice/001/{part.lower()}.json', bundle(key='No key', part=part))
build_catalogue(self.root) build_catalog(self.root)
self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises'))) self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises')))
def test_invalid_layout_and_escaping_source_fail(self): def test_invalid_layout_and_escaping_source_fail(self):
with self.assertRaisesRegex(ValueError, 'directory not found'): with self.assertRaisesRegex(ValueError, 'directory not found'):
build_catalogue(self.root / 'missing') build_catalog(self.root / 'missing')
self.assertFalse((self.root / 'missing').exists()) self.assertFalse((self.root / 'missing').exists())
score = write_score(self.root, 'songs/nested/tune.json') score = write_score(self.root, 'songs/nested/tune.json')
with self.assertRaisesRegex(ValueError, 'unexpected songs'): with self.assertRaisesRegex(ValueError, 'unexpected songs'):
build_catalogue(self.root) build_catalog(self.root)
score.unlink() score.unlink()
(self.root / 'songs/escape.json').symlink_to('/etc/passwd') (self.root / 'songs/escape.json').symlink_to('/etc/passwd')
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
build_catalogue(self.root) build_catalog(self.root)
def test_runtime_asset_packaging_excludes_score_json(self): def test_runtime_asset_packaging_excludes_score_json(self):
import subprocess import subprocess
write_score(self.root, 'songs/tune.json') write_score(self.root, 'songs/tune.json')
build_catalogue(self.root) build_catalog(self.root)
(self.root / 'songs/tune.png').write_bytes(b'page') (self.root / 'songs/tune.png').write_bytes(b'page')
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
destination = Path(temporary) destination = Path(temporary)
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True) subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
self.assertTrue((destination / 'catalogue.db').exists()) self.assertTrue((destination / 'catalog.db').exists())
for name in ('vamp.example.json', 'vamp.development.json'):
config = json.loads((Path('package/config') / name).read_text())
self.assertEqual(Path(config['database']['path']).name, 'catalog.db')
self.assertTrue((destination / 'songs/tune.png').exists()) self.assertTrue((destination / 'songs/tune.png').exists())
self.assertFalse(list(destination.rglob('*.json'))) self.assertFalse(list(destination.rglob('*.json')))
@@ -29,7 +29,7 @@
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/portal.hpp> #include <vamp_zero/portal.hpp>
#include "catalogue_fixture.hpp" #include "catalog_fixture.hpp"
namespace { namespace {
@@ -40,30 +40,30 @@ static_assert(!retains_score_json<vamp_zero::library_part>);
static_assert(!retains_score_json<vamp_zero::practice_exercise>); static_assert(!retains_score_json<vamp_zero::practice_exercise>);
static_assert(!retains_score_json<vamp_zero::library_part>); static_assert(!retains_score_json<vamp_zero::library_part>);
struct catalogue_directory { struct catalog_directory {
std::filesystem::path path; std::filesystem::path path;
catalogue_directory() { catalog_directory() {
auto pattern = (std::filesystem::temp_directory_path() / "vamp-catalogue-XXXXXX").string(); auto pattern = (std::filesystem::temp_directory_path() / "vamp-catalog-XXXXXX").string();
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr); BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
path = pattern; path = pattern;
} }
~catalogue_directory() { ~catalog_directory() {
auto ignored = std::error_code{}; auto ignored = std::error_code{};
std::filesystem::remove_all(path, ignored); std::filesystem::remove_all(path, ignored);
} }
}; };
template <typename Function, typename... Arguments> template <typename Function, typename... Arguments>
auto run_catalogue_operation(Function function, Arguments... arguments) { auto run_catalog_operation(Function function, Arguments... arguments) {
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, function(io.get_executor(), std::move(arguments)...), boost::asio::use_future); auto future = boost::asio::co_spawn(io, function(io.get_executor(), std::move(arguments)...), boost::asio::use_future);
io.run(); io.run();
return future.get(); return future.get();
} }
auto catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string sql, std::vector<noto::sqlite_value> params) auto catalog_query(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string sql, std::vector<noto::sqlite_value> params)
-> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> { -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
auto connection = noto::connection{executor, noto::file_connection_config(config.path, "vamp-catalogue-test")}; auto connection = noto::connection{executor, noto::file_connection_config(config.path, "vamp-catalog-test")};
auto connected = co_await connection.async_connect(); auto connected = co_await connection.async_connect();
if (!connected) { if (!connected) {
co_return std::unexpected(connected.error()); co_return std::unexpected(connected.error());
@@ -79,7 +79,7 @@ auto catalogue_query(boost::asio::any_io_executor executor, vamp_zero::database_
co_return result; co_return result;
} }
auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string { auto catalog_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string {
auto value = boost::json::parse(R"json({ auto value = boost::json::parse(R"json({
"schema_version":3, "schema_version":3,
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, "music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
@@ -95,15 +95,15 @@ auto catalogue_bundle(std::string_view title, std::string_view key = "C", std::s
return boost::json::serialize(value); return boost::json::serialize(value);
} }
struct catalogue_database { struct catalog_database {
catalogue_directory directory; catalog_directory directory;
std::filesystem::path assets{directory.path / "assets"}; std::filesystem::path assets{directory.path / "assets"};
vamp_zero::database_config config{.path = (directory.path / "catalogue.db").string()}; vamp_zero::database_config config{.path = (directory.path / "catalog.db").string()};
catalogue_database() { catalog_database() {
std::filesystem::create_directories(assets / "songs"); std::filesystem::create_directories(assets / "songs");
} }
auto query(std::string sql, std::vector<noto::sqlite_value> params = {}) const -> noto::query_result { auto query(std::string sql, std::vector<noto::sqlite_value> params = {}) const -> noto::query_result {
auto result = run_catalogue_operation(catalogue_query, config, std::move(sql), std::move(params)); auto result = run_catalog_operation(catalog_query, config, std::move(sql), std::move(params));
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe())); BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
return std::move(*result); return std::move(*result);
} }
@@ -126,20 +126,20 @@ struct catalogue_database {
BOOST_REQUIRE(output.good()); BOOST_REQUIRE(output.good());
} }
auto build() const -> std::bool_t { auto build() const -> std::bool_t {
return vamp_test::build_catalogue(assets, config.path); return vamp_test::build_catalog(assets, config.path);
} }
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> { auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_performance_library, config, assets.string()); return run_catalog_operation(vamp_zero::async_load_performance_library, config, assets.string());
} }
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> { auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_practice_library, config, assets.string()); return run_catalog_operation(vamp_zero::async_load_practice_library, config, assets.string());
} }
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> { auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string()); return run_catalog_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string());
} }
}; };
auto catalogue_exercise(std::string authored = "Eb", std::string id = "001") -> vamp_zero::practice_exercise { auto catalog_exercise(std::string authored = "Eb", std::string id = "001") -> vamp_zero::practice_exercise {
auto result = vamp_zero::practice_exercise{ auto result = vamp_zero::practice_exercise{
.id = std::move(id), .id = std::move(id),
.title = "Exercise", .title = "Exercise",
@@ -153,14 +153,14 @@ auto catalogue_exercise(std::string authored = "Eb", std::string id = "001") ->
} }
BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutable_metadata) { BOOST_AUTO_TEST_CASE(catalog_snapshots_heartbeat_and_navigation_share_immutable_metadata) {
auto performance = vamp_zero::group_library_parts({ auto performance = vamp_zero::group_library_parts({
{.id = "other", .title = "A Tune", .composer = "Composer", .part = "Lead", .total_pages = 1}, {.id = "other", .title = "A Tune", .composer = "Composer", .part = "Lead", .total_pages = 1},
{.id = "chosen", .title = "B Tune", .composer = "Composer", .part = "Lead", .total_pages = 2}, {.id = "chosen", .title = "B Tune", .composer = "Composer", .part = "Lead", .total_pages = 2},
}); });
auto practice = vamp_zero::group_practice_exercises({catalogue_exercise("Eb", "002"), catalogue_exercise("Eb", "001")}); auto practice = vamp_zero::group_practice_exercises({catalog_exercise("Eb", "002"), catalog_exercise("Eb", "001")});
BOOST_REQUIRE(practice); BOOST_REQUIRE(practice);
auto catalog = std::make_shared<vamp_zero::catalogue const>(vamp_zero::catalogue{std::move(performance), std::move(*practice)}); auto catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::catalog{std::move(performance), std::move(*practice)});
auto original = vamp_zero::app_state{ auto original = vamp_zero::app_state{
.catalog = catalog, .catalog = catalog,
.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU, .mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU,
@@ -211,7 +211,7 @@ BOOST_AUTO_TEST_CASE(catalogue_snapshots_heartbeat_and_navigation_share_immutabl
BOOST_TEST(parts.selected_practice_part_index == 0U); BOOST_TEST(parts.selected_practice_part_index == 0U);
} }
BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_part_indices) { BOOST_AUTO_TEST_CASE(catalog_performance_groups_use_title_and_composer_with_part_indices) {
auto library = vamp_zero::group_library_parts({ auto library = vamp_zero::group_library_parts({
{.id = "other-composer", .title = "Shared Title", .composer = "Bea", .part = "Lead"}, {.id = "other-composer", .title = "Shared Title", .composer = "Bea", .part = "Lead"},
{.id = "vocal", .title = "Shared Title", .composer = "Ada", .part = "Vocal"}, {.id = "vocal", .title = "Shared Title", .composer = "Ada", .part = "Vocal"},
@@ -242,15 +242,15 @@ BOOST_AUTO_TEST_CASE(catalogue_performance_groups_use_title_and_composer_with_pa
BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal"); BOOST_TEST(library.parts[library.groups[1].parts[1]].id == "vocal");
} }
BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_parts_and_key_agreement) { BOOST_AUTO_TEST_CASE(catalog_practice_requires_complete_ordered_parts_and_key_agreement) {
for (auto authored : {"Eb", "C dorian", "No key", ""}) { for (auto authored : {"Eb", "C dorian", "No key", ""}) {
auto first = catalogue_exercise(authored); auto first = catalog_exercise(authored);
std::reverse(first.parts.begin(), first.parts.end()); std::reverse(first.parts.begin(), first.parts.end());
auto last = first.parts.back(); auto last = first.parts.back();
first.parts.pop_back(); first.parts.pop_back();
auto fragment = catalogue_exercise(authored); auto fragment = catalog_exercise(authored);
fragment.parts = {last}; fragment.parts = {last};
auto grouped = vamp_zero::group_practice_exercises({catalogue_exercise("F", "002"), first, fragment}); auto grouped = vamp_zero::group_practice_exercises({catalog_exercise("F", "002"), first, fragment});
BOOST_REQUIRE(grouped); BOOST_REQUIRE(grouped);
BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U); BOOST_REQUIRE_EQUAL(grouped->exercises.size(), 2U);
auto const &exercise = grouped->exercises[0]; auto const &exercise = grouped->exercises[0];
@@ -262,8 +262,8 @@ BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_parts_and_key_
} }
} }
for (auto conflict : {"F", ""}) { for (auto conflict : {"F", ""}) {
auto first = catalogue_exercise(); auto first = catalog_exercise();
auto second = catalogue_exercise(conflict); auto second = catalog_exercise(conflict);
second.parts = {second.parts.back()}; second.parts = {second.parts.back()};
first.parts.pop_back(); first.parts.pop_back();
auto grouped = vamp_zero::group_practice_exercises({first, second}); auto grouped = vamp_zero::group_practice_exercises({first, second});
@@ -271,21 +271,21 @@ BOOST_AUTO_TEST_CASE(catalogue_practice_requires_complete_ordered_parts_and_key_
BOOST_TEST(grouped.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT)); BOOST_TEST(grouped.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(grouped.error().message().find("disagree on key") != std::string_view::npos); BOOST_TEST(grouped.error().message().find("disagree on key") != std::string_view::npos);
} }
auto missing = catalogue_exercise(); auto missing = catalog_exercise();
missing.parts.pop_back(); missing.parts.pop_back();
BOOST_TEST(!vamp_zero::group_practice_exercises({missing})); BOOST_TEST(!vamp_zero::group_practice_exercises({missing}));
auto duplicate = catalogue_exercise(); auto duplicate = catalog_exercise();
duplicate.parts.back().part = "Acoustic"; duplicate.parts.back().part = "Acoustic";
BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate})); BOOST_TEST(!vamp_zero::group_practice_exercises({duplicate}));
} }
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) { BOOST_AUTO_TEST_CASE(catalog_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
auto db = catalogue_database{}; auto db = catalog_database{};
auto song = catalogue_bundle("Song", "F"); auto song = catalog_bundle("Song", "F");
auto exercise = catalogue_bundle("Exercise", "D", "Electric"); auto exercise = catalog_bundle("Exercise", "D", "Electric");
db.write("songs/shared.json", song); db.write("songs/shared.json", song);
for (auto part : vamp_zero::practice_part_names()) { for (auto part : vamp_zero::practice_part_names()) {
db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part), catalogue_bundle("Exercise", "D", part)); db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part), catalog_bundle("Exercise", "D", part));
} }
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
db.query("UPDATE practice_exercises SET id = 'shared' WHERE part = 'Electric'"); db.query("UPDATE practice_exercises SET id = 'shared' WHERE part = 'Electric'");
@@ -317,9 +317,9 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
} }
} }
BOOST_AUTO_TEST_CASE(catalogue_sequential_score_loads_keep_executor_stack_bounded) { BOOST_AUTO_TEST_CASE(catalog_sequential_score_loads_keep_executor_stack_bounded) {
auto db = catalogue_database{}; auto db = catalog_database{};
db.write("songs/repeated.json", catalogue_bundle("Repeated")); db.write("songs/repeated.json", catalog_bundle("Repeated"));
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets) auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets)
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> { -> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
@@ -332,13 +332,13 @@ BOOST_AUTO_TEST_CASE(catalogue_sequential_score_loads_keep_executor_stack_bounde
} }
co_return 4096U; co_return 4096U;
}; };
auto result = run_catalogue_operation(repeated_loads, db.config, db.assets.string()); auto result = run_catalog_operation(repeated_loads, db.config, db.assets.string());
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe())); BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
BOOST_TEST(*result == 4096U); BOOST_TEST(*result == 4096U);
} }
BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score_json) { BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_json) {
auto db = catalogue_database{}; auto db = catalog_database{};
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
// Complete cached metadata must not require parsing or retaining the score body. // Complete cached metadata must not require parsing or retaining the score body.
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, " db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
@@ -388,9 +388,9 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos); BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos);
} }
BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_files_without_repair) { BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_legacy_and_corrupt_files_without_repair) {
auto db = catalogue_database{}; auto db = catalog_database{};
auto validate = [&] { return run_catalogue_operation(vamp_zero::async_validate_database, db.config, true); }; auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, true); };
BOOST_TEST(!validate()); BOOST_TEST(!validate());
BOOST_TEST(!std::filesystem::exists(db.config.path)); BOOST_TEST(!std::filesystem::exists(db.config.path));
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
@@ -419,11 +419,11 @@ BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_fil
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal")); BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
} }
BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves_relocated_assets) { BOOST_AUTO_TEST_CASE(catalog_reads_readonly_database_without_json_and_resolves_relocated_assets) {
auto db = catalogue_database{}; auto db = catalog_database{};
db.write("songs/tune.json", catalogue_bundle("Relocated")); db.write("songs/tune.json", catalog_bundle("Relocated"));
for (auto part_name : vamp_zero::practice_part_names()) { for (auto part_name : vamp_zero::practice_part_names()) {
db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part_name), catalogue_bundle("Exercise", "Eb", part_name)); db.write(std::filesystem::path{"practice/001"} / vamp_test::part_filename(part_name), catalog_bundle("Exercise", "Eb", part_name));
} }
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
std::filesystem::remove(db.assets / "songs/tune.json"); std::filesystem::remove(db.assets / "songs/tune.json");
@@ -433,7 +433,7 @@ BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves
db.assets = moved; db.assets = moved;
auto timestamp = std::filesystem::last_write_time(db.config.path); auto timestamp = std::filesystem::last_write_time(db.config.path);
std::filesystem::permissions(db.config.path, std::filesystem::perms::owner_read); std::filesystem::permissions(db.config.path, std::filesystem::perms::owner_read);
BOOST_REQUIRE(run_catalogue_operation(vamp_zero::async_validate_database, db.config, true)); BOOST_REQUIRE(run_catalog_operation(vamp_zero::async_validate_database, db.config, true));
auto songs = db.performance(); auto songs = db.performance();
auto practice = db.practice(); auto practice = db.practice();
auto score = db.score(vamp_zero::score_category::PERFORMANCE, "tune"); auto score = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
@@ -449,9 +449,9 @@ BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal")); BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
} }
BOOST_AUTO_TEST_CASE(catalogue_rejects_paths_outside_the_asset_root) { BOOST_AUTO_TEST_CASE(catalog_rejects_paths_outside_the_asset_root) {
auto db = catalogue_database{}; auto db = catalog_database{};
db.write("songs/tune.json", catalogue_bundle("Fixture")); db.write("songs/tune.json", catalog_bundle("Fixture"));
BOOST_REQUIRE(db.build()); BOOST_REQUIRE(db.build());
for (auto path : {"/tmp/tune.json", "../tune.json", "songs/../../tune.json", "songs/./tune.json", "songs\\tune.json", ""}) { for (auto path : {"/tmp/tune.json", "../tune.json", "songs/../../tune.json", "songs/./tune.json", "songs\\tune.json", ""}) {
if (std::string_view{path}.empty()) { if (std::string_view{path}.empty()) {
+7 -7
View File
@@ -29,7 +29,7 @@
#include <vamp_zero/health.hpp> #include <vamp_zero/health.hpp>
#include <vamp_zero/portal.hpp> #include <vamp_zero/portal.hpp>
#include "catalogue_fixture.hpp" #include "catalog_fixture.hpp"
namespace { namespace {
@@ -189,7 +189,7 @@ auto make_assets(vamp_zero::config const &config) -> void {
name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a'); name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a');
write_score(root / "practice" / "001" / (name + ".json"), std::string{part_name}); write_score(root / "practice" / "001" / (name + ".json"), std::string{part_name});
} }
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path)); BOOST_REQUIRE(vamp_test::build_catalog(root, config.database.path));
} }
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> { auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
@@ -336,7 +336,7 @@ BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_backgroun
} }
} }
BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_heartbeat) { BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalog_without_hardware_or_heartbeat) {
auto root = temporary_directory{}; auto root = temporary_directory{};
auto config = fixture_config(root.path); auto config = fixture_config(root.path);
make_assets(config); make_assets(config);
@@ -399,7 +399,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
auto score = boost::json::parse(read_file(path)); auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback"); score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score)); write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path)); BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
auto ready = run_once(root.path, config); auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe())); BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto &sample = score.at("playback").at("samples").as_array().front().as_object(); auto &sample = score.at("playback").at("samples").as_array().front().as_object();
@@ -409,7 +409,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
sample["sha256"] = checksum(corrupt); sample["sha256"] = checksum(corrupt);
write_file(audio, corrupt); write_file(audio, corrupt);
write_file(path, boost::json::serialize(score)); write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path)); BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
ready = run_once(root.path, config); ready = run_once(root.path, config);
BOOST_REQUIRE(!ready); BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos); BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
@@ -437,7 +437,7 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
} }
} }
BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) { BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogs) {
auto root = temporary_directory{}; auto root = temporary_directory{};
auto config = fixture_config(root.path); auto config = fixture_config(root.path);
BOOST_TEST(!run_once(root.path, config)); BOOST_TEST(!run_once(root.path, config));
@@ -479,7 +479,7 @@ BOOST_AUTO_TEST_CASE(readiness_decodes_pages_even_when_checksums_match) {
write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes); write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes);
page["sha256"] = checksum(bytes); page["sha256"] = checksum(bytes);
write_file(score_path, boost::json::serialize(score)); write_file(score_path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(config.runtime.assets_path, config.database.path)); BOOST_REQUIRE(vamp_test::build_catalog(config.runtime.assets_path, config.database.path));
auto ready = run_once(root.path, config); auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(!ready, kind); BOOST_REQUIRE_MESSAGE(!ready, kind);
BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos); BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos);
+5 -5
View File
@@ -22,9 +22,9 @@ class MusicModelTests(unittest.TestCase):
engraving = patch('generator.__main__.engrave_tree') engraving = patch('generator.__main__.engrave_tree')
engraving.start() engraving.start()
self.addCleanup(engraving.stop) self.addCleanup(engraving.stop)
catalogue = patch('generator.__main__.build_catalogue') catalog = patch('generator.__main__.build_catalog')
catalogue.start() catalog.start()
self.addCleanup(catalogue.stop) self.addCleanup(catalog.stop)
def test_chord_semantics_are_not_voicing_heuristics(self): def test_chord_semantics_are_not_voicing_heuristics(self):
cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7}, cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7},
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}} 'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}
@@ -35,7 +35,7 @@ class MusicModelTests(unittest.TestCase):
pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0]) pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0])
self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label) self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
harmony.parse_symbol('Cunrecognised') harmony.parse_symbol('Cunrecognized')
def test_transposition_preserves_spelling_octaves_modes(self): def test_transposition_preserves_spelling_octaves_modes(self):
for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}): for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}):
p['alter'] = rational(p['alter']) p['alter'] = rational(p['alter'])
@@ -101,7 +101,7 @@ class MusicModelTests(unittest.TestCase):
for text in ('Med.', 'Med. Swing', 'Medium', 'Medium Swing'): for text in ('Med.', 'Med. Swing', 'Medium', 'Medium Swing'):
self.assertEqual(canonical_style(text), 'Medium Swing') self.assertEqual(canonical_style(text), 'Medium Swing')
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
canonical_style('Unrecognised style') canonical_style('Unrecognized style')
def test_failure_preserves_generated_assets(self): def test_failure_preserves_generated_assets(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
+1 -1
View File
@@ -43,7 +43,7 @@ class MusicXMLTests(unittest.TestCase):
score = load_musicxml(Path('tests/fixtures/notation.xml')) score = load_musicxml(Path('tests/fixtures/notation.xml'))
self.assertEqual(len(score['staves'][0]['measures']), 4) self.assertEqual(len(score['staves'][0]['measures']), 4)
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1) self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
def test_catalogue_imports(self): def test_catalog_imports(self):
sources = sorted(Path('data').rglob('*.xml')) sources = sorted(Path('data').rglob('*.xml'))
self.assertTrue(sources) self.assertTrue(sources)
for path in sources: for path in sources:
+1 -1
View File
@@ -100,7 +100,7 @@ class PartGenerationTests(unittest.TestCase):
(root / 'data' / category).mkdir(parents=True) (root / 'data' / category).mkdir(parents=True)
(root / 'data' / category / (name + '.xml')).write_text(xml_score(xml_harmony() + xml_note())) (root / 'data' / category / (name + '.xml')).write_text(xml_score(xml_harmony() + xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=Path('package/assets'), check=False) args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=Path('package/assets'), check=False)
with patch('generator.arrange.generate.load_musicxml', wraps=load_musicxml) as song_load, patch('generator.practice.generate.load_musicxml', wraps=load_musicxml) as practice_load, patch('generator.parts.concert_document', wraps=concert_document) as normalize, patch('generator.__main__.engrave_tree'), patch('generator.__main__.build_catalogue'), patch('generator.__main__.compile_tree'): with patch('generator.arrange.generate.load_musicxml', wraps=load_musicxml) as song_load, patch('generator.practice.generate.load_musicxml', wraps=load_musicxml) as practice_load, patch('generator.parts.concert_document', wraps=concert_document) as normalize, patch('generator.__main__.engrave_tree'), patch('generator.__main__.build_catalog'), patch('generator.__main__.compile_tree'):
generate_assets(args) generate_assets(args)
song_load.assert_called_once() song_load.assert_called_once()
practice_load.assert_called_once() practice_load.assert_called_once()
+3 -3
View File
@@ -22,7 +22,7 @@ auto playback_score() -> vamp_zero::score_document {
} }
auto performance_state() -> vamp_zero::app_state { auto performance_state() -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalogue>(); auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U}); catalog->performance.parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
catalog->performance.groups.push_back({.title = "Song", .parts = {0U}}); catalog->performance.groups.push_back({.title = "Song", .parts = {0U}});
auto state = vamp_zero::app_state{}; auto state = vamp_zero::app_state{};
@@ -71,7 +71,7 @@ BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
auto state = vamp_zero::control_state{{}, performance_state()}; auto state = vamp_zero::control_state{{}, performance_state()};
state.finish_playback(41U); state.finish_playback(41U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE); BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalogue const>()); state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalog const>());
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE); BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, "sample missing"); state.finish_playback(42U, "sample missing");
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE); BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
@@ -91,7 +91,7 @@ BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE); BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
} }
BOOST_AUTO_TEST_CASE(playback_worker_discards_cancelled_preparation_and_reports_errors_once) { BOOST_AUTO_TEST_CASE(playback_worker_discards_canceled_preparation_and_reports_errors_once) {
auto engine = sonus::engine::create({.no_device = true}); auto engine = sonus::engine::create({.no_device = true});
BOOST_REQUIRE(engine); BOOST_REQUIRE(engine);
auto audio = vamp_zero::audio_controller{{}, std::move(*engine)}; auto audio = vamp_zero::audio_controller{{}, std::move(*engine)};
+1 -1
View File
@@ -158,7 +158,7 @@ class ScoreBundleTests(unittest.TestCase):
source['staves'][0]['instruments'] = [] source['staves'][0]['instruments'] = []
with self.assertRaisesRegex(ValueError, 'unknown score instrument'): with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
validate_document(source) validate_document(source)
def test_catalogue_roundtrip_preserves_written_pitch_and_rhythm(self): def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in Path('data').rglob('*.xml'): for path in Path('data').rglob('*.xml'):
with self.subTest(path=path): with self.subTest(path=path):
source = load_musicxml(path) source = load_musicxml(path)
+6 -6
View File
@@ -64,7 +64,7 @@ struct controlled_scores {
struct loader_fixture { struct loader_fixture {
boost::asio::io_context io{}; boost::asio::io_context io{};
std::shared_ptr<controlled_scores> scores{std::make_shared<controlled_scores>(io)}; std::shared_ptr<controlled_scores> scores{std::make_shared<controlled_scores>(io)};
std::shared_ptr<vamp_zero::catalogue const> catalog{std::make_shared<vamp_zero::catalogue const>()}; std::shared_ptr<vamp_zero::catalog const> catalog{std::make_shared<vamp_zero::catalog const>()};
std::shared_ptr<std::size_t> completions{std::make_shared<std::size_t>()}; std::shared_ptr<std::size_t> completions{std::make_shared<std::size_t>()};
vamp_zero::score_loader loader{io.get_executor(), vamp_zero::score_loader loader{io.get_executor(),
[scores = scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); }, [scores = scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); },
@@ -147,11 +147,11 @@ BOOST_AUTO_TEST_CASE(score_loader_stale_error_does_not_replace_pending_selection
BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "available"); BOOST_CHECK_EQUAL(fixture.loader.snapshot().document->id, "available");
} }
BOOST_AUTO_TEST_CASE(score_loader_catalogue_identity_and_category_invalidate_selection) { BOOST_AUTO_TEST_CASE(score_loader_catalog_identity_and_category_invalidate_selection) {
auto fixture = loader_fixture{}; auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("same-id")); fixture.loader.select(fixture.catalog, performance("same-id"));
drain(fixture.io); drain(fixture.io);
fixture.catalog = std::make_shared<vamp_zero::catalogue const>(); fixture.catalog = std::make_shared<vamp_zero::catalog const>();
fixture.loader.select(fixture.catalog, performance("same-id")); fixture.loader.select(fixture.catalog, performance("same-id"));
fixture.scores->complete(0); fixture.scores->complete(0);
drain(fixture.io); drain(fixture.io);
@@ -279,7 +279,7 @@ BOOST_AUTO_TEST_CASE(score_loader_destruction_during_load_does_not_call_consumer
auto loader = vamp_zero::score_loader{io.get_executor(), auto loader = vamp_zero::score_loader{io.get_executor(),
[scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); }, [scores](vamp_zero::score_reference reference) { return scores->load(std::move(reference)); },
[&] { ++completions; }}; [&] { ++completions; }};
loader.select(std::make_shared<vamp_zero::catalogue const>(), performance("a")); loader.select(std::make_shared<vamp_zero::catalog const>(), performance("a"));
drain(io); drain(io);
BOOST_REQUIRE_EQUAL(scores->requests.size(), 1U); BOOST_REQUIRE_EQUAL(scores->requests.size(), 1U);
} }
@@ -301,7 +301,7 @@ BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destr
if (auto target = weak.lock()) { if (auto target = weak.lock()) {
++*target; ++*target;
} }); } });
loader->select(std::make_shared<vamp_zero::catalogue const>(), performance("a")); loader->select(std::make_shared<vamp_zero::catalog const>(), performance("a"));
auto worker = std::thread{[&] { io.run(); }}; auto worker = std::thread{[&] { io.run(); }};
auto entered_status = entered_future.wait_for(std::chrono::seconds{5}); auto entered_status = entered_future.wait_for(std::chrono::seconds{5});
if (entered_status == std::future_status::ready) { if (entered_status == std::future_status::ready) {
@@ -317,7 +317,7 @@ BOOST_AUTO_TEST_CASE(score_loader_completion_can_overlap_stop_and_consumer_destr
} }
BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_part) { BOOST_AUTO_TEST_CASE(selected_score_reference_uses_active_part_and_practice_part) {
auto catalog = std::make_shared<vamp_zero::catalogue>(); auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}}; catalog->performance.parts = {{.id = "part-a"}, {.id = "part-b"}};
catalog->practice.exercises = {{.id = "scale", .parts = {{.id = "scale-c"}, {.id = "scale-d"}}}}; catalog->practice.exercises = {{.id = "scale", .parts = {{.id = "scale-c"}, {.id = "scale-d"}}}};
auto state = vamp_zero::app_state{.catalog = catalog}; auto state = vamp_zero::app_state{.catalog = catalog};